From c15421d5eb269871fddaa1c3596087cca745ae4c Mon Sep 17 00:00:00 2001 From: itswl Date: Tue, 3 Nov 2026 08:36:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?hookprobe:=20what=20a=20read-only=20node=20?= =?UTF-8?q?could=20still=20do=20=E2=80=94=20rewrite=20the=20files=20config?= =?UTF-8?q?uration=20points=20at,=20and=20read=20secret=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run could write its own MCP config when a deployment kept it on the run's volume, and every later run started the command it named: measured on the probe image with the work compose's setting and a recording stand-in model, with no tool call for the guards or the audit to see. The input guard now protects every configured file by its path on all three runtimes, the before-and-after digest covers them, and the startup posture names any the service user could rewrite (writable_inputs, paged once). The work compose reads both MCP configs from the read-only secrets mounts, and the configuration doc stops offering the volume. A read-only identity still reads secret values. Posture records reads_secrets beside the verdict (can-i get|list secrets, and a second simulation of Secrets Manager, SSM, KMS and S3 reads) without letting it decide anything, and the bash guard refuses printing a secret's value under every posture. Whether a secret exists and when it changed stays open. The console's posture card shows both. Read back from the branch image: the planted config is refused and nothing runs after it; with the config on a read-only mount the posture reports nothing and the kernel refuses the write. Two decision notes, four guard mutations; the 2026-09-09 note that left Secret reads allowed is corrected in place. --- ...-03-read-only-still-reads-secret-values.md | 57 ++++++++ ...iguration-points-at-steer-every-run-too.md | 57 ++++++++ ...e-openai-agents-sdk-was-worth-borrowing.md | 5 + deploy/docker-compose.work.yml | 29 ++-- deploy/sidecars/mcp.example.json | 2 +- docs/containment.md | 5 +- hookprobe/README.md | 32 ++++- hookprobe/docs/configuration.md | 11 +- hookprobe/hookprobe/app.py | 2 + hookprobe/hookprobe/engine.py | 28 +++- hookprobe/hookprobe/gate.py | 16 ++- hookprobe/hookprobe/guard.py | 51 +++++++ hookprobe/hookprobe/inputs.py | 75 +++++++++- hookprobe/hookprobe/posture.py | 131 +++++++++++++++++- hookprobe/hookprobe/ui.html | 16 ++- hookprobe/tests/test_guard.py | 61 ++++++++ hookprobe/tests/test_inputs.py | 126 +++++++++++++++++ hookprobe/tests/test_output_guard.py | 33 +++-- hookprobe/tests/test_posture.py | 131 ++++++++++++++++++ scripts/assert_guards_are_tested.py | 28 ++++ 20 files changed, 839 insertions(+), 57 deletions(-) create mode 100644 .agents/notes/implemented/2026-11-03-read-only-still-reads-secret-values.md create mode 100644 .agents/notes/implemented/2026-11-03-the-files-configuration-points-at-steer-every-run-too.md diff --git a/.agents/notes/implemented/2026-11-03-read-only-still-reads-secret-values.md b/.agents/notes/implemented/2026-11-03-read-only-still-reads-secret-values.md new file mode 100644 index 00000000..ef216bc3 --- /dev/null +++ b/.agents/notes/implemented/2026-11-03-read-only-still-reads-secret-values.md @@ -0,0 +1,57 @@ +--- +title: A read-only identity still reads secret values, so posture says how much and the guard refuses the obvious prints +status: implemented +date: 2026-11-03 +scope: hookprobe +--- + +## Decision + +Posture asks two reading questions beside the mutating ones and records the +answers as `reads_secrets`, without letting them touch the verdict. For a +cluster: `kubectl auth can-i get|list secrets -A`, and any `secrets` or +wildcard rule in `can-i --list`. For AWS: a second simulation of +`secretsmanager:GetSecretValue`, `ssm:GetParameter`, `kms:Decrypt` and +`s3:GetObject`. Their failures go to `read_errors`, never to the errors that +make a verdict unverifiable. + +The bash guard refuses printing a secret's value under every posture, before +the posture branch. That covers a Secret's data from kubectl (`get` with any +output but the table forms, `--raw` on a secrets path, `view-secret`, +`oc extract`), Secrets Manager values, SSM parameters with decryption, KMS +decryption and plaintext data keys, `sts get-session-token` and +`get-federation-token`, and `aws configure` printing the runner's own keys. +Whether a secret exists, which keys it holds and when it changed stays open. + +## Why + +A second deployment running copies of these files measured its read-only +identities. Its cluster identity read Secret objects in every cluster, and its +read-only AWS policy read S3 object contents. Neither shows up in a posture +that only asks what an identity may change, and both are reads a read-only +investigator is allowed to make. + +A value read into an investigation is in the model's context, sent to the +provider, kept in the transcript, and one summary away from a chat card. The +output guard records that a credential came back, after the fact. Refusing the +few commands whose whole output is a secret costs an investigation almost +nothing: it asks whether the secret is there and when it changed, not what it +says. + +Reported rather than refused at startup, because a read-only identity is +allowed to read, and an upgrade must not stop a node that ran yesterday. + +## Consequences + +- `/v1/posture` shows how much a read-only node can read that is a secret, so + an operator can scope the identity. The verdict means what it meant. +- `kubectl get secret x -o yaml` and `aws secretsmanager get-secret-value` are + refused, with a sentence naming what to ask instead. This overturns the + 2026-09-09 note that left them allowed on purpose; that note is corrected in + place. +- Not caught: a raw API URL built at runtime, a pod that mounts the secret, + another vendor's CLI, or a secret in a log line or a config file. The + credential is what holds, and the output guard still records what comes + back. +- An identity that may not simulate the reading actions keeps its verdict, and + shows the failure in `read_errors`. diff --git a/.agents/notes/implemented/2026-11-03-the-files-configuration-points-at-steer-every-run-too.md b/.agents/notes/implemented/2026-11-03-the-files-configuration-points-at-steer-every-run-too.md new file mode 100644 index 00000000..5639e0e2 --- /dev/null +++ b/.agents/notes/implemented/2026-11-03-the-files-configuration-points-at-steer-every-run-too.md @@ -0,0 +1,57 @@ +--- +title: The files configuration points at steer every run too, and the mount is what holds +status: implemented +date: 2026-11-03 +scope: hookprobe +--- + +## Decision + +The input guard protects every file the operator names by path, wherever it +lives: the MCP config, the roles file, both remediation allowlists, the +declared radius, the decline patterns, and the codex and pi configs. It also +protects the appended methodology when its path is configured. A write to one is +refused on all three runtimes, and the before-and-after digest of a run covers +them. At startup the posture record names any of the operator's files this +node's own user could write or replace (`writable_inputs`), and the alarm +channel says so once. The work deployment's planner and watcher read their MCP +config from their read-only secrets mounts instead of their volumes, and the +configuration doc no longer offers the volume as a place for it. + +## Why + +A second deployment running copies of these files audited what its agent could +write, and found its own deny list and scheduled jobs on a writable volume. +Asked here, the same question found the MCP config. The work deployment kept it +on each node's volume, the engine rereads it at every run and starts the stdio +servers it names, and the input guard protected only fixed names in the +workdir. Measured on the probe image with that setting and a recording stand-in +for the model API: one run wrote the file through an ordinary shell step, and +the next run, which made no tool call at all, started the planted command. No +guard and no audit line saw it. + +The guard alone is not a boundary. The agent shares the service's UID, and the +bash guard's docstring already says a determined shell gets past a regex. For +the workdir's files that is accepted, because the service writes them for the +operator and a mount cannot tell the two apart. These files are different: no +endpoint writes them, so a read-only mount can hold, and checking whether one is +in place is cheap. + +Reported rather than refused: refusing to start would stop a node that ran +yesterday with a config an older version of the docs told its operator to put +on the volume. + +## Consequences + +- A run asked to edit its MCP config, roles or allowlist is refused, and told + that an operator changes it outside any run. +- A configured path on the volume keeps working. It is named in + `writable_inputs` and paged once per start until it moves to a read-only + mount. +- The work deployment's two MCP configs moved from each node's volume to its + read-only secrets directory. They are still edited on the host and read + fresh by the next run. +- A new file setting has to be classified on purpose: `tests/test_inputs.py` + fails while one is unclassified. +- Not changed: codex keeps its hooks in a home on the run's volume, which is + already a containment row of its own. diff --git a/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md b/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md index b3d0ab34..d5f22afb 100644 --- a/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md +++ b/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md @@ -48,6 +48,11 @@ whose context had been filled with a live credential was indistinguishable from one that read a pod list. Nobody could decide to discard the report or rotate the key. It records the KIND and never the match, because the reason string reaches an audit file and sometimes a chat card. +*Corrected 2026-11-03:* printing a Secret's data is now refused under every +posture, with Secrets Manager values, decrypted SSM parameters and KMS +plaintext (`_SECRET_RULES` in guard.py; note +2026-11-03-read-only-still-reads-secret-values). `env` and every other read stay +allowed, and the output guard still records what comes back from them. **The third outcome, because nothing escalated.** `deny_reason` could refuse and continue, and that was all. An agent steered into the posture twenty times left diff --git a/deploy/docker-compose.work.yml b/deploy/docker-compose.work.yml index e2bd3d81..fe4d7eb0 100644 --- a/deploy/docker-compose.work.yml +++ b/deploy/docker-compose.work.yml @@ -173,7 +173,7 @@ services: # deploy/sidecars/doors.py): the egress proxy, the MCP gate and the console # ingress each sit on BOTH networks and are stateless stdlib servers, so three # containers were three listeners. The old service names stay as network - # aliases: the probes' HTTP_PROXY and NO_PROXY, their /data/mcp.json and every + # aliases: the probes' HTTP_PROXY and NO_PROXY, their mcp.json and every # document still name egress-proxy, mcp-gate and probe-ingress. # # The proxy: the investigators' way out, and a list of where out may be. @@ -436,15 +436,21 @@ services: HOOKPROBE_RUNBOOK_ANSWER_DAYS: ${HOOKPROBE_RUNBOOK_ANSWER_DAYS:-} HOOKPROBE_REFIRE_ANSWER_HOURS: ${HOOKPROBE_REFIRE_ANSWER_HOURS:-} HOOKPROBE_SKILLS: ${HOOKPROBE_SKILLS:-} - # Lives inside the volume above, so it is hot-editable like the system - # prompt and needs no mount of its own — and it is NOT tracked, so this - # is the one place that says what it must contain: the chat's MCP server - # is reached as http://mcp-gate:8097/mcp/ with this node's bearer from - # .env (deploy/sidecars/mcp.example.json is the shape). A server on the - # host is out of reach from this network, and a stdio server cannot be - # reached from here at all. A missing file is one warning in the log and - # a round with no chat tools, which reads like a quiet day. - HOOKPROBE_MCP_CONFIG: ${HOOKPROBE_MCP_CONFIG:-/data/mcp.json} + # On the read-only secrets mount, not the volume above (2026-11-03). The + # engine reads it fresh at every run and starts the stdio servers it + # names, so a copy the agent could write was a command one run could + # plant for every run after it: measured 2026-11-02 with this setting on + # the probe image. Still hot-editable from the host + # (work-data/probe-plan-secrets/mcp.json), and it holds this node's + # mcp-gate bearer, which belongs with the secrets anyway. It is NOT + # tracked, so this is the one place that says what it must contain: + # the chat's MCP server is reached as http://mcp-gate:8097/mcp/ with this + # node's bearer from .env (deploy/sidecars/mcp.example.json is the + # shape). A server on the host is out of reach from this network, and a + # stdio server cannot be reached from here at all. A missing file is one + # warning in the log and a round with no chat tools, which reads like a + # quiet day. + HOOKPROBE_MCP_CONFIG: ${HOOKPROBE_MCP_CONFIG:-/data/secrets/mcp.json} # The closed set of MCP tools this planner may call. EMPTY DENIES ALL — # mounting a server is plumbing, naming its tools is policy, and only the # second one decides what a colleague's chat message can ask this agent to @@ -592,7 +598,8 @@ services: HOOKPROBE_PRICE_OUT_PER_1M: ${HOOKPROBE_PRICE_OUT_PER_1M:-} HOOKPROBE_PRICE_CACHE_READ_PER_1M: ${HOOKPROBE_PRICE_CACHE_READ_PER_1M:-} HOOKPROBE_PRICE_CACHE_WRITE_PER_1M: ${HOOKPROBE_PRICE_CACHE_WRITE_PER_1M:-} - HOOKPROBE_MCP_CONFIG: /data/mcp.json + # On the read-only secrets mount, for the planner's reason above. + HOOKPROBE_MCP_CONFIG: /data/secrets/mcp.json # Read-only chat tools ONLY. This instance reads colleagues' messages for # a living, so the list is the difference between "it can see the thread" # and "a message in the thread can post as the operator". diff --git a/deploy/sidecars/mcp.example.json b/deploy/sidecars/mcp.example.json index f6373400..a2b1fc6e 100644 --- a/deploy/sidecars/mcp.example.json +++ b/deploy/sidecars/mcp.example.json @@ -1,5 +1,5 @@ { - "_comment": "The probes' MCP client config (HOOKPROBE_MCP_CONFIG, /data/mcp.json on each node's volume, NOT tracked). The chat's MCP server is reached only through mcp-gate: this URL, and the node's own bearer from .env (WATCH_MCPGATE_TOKEN for probe-watch, PLAN_MCPGATE_TOKEN for probe-plan). Name the server as the chat tool is named in HOOKPROBE_MCP_TOOLS. A file that still points at the host is a round with no chat tools and one warning in the log. With several MCP servers behind the gate as routes, each is its own entry here, its URL http://mcp-gate:8097// and the same bearer.", + "_comment": "The probes' MCP client config (HOOKPROBE_MCP_CONFIG, /data/secrets/mcp.json on each node's read-only secrets mount, NOT tracked; never on the node's volume, where a run could plant a command every later run starts). The chat's MCP server is reached only through mcp-gate: this URL, and the node's own bearer from .env (WATCH_MCPGATE_TOKEN for probe-watch, PLAN_MCPGATE_TOKEN for probe-plan). Name the server as the chat tool is named in HOOKPROBE_MCP_TOOLS. A file that still points at the host is a round with no chat tools and one warning in the log. With several MCP servers behind the gate as routes, each is its own entry here, its URL http://mcp-gate:8097// and the same bearer.", "mcpServers": { "chat": { "type": "http", diff --git a/docs/containment.md b/docs/containment.md index 7d424fa1..d8b75fa5 100644 --- a/docs/containment.md +++ b/docs/containment.md @@ -24,6 +24,9 @@ a real model cannot be steered into emitting a harmful one. > and cited in every run's audit record. > An unverifiable writing runner with a declared radius also refuses under `enforce`. With no explicit AWS source, the > instance metadata service is asked directly whether it offers a role: one it offers is measured like any other source. +> Since 2026-11-03 two more answers are recorded beside the verdict and never decide it: whether the identity can read +> secret values (`reads_secrets`: Secret objects, Secrets Manager values, SSM parameters, KMS decryption, S3 objects), and +> which configured files this node's own user could rewrite (`writable_inputs`, paged once at startup). > **Recovery is not exactly-once (2026-10-27).** Concurrent signed hook requests use a durable, renewable processing > lease; an in-flight duplicate receives 503 and can retry, while completed signatures are deduplicated. A crash after @@ -146,7 +149,7 @@ Every row here is a claim, and a claim is worth what its test is worth. `scripts | **Chained audit** — every flight-recorder line carries the hash of the one before it; `verify_chain` names the first line that stops adding up, and `/v1/selftest` walks it | A record quietly tidied after the fact. The audit is what an investigation is reconstructed from and what a compliance reader asks about, and an append-only file cannot tell a line rewritten later from one written that way. Edits, deletions and reorderings are all caught, because each line names its predecessor rather than only hashing itself | Somebody who can rewrite the audit here AND reach the pipe's ledger. Rewriting `.chain` alone is not enough since 2026-09-10: every report carries `meta.audit_head` off-box, so a rebuilt chain is locally consistent and still fails `chain_anchored` against a head another service recorded hours earlier. Two disks and two services is the bound, not proof. Lines from before the chain existed are counted, never treated as a break — an alarm that fired on all existing history would be ignored by day two | `hookprobe/gate.py` (`append_audit`, `verify_chain`), `selftest.py` | | **What an approved procedure may run, and with what** — `HOOKPROBE_REMEDIATION_ALLOWLIST` (full-match regexes, one per line, hot-read; no file means nothing runs), checked over the WHOLE procedure at approval and again before each step; `execution_env()` passes `PATH`/`HOME`/locale, the cloud credentials an operator mounted for the purpose (`AWS_*`, `KUBE*`, `GOOGLE_*`, `AZURE_*`) and the egress proxy variables, and nothing else; a WRITING node names what its credential may do (`HOOKPROBE_BLAST_RADIUS`, one measured line per entry as `/v1/posture` reports it) and under `enforce` a credential that can do more refuses to start, naming the excess | The one path that executes anything running a command nobody wrote down, or a half-executed 1-2-3 when the file is tightened mid-incident; a procedure reading the family's HMAC keys, the Lark app secret or the provider credential out of its own environment — until 2026-09-10 `create_subprocess_exec` passed no `env` at all; a writing node quietly growing, which under `danger-only` had nothing to be compared against for its whole life | A pattern an operator wrote too widely — the file is the decision, this only holds it, and the model proposing anything stays free. A permitted command doing something with the credentials it IS given. Anything inside the declared radius, a grant that was unwise (`iam:*` declared is `iam:*`), and a credential widened while the node runs, which is caught at the next boot and by the hourly selftest, not at the moment it changes | `hookprobe/remediation.py` (`deny_reason`, `approve`, `execute`, `execution_env`), `posture.py` (`declared_radius`, `beyond`, `verdict`) | | **Approval window, freshness cursor and the version pressed** — a proposal is unrunnable 24h after it was made (the span the pipe gives a card's token), and it records what was true about its condition when the steps were chosen — has it ENDED, has the investigation taken another turn; if either has moved by the time somebody presses, the procedure is retired instead of run, the button is not drawn, and the refusal returns to the chat as a report. And a press names WHICH proposal it approves: the digest of its steps, run, clock and cursor as they were shown — inside the card's signed token, or as the console listed them — and a press naming none or another version is refused with the row untouched; the approved digest goes on the row | Commands chosen from evidence that has since moved being run by a click on a week-old card; approving a fix for a condition that already cleared, or running steps lifted from a report the investigation has since superseded — both reachable at any age under a clock alone, because a clock assumes the world moves at a rate; and commands nobody saw running under an approval of others — a proposal rewritten under the same id between the reading and the press | An approval at 23 hours on evidence that went stale in one is what the cursor is for; what neither sees is anything that happened outside this pipe. Nothing here reaches out to look, and it must not — the production investigator holds no credentials for the systems it writes procedures about — so somebody fixing the target by hand without saying so in chat is invisible | `hookprobe/remediation.py` (`APPROVAL_WINDOW_SECONDS`, `stale`, `cursor`, `moved`, `content_hash`, `Changed`), `actions.py`, `events.py`, `service.py` (`report_superseded`) | -| **Bash guard and input guard** — under `readonly`, mutating verbs of known CLIs with `aws` inverted to deny-unless-read, under `danger-only` only what no credential scope can undo; and writes to the files that steer the next run (`CLAUDE.md` and `AGENTS.md`, `.claude/`, the system prompt, `audit/`, `telemetry/`, `remediation/`, `actions/`, and the `results/` case files the prompt tells every run to read first) refused in the three shapes a write arrives in — an edit tool's path argument, the paths inside a codex `apply_patch` envelope, and a shell command that redirects, `tee`s, `sed -i`s, `cp`s, `mv`s, removes (`rm`, `find -delete`) or links (`ln`) into one of them; and under every posture, a script piped or handed to a shell (`curl … \| sh`), whose verbs no rule ever reads; and before any tool runs, the message itself reaches the model as words, not as input typed at the CLI's prompt | An over-eager model running `kubectl delete` or `aws s3 rm`, or an installer's `curl … \| sh`; a run installing its own runbook as a persistence vector, or deleting the case files the next run reads. All three write shapes were measured getting through before they were closed: `apply_patch` because the guard read argument keys and codex puts its paths inside a patch string, `AGENTS.md` because the list only knew Claude's name for the steering file, and the shell redirect on **every** runtime under **both** postures; removing and symlinking from the shell were measured getting through on 2026-10-27; an `@path` in an alert or a chat reply making the CLI read that file into the model's context as a Read result no hook saw, and a leading `/` running a CLI command in place of the investigation, both measured getting through on 2026-11-02 | An adversary: the rules need a binary and a verb in one pipeline segment, and separating them gets through; a redirect assembled at runtime out of two variables, or any of the endless other ways a shell can name a file; a script saved to a file and then run. These catch the obvious forms — the over-eager model, which is the case that happens. Writes anywhere else in the workspace are allowed on purpose. The model can still read a file the text names, on its own initiative; that read is a tool call the audit records. **The read-only credential is what holds** | `hookprobe/guard.py`, `inputs.py`, `gate.py` (`patch_targets`, `shell_write_target`), `engine.py` (`verbatim_prompts`) | +| **Bash guard and input guard** — under `readonly`, mutating verbs of known CLIs with `aws` inverted to deny-unless-read, under `danger-only` only what no credential scope can undo; and writes to the files that steer the next run (`CLAUDE.md` and `AGENTS.md`, `.claude/`, the system prompt, `audit/`, `telemetry/`, `remediation/`, `actions/`, and the `results/` case files the prompt tells every run to read first, and every file configuration points at by path, wherever it lives: the MCP config, roles, allowlists, declared radius, decline patterns, runtime configs) refused in the three shapes a write arrives in — an edit tool's path argument, the paths inside a codex `apply_patch` envelope, and a shell command that redirects, `tee`s, `sed -i`s, `cp`s, `mv`s, removes (`rm`, `find -delete`) or links (`ln`) into one of them; and under every posture, a script piped or handed to a shell (`curl … \| sh`), whose verbs no rule ever reads, and printing a secret's value: a Secret's data from kubectl, a Secrets Manager value, a decrypted SSM parameter, KMS plaintext, AWS credentials; and before any tool runs, the message itself reaches the model as words, not as input typed at the CLI's prompt | An over-eager model running `kubectl delete` or `aws s3 rm`, or an installer's `curl … \| sh`; a run installing its own runbook as a persistence vector, or deleting the case files the next run reads. All three write shapes were measured getting through before they were closed: `apply_patch` because the guard read argument keys and codex puts its paths inside a patch string, `AGENTS.md` because the list only knew Claude's name for the steering file, and the shell redirect on **every** runtime under **both** postures; removing and symlinking from the shell were measured getting through on 2026-10-27; an `@path` in an alert or a chat reply making the CLI read that file into the model's context as a Read result no hook saw, and a leading `/` running a CLI command in place of the investigation, both measured getting through on 2026-11-02; a run planting an MCP config whose command every later run starts, measured getting through the same day; an over-eager model printing a Secret to see what is in it | An adversary: the rules need a binary and a verb in one pipeline segment, and separating them gets through; a redirect assembled at runtime out of two variables, or any of the endless other ways a shell can name a file; a script saved to a file and then run. These catch the obvious forms — the over-eager model, which is the case that happens. Writes anywhere else in the workspace are allowed on purpose. The model can still read a file the text names, on its own initiative; that read is a tool call the audit records. A secret read some other way: a raw API URL built at runtime, a pod that mounts it, another vendor's CLI. For the configured files the read-only mount is what holds, and the startup posture names any this UID could still write. **The read-only credential is what holds** | `hookprobe/guard.py`, `inputs.py`, `gate.py` (`patch_targets`, `shell_write_target`), `engine.py` (`verbatim_prompts`), `posture.py` (`writable_inputs`, `reads_secrets`) | | **Memory shape check** — a suggested fact whose SHAPE could act waits for a human | An instruction smuggled through alert text into `CLAUDE.md` | A benign-shaped line that is simply wrong | `hookprobe/suggestions.py` | | **High-risk allowlist and target cooldown** — a step the report marked `risk: high` must also full-match a pattern in `HOOKPROBE_REMEDIATION_HIGH_RISK_ALLOWLIST`, deny-by-default in the same direction, both lists re-read before each step and one uncovered high step refusing the whole procedure at the click; after a procedure acts, a second one sharing its declared `target` **or** its literal command is refused for 15 minutes (`HOOKPROBE_REMEDIATION_COOLDOWN_SECONDS`, 0 disables), and one already `running` holds its target with no window at all | Arming remediation from also arming its worst half by the same gesture — `kubectl rollout restart .*` written to cover restarts has not licensed the steps the model itself calls dangerous; a fix and the rollback of that fix approved a minute apart, and two sequences interleaving their steps against one host. Held, not retired: the row stays `proposed` and the same press works after the window | **Keys the model writes.** A dangerous command the report labelled `low` never meets the strict list — the risk label is the MODEL's, so this can only ADD a requirement, and a mislabelled step is bounded by the ordinary allowlist exactly as before. A step that varies its command by a flag AND labels its target differently is not cooled; two keys because production named one target three ways while running character-identical commands, so the command half is the one that holds. Both count only what THIS node ran — somebody fixing the same box by hand is invisible, the same blind spot the freshness cursor has | `hookprobe/remediation.py` (`step_deny_reason`, `cooldown_keys`, `cooling`), `actions.py`, `service.py` (`report_cooling`) | | **Refused at config load** — `on_error` per stage (`pass` or `drop`, chosen per stage, never defaulted globally); an inline decider's timeout capped at 10s; no unreachable door, starved exit, or wildcard a return door can reach; a write-back channel may say `POST`, `PUT` or `PATCH` and nothing else | A stage's failure silently changing policy; an inline decider holding the sender's connection until it retries and duplicates the alert; a config change silently feeding a brain its own output; a channel configured with `DELETE`, or a `GET` whose side effect fires when a chat client fetches a link preview — the trap `/card-action` refuses to fall into | Choosing wrong inside what is legal: an explicit fail-open, a node that answers in 9s and is still the wrong place for the work, a graph that is legal and still wrong, a `POST` that does damage at the receiver — the pipe checks the verb it is asked for, never what the far side does with it | `hookrelay/processors.py` (`MAX_INLINE_TIMEOUT_SECONDS`), `config.py` (`_WRITE_METHODS`), `topology.py`, `scripts/assert_topology.py` | diff --git a/hookprobe/README.md b/hookprobe/README.md index 8f3589de..09c2de98 100644 --- a/hookprobe/README.md +++ b/hookprobe/README.md @@ -37,8 +37,9 @@ runner failure instead of leaving the caller to wait out its own timeout. A poller can write the result on its first confirming read. **The agent cannot edit what steers the next run.** `.claude/` (skills, roles, -settings), `CLAUDE.md`, the audit log and the `results/` case files every prompt -tells the next run to open first are closed to it — by a PreToolUse hook +settings), `CLAUDE.md`, the audit log, the `results/` case files every prompt +tells the next run to open first, and every file configuration points at by +path, its MCP config among them, are closed to it — by a PreToolUse hook that refuses the write, and by a digest of every input file compared before and after each run, because the two fail differently. Without this, one injected line reaching `.claude/skills/` outlives the run that read it and comes back as @@ -190,6 +191,15 @@ always reach. Two things about the word *default*, before the layers: per question): a role it offers is measured like any other source; no role, another vendor's service at the address, or nothing there is no credential; `AWS_EC2_METADATA_DISABLED=true` skips the question. + Two more answers sit beside the verdict and never decide it. `reads_secrets` + says whether the identity can read secret values: `kubectl auth can-i + get|list secrets -A`, and a second simulation of Secrets Manager values, SSM + parameters, KMS decryption and S3 object reads. A read-only identity is + allowed to read; this is how much of what it reads is a secret. + `writable_inputs` names every configured file this node's own user could + rewrite, such as an MCP config, roles, allowlists or a declared radius, and + pages once at startup. A run could rewrite such a file, so mount them + read-only. - **Widening is a decision, and it is signed.** Under `danger-only` the guard keeps only the rules no credential scope can undo — `rm -rf`, `mkfs`/`dd` onto a device, fork bombs, container runtimes, `terraform destroy`, @@ -225,7 +235,10 @@ always reach. Two things about the word *default*, before the layers: of kubectl, helm, docker/podman, systemctl, terraform, plus ssh/scp and `git push`; under `danger-only` it keeps only the estate-wide destroyers listed above. Under both, a script piped or handed to a shell (`curl … | sh`) - is refused, because its verbs never reach a rule. It errs toward over-blocking. HTTP verbs are not policed (query APIs POST), and + is refused, because its verbs never reach a rule. So is printing a secret's + value: a Secret's data from kubectl, a Secrets Manager value, a decrypted SSM + parameter, KMS plaintext, or AWS credentials. Whether a secret exists, which + keys it holds and when it changed stay open. It errs toward over-blocking. HTTP verbs are not policed (query APIs POST), and cloud CLIs are too many to enumerate — scope their credentials instead. 3. **Container** — non-root, disposable, nothing precious inside, no container runtime and no socket (a run cannot start a container, and cannot build a @@ -237,8 +250,10 @@ always reach. Two things about the word *default*, before the layers: freely in `/data`, minus the carve-out below. 4. **Input guard** — the agent may not write what steers the *next* run: `.claude/` (skills, roles, settings), `CLAUDE.md`, `system-prompt.md`, - the `audit/` flight recorder, and `results/` — the case files the prompt - tells every run to open first as earlier evidence. Without this, one + the `audit/` flight recorder, `results/` — the case files the prompt + tells every run to open first as earlier evidence — and every file + configuration points at by path, wherever it lives: the MCP config, roles, + allowlists, declared radius, decline patterns and runtime configs. Without this, one injected line reaching `.claude/skills/` outlives the run that read it and comes back as the operator's own runbook — a single injection made durable — and a forged case file is evidence the next run trusts without a tool call. @@ -251,7 +266,12 @@ always reach. Two things about the word *default*, before the layers: share a UID and a process tree: a mount cannot tell `PUT /v1/skills/{name}` (an operator installing a reviewed runbook) from a run editing itself, and mounting these paths read-only would take the operator's write endpoints - down along with the attack. + down along with the attack. The files configuration points at are the + exception: no endpoint writes them, so a read-only mount outside the run's + volume is what holds, and the startup posture names any this UID could + still write (`writable_inputs`). A guard is still worth having in front + of that: a run that planted an MCP config was measured getting every later + run to start its command, with no tool call to see (2026-11-02). ## Non-goals (v1) diff --git a/hookprobe/docs/configuration.md b/hookprobe/docs/configuration.md index 80fa18cc..e1e282e8 100644 --- a/hookprobe/docs/configuration.md +++ b/hookprobe/docs/configuration.md @@ -66,9 +66,14 @@ not in a web form. The loop is: (`{"mcpServers": {...}}`), and the marketplace `config.json` shape — entries with `"enabled": false` are skipped and the flag is stripped, so downloaded MCP packages work unedited. -2. Mount it read-only and point `HOOKPROBE_MCP_CONFIG` at it (see the - commented lines in every compose). A path on the volume (e.g. - `/data/mcp.json`) also works and is editable without remounting. +2. Mount it read-only, outside the run's volume, and point + `HOOKPROBE_MCP_CONFIG` at it (see the commented lines in every compose). + It stays editable from the host, and the next run reads the edit. Not + on the volume: the engine starts the stdio servers the file names at + the top of every run, so a copy the agent can write is a command one run + can plant for every run after it. The input guard refuses the obvious + writes to it wherever it lives, and the startup posture lists it under + `writable_inputs` while this node's own user could still write it. 3. Verify with `GET /v1/mcp`: it reads the file fresh and shows each server's command/args/type/url plus its env **key names only** — env values never leave the file. diff --git a/hookprobe/hookprobe/app.py b/hookprobe/hookprobe/app.py index 9f6e10bc..054856fb 100644 --- a/hookprobe/hookprobe/app.py +++ b/hookprobe/hookprobe/app.py @@ -50,6 +50,7 @@ describe, events, handoff, + inputs, library, ops, posture, @@ -228,6 +229,7 @@ async def lifespan(_: FastAPI): settings.posture_check, radius=settings.blast_radius, alarm=service.alarm, + steering=inputs.operator_files(settings), ) service.recover_orphans() service.sweep_interrupted_remediations() diff --git a/hookprobe/hookprobe/engine.py b/hookprobe/hookprobe/engine.py index 8eb5ba1a..cc5ad996 100644 --- a/hookprobe/hookprobe/engine.py +++ b/hookprobe/hookprobe/engine.py @@ -106,6 +106,7 @@ def _bash_guard_hook( record: Callable[[dict[str, Any]], None] | None = None, workdir: Path | None = None, home: Path | None = None, + configured: tuple[Path, ...] = (), ) -> Callable[..., Any]: """PreToolUse: refuse a command this runner's posture does not allow. @@ -125,7 +126,7 @@ async def hook(input_data: dict[str, Any], tool_use_id: str | None, context: Any reason = bash_deny_reason(command, mode) guard = "bash" if reason is None and workdir is not None: - hit = shell_write_target(command, inputs.protected_paths(workdir, home), workdir) + hit = shell_write_target(command, inputs.protected_paths(workdir, home, configured), workdir) if hit is not None: guard = "input" reason = ( @@ -194,7 +195,10 @@ async def hook(input_data: dict[str, Any], tool_use_id: str | None, context: Any def _input_guard_hook( - workdir: Path, home: Path | None, record: Callable[[dict[str, Any]], None] | None = None + workdir: Path, + home: Path | None, + record: Callable[[dict[str, Any]], None] | None = None, + configured: tuple[Path, ...] = (), ) -> Callable[..., Any]: """PreToolUse: refuse a write aimed at the files that steer the next run.""" @@ -204,7 +208,9 @@ async def hook(input_data: dict[str, Any], tool_use_id: str | None, context: Any tool_input = input_data.get("tool_input") data = tool_input if isinstance(tool_input, dict) else {} for key in _WRITE_PATH_KEYS: - reason = inputs.write_deny_reason(str(data.get(key) or ""), workdir=workdir, home=home) + reason = inputs.write_deny_reason( + str(data.get(key) or ""), workdir=workdir, home=home, configured=configured + ) if reason is not None: logger.warning("input guard denied write: %s", data.get(key)) if record is not None: @@ -808,6 +814,9 @@ def __init__(self, settings: Settings) -> None: self._settings = settings self._workdir = settings.workdir self._home = Path(os.environ.get("HOME", "") or "/data/home") + # The files configuration points at (MCP config, roles, allowlists...): + # guarded and fingerprinted like the names in the workdir. See inputs. + self._configured = inputs.configured(settings) self._agents_raw = _load_agents_raw(settings.agents_config) (self._workdir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Set for the duration of one run, so a caller that wants the turn to @@ -1083,13 +1092,18 @@ async def _step_done(input_data: dict[str, Any], tool_use_id: str | None, contex HookMatcher( matcher="Bash", hooks=_hook_list( - _bash_guard_hook(self._settings.bash_guard, recorder, self._workdir, self._home) + _bash_guard_hook( + self._settings.bash_guard, recorder, self._workdir, self._home, self._configured + ) ), ), # Matched on every tool, filtered by name inside: the guard # must not depend on how the SDK interprets a matcher # pattern for the one thing it exists to stop. - HookMatcher(matcher=None, hooks=_hook_list(_input_guard_hook(self._workdir, self._home, recorder))), + HookMatcher( + matcher=None, + hooks=_hook_list(_input_guard_hook(self._workdir, self._home, recorder, self._configured)), + ), # matcher=None for the same reason as the line above: the # one guard standing between a colleague's chat message and # a tool that can act as the operator must not depend on @@ -1127,7 +1141,7 @@ async def _step_done(input_data: dict[str, Any], tool_use_id: str | None, contex ) logger.info("engine start session=%s model=%s resume=%s", session_key, self._settings.model, resume or "-") - inputs_before = inputs.fingerprint(self._workdir, self._home) + inputs_before = inputs.fingerprint(self._workdir, self._home, self._configured) input_changes: tuple[str, ...] = () last_text = "" message_count = 0 @@ -1310,7 +1324,7 @@ async def _context_usage(self, client: Any) -> dict[str, Any] | None: def _input_changes(self, before: dict[str, str]) -> tuple[str, ...]: """What this run did to its own input surface — empty when it behaved.""" - found = tuple(inputs.changes(before, inputs.fingerprint(self._workdir, self._home))) + found = tuple(inputs.changes(before, inputs.fingerprint(self._workdir, self._home, self._configured))) if found: logger.warning("run rewrote its own inputs: %s", "; ".join(found)) return found diff --git a/hookprobe/hookprobe/gate.py b/hookprobe/hookprobe/gate.py index 554fc66d..303926b2 100644 --- a/hookprobe/hookprobe/gate.py +++ b/hookprobe/hookprobe/gate.py @@ -222,6 +222,7 @@ def deny_reason( mcp_allowed: frozenset[str] = frozenset(), workdir: Path | None = None, home: Path | None = None, + configured: tuple[Path, ...] = (), ) -> tuple[str, str, str] | None: """Which guard refuses this call, why, and what to record — or None to allow. @@ -237,7 +238,7 @@ def deny_reason( if reason is not None: return ("bash", reason, command[:300]) if workdir is not None: - hit = shell_write_target(command, inputs.protected_paths(workdir, home), workdir) + hit = shell_write_target(command, inputs.protected_paths(workdir, home, configured), workdir) if hit is not None: return ( "input", @@ -250,7 +251,7 @@ def deny_reason( # rather than in an argument, so the key-reading branch below cannot see it. if name == "apply_patch" and workdir is not None: for target in patch_targets(str(data.get("command") or data.get("input") or "")): - reason = inputs.write_deny_reason(target, workdir=workdir, home=home) + reason = inputs.write_deny_reason(target, workdir=workdir, home=home, configured=configured) if reason is not None: return ("input", reason, target[:300]) @@ -262,7 +263,7 @@ def deny_reason( if name in WRITE_TOOLS and workdir is not None: for key in _WRITE_PATH_KEYS: target = str(data.get(key) or "") - reason = inputs.write_deny_reason(target, workdir=workdir, home=home) + reason = inputs.write_deny_reason(target, workdir=workdir, home=home, configured=configured) if reason is not None: return ("input", reason, target[:300]) @@ -351,6 +352,11 @@ def _env_path(name: str) -> Path | None: return Path(raw) if raw else None +def _env_paths(env: dict[str, str], name: str) -> tuple[Path, ...]: + """A path list the adapter joined with os.pathsep; empty when unset.""" + return tuple(Path(part) for part in env.get(name, "").split(os.pathsep) if part.strip()) + + def decide(payload: dict[str, Any], env: dict[str, str]) -> dict[str, Any]: """One hook payload in, one decision out. The whole of the spawned gate. @@ -385,6 +391,9 @@ def decide(payload: dict[str, Any], env: dict[str, str]) -> dict[str, Any]: workdir=_env_path("HOOKPROBE_GATE_WORKDIR"), # The agent's home, whose settings and skills are inputs too. home=_env_path("HOOKPROBE_GATE_HOME"), + # The files configuration points at, wherever they live: the MCP + # config, roles, allowlists. Inputs as much as the workdir's are. + configured=_env_paths(env, "HOOKPROBE_GATE_CONFIGURED"), ) if verdict is None: return {} @@ -594,6 +603,7 @@ def environment(settings: Any, session_key: str, *, package_root: str) -> dict[s env["HOOKPROBE_GATE_AUDIT"] = str(settings.workdir / "audit") env["HOOKPROBE_GATE_WORKDIR"] = str(settings.workdir) env["HOOKPROBE_GATE_HOME"] = str(Path.home()) + env["HOOKPROBE_GATE_CONFIGURED"] = os.pathsep.join(str(path) for path in inputs.configured(settings)) env.update(agent_credentials(settings)) env.update(trace_environment(session_key)) return env diff --git a/hookprobe/hookprobe/guard.py b/hookprobe/hookprobe/guard.py index f7387168..cb7eb631 100644 --- a/hookprobe/hookprobe/guard.py +++ b/hookprobe/hookprobe/guard.py @@ -210,6 +210,49 @@ ), ) +# Printing a secret's VALUE. Checked under every posture, before the mode +# branch, because reading is what both postures may do and this is the read that +# should not happen by accident: a value printed here is in the model's context +# and the transcript, one summary away from a chat card. Whether a secret +# exists, which keys it holds and when it changed stays open — `kubectl get +# secret x`, `kubectl describe secret x`, `aws secretsmanager describe-secret`, +# `aws ssm get-parameter` without decryption — and that is nearly always what an +# investigation needs. The same honesty as above: a raw API URL assembled at +# runtime, a pod that mounts the secret, or another vendor's CLI is not caught. +# The credential is what holds, and posture reports whether it can read secrets +# at all (`reads_secrets` on /v1/posture). +_KUBE = r"\b(?:kubectl|oc|kubecolor)\b" +_AWS = r"(? str | None: f"unseen-code guard: {label} is blocked. The guard cannot read a script it never " f"sees; fetch it to a file, read it, and run the commands it holds one by one" ) + for pattern, label in _SECRET_RULES: + if pattern.search(command): + return ( + f"secret-value guard: {label} is blocked under every posture. A value printed here " + f"reaches the model and can reach a report; ask whether it exists, which keys it " + f"holds or when it changed instead (`kubectl describe secret`, " + f"`aws secretsmanager describe-secret`, `aws ssm get-parameter` without decryption)" + ) if mode == DANGER_ONLY: for pattern, label in _DANGER_RULES: if pattern.search(command): diff --git a/hookprobe/hookprobe/inputs.py b/hookprobe/hookprobe/inputs.py index 72b9f448..24916c10 100644 --- a/hookprobe/hookprobe/inputs.py +++ b/hookprobe/hookprobe/inputs.py @@ -34,8 +34,10 @@ from __future__ import annotations import hashlib +import os from collections.abc import Iterable from pathlib import Path +from typing import Any # Everything the model is handed as standing instruction — plus the audit log, # which is here for a different reason: a record its own subject can rewrite is @@ -80,6 +82,25 @@ # and would report tampering on all of them. _FINGERPRINTED = (".claude", "CLAUDE.md", "system-prompt.md") +# The files an operator points this service at by path. They steer every run as +# surely as the names above do — the MCP config is started as processes at the +# top of every run — but they live wherever the operator put them, so they are +# protected by the configured path rather than by a name in the workdir. Found +# 2026-11-02: a deployment kept its MCP config on the run's volume, one run wrote +# a command into it, and every later run started that command with no tool call +# for the guards or the audit to see. The service reads these and never writes +# them, so the boundary that holds is a read-only mount; see `writable`. +_OPERATOR_FILES = ( + "mcp_config", + "agents_config", + "remediation_allowlist", + "remediation_high_risk_allowlist", + "blast_radius", + "decline_patterns", + "codex_config", + "pi_config", +) + # Bounds, so that a run cannot make its own verification expensive by dropping # a very large or very deep tree into the skills directory. _MAX_FILES = 400 @@ -94,16 +115,53 @@ def _resolve(path: Path) -> Path: return path -def protected_paths(workdir: Path, home: Path | None = None) -> tuple[Path, ...]: +def operator_files(settings: Any) -> tuple[Path, ...]: + """The configured files only an operator writes: the service reads them and + never writes them, so nothing running here should be able to either.""" + found = (getattr(settings, name, None) for name in _OPERATOR_FILES) + return tuple(Path(path) for path in found if path) + + +def configured(settings: Any) -> tuple[Path, ...]: + """Every file a run is steered by through configuration: the operator's + files, and the appended methodology when its path is configured. The + service writes that one for the operator (`PUT /v1/system-prompt`), so it is + guarded here and never expected to be read-only.""" + append = getattr(settings, "system_prompt_append", None) + return (*operator_files(settings), *((Path(append),) if append else ())) + + +def protected_paths(workdir: Path, home: Path | None = None, configured: Iterable[Path] = ()) -> tuple[Path, ...]: """Paths this runner may read but never write.""" paths = [workdir / name for name in _PROTECTED] if home is not None: # The host skills library, when HOOKPROBE_SETTING_SOURCES loads "user". paths.append(home / ".claude") + paths.extend(configured) return tuple(_resolve(path) for path in paths) -def write_deny_reason(raw_path: str, *, workdir: Path, home: Path | None = None) -> str | None: +def writable(paths: Iterable[Path]) -> list[str]: + """The configured files this process's own user could write or replace. + + The guard on the tool path catches the obvious writes and is not a + boundary; the agent shares this uid. What holds is a file this uid cannot + write at all: a read-only mount, or a file and directory another user owns. + A file in a directory this uid can write is replaceable even when the file + itself is read-only, and a configured path that does not exist yet in such + a directory can be created, so both count. + """ + found: list[str] = [] + for path in paths: + target = _resolve(path) + if (target.exists() and os.access(target, os.W_OK)) or os.access(target.parent, os.W_OK): + found.append(str(path)) + return found + + +def write_deny_reason( + raw_path: str, *, workdir: Path, home: Path | None = None, configured: Iterable[Path] = () +) -> str | None: """Why this write is refused, or None when it may proceed.""" if not raw_path: return None @@ -112,8 +170,14 @@ def write_deny_reason(raw_path: str, *, workdir: Path, home: Path | None = None) # Tool paths are relative to the agent's cwd, which is the workdir. candidate = workdir / candidate candidate = _resolve(candidate) - for root in protected_paths(workdir, home): + operator = {_resolve(path) for path in configured} + for root in protected_paths(workdir, home, configured): if candidate == root or root in candidate.parents: + if root in operator: + return ( + f"input guard: {root} is configuration this node reads fresh at every run, " + "so no run may write it. An operator changes it, outside any run." + ) return ( f"input guard: {root} steers the next run, so this runner may not write it. " "Turning a finding into a runbook is an operator action — distill the run, " @@ -122,13 +186,16 @@ def write_deny_reason(raw_path: str, *, workdir: Path, home: Path | None = None) return None -def fingerprint(workdir: Path, home: Path | None = None) -> dict[str, str]: +def fingerprint(workdir: Path, home: Path | None = None, configured: Iterable[Path] = ()) -> dict[str, str]: """One digest per input file, for a before/after comparison across a run.""" roots = [workdir / name for name in _FINGERPRINTED] if home is not None: # Only the skills subtree: the rest of $HOME/.claude is the CLI's own # transcript state, which changes on every run by design. roots.append(home / ".claude" / "skills") + # The configured files too: whatever way a run found to rewrite one, the + # comparison still sees that it changed. + roots.extend(configured) facts: dict[str, str] = {} for root in roots: for path in _files(root): diff --git a/hookprobe/hookprobe/posture.py b/hookprobe/hookprobe/posture.py index 176cb8c8..2ef8b90e 100644 --- a/hookprobe/hookprobe/posture.py +++ b/hookprobe/hookprobe/posture.py @@ -14,7 +14,13 @@ aws sts get-caller-identity who am I aws iam simulate-principal-policy would these dangerous actions be allowed -and compare with the declared posture. A runner declared read-only whose +and compare with the declared posture. Two more answers are recorded beside the +verdict and never decide it: whether the identity can read secret values +(`kubectl auth can-i get|list secrets -A`, and a second simulation of +Secrets Manager, SSM, KMS and S3 object reads), and which configured files +this process's own user could rewrite. A read-only identity is allowed to +read, and an upgrade must not stop a node that ran yesterday; both are there +so an operator can see them and scope them. A runner declared read-only whose credentials can `delete pods` is WIDER THAN DECLARED, and under the default (`enforce`) it refuses to start — a boundary that exists only in a README is not one, and a runner that starts anyway would spend its first run proving it. @@ -54,6 +60,8 @@ from pathlib import Path from typing import Any +from hookprobe import inputs + logger = logging.getLogger("hookprobe.posture") MODES = ("enforce", "warn", "off") @@ -81,6 +89,21 @@ "lambda:UpdateFunctionCode", "secretsmanager:PutSecretValue", ) +# Reading questions, asked beside the mutating ones and kept apart from them: a +# read-only identity is allowed to read, so none of this decides a verdict. What +# it answers is how much a read-only node can see that is a secret's VALUE rather +# than a fact about a system. A second deployment running copies of these files +# measured it on 2026-11-02: its read-only cluster identity read Secret objects +# in every cluster, and its read-only AWS policy read S3 object contents, which +# is where state files and backups keep their secrets in plain text. +KUBE_SECRET_QUESTIONS = (("get", "secrets"), ("list", "secrets")) +SECRET_READ_VERBS = frozenset({"get", "list", "watch", "*"}) +SECRET_AWS_ACTIONS = ( + "secretsmanager:GetSecretValue", + "ssm:GetParameter", + "kms:Decrypt", + "s3:GetObject", +) TIMEOUT_SECONDS = 25.0 Runner = Callable[[list[str]], Awaitable[tuple[int, str, str]]] @@ -131,6 +154,22 @@ def kube_mutating_rules(can_i_list: str) -> list[str]: return found +def kube_secret_rules(can_i_list: str) -> list[str]: + """The rules in `kubectl auth can-i --list` output that let this identity read + Secret objects here: `secrets` itself, or a wildcard resource, with a verb + that returns the data.""" + found: list[str] = [] + for raw in can_i_list.splitlines()[1:]: + line = raw.strip() + if not line or line.startswith("Resources") or "[" not in line: + continue + resource = line.split()[0] + verbs = {v.strip() for v in line.rsplit("[", 1)[1].rstrip("]").split() if v.strip()} + if resource.split(".")[0] in {"secrets", "*"} and verbs & SECRET_READ_VERBS: + found.append(f"{resource}: {' '.join(sorted(verbs & SECRET_READ_VERBS))}") + return found + + def aws_allowed(simulation_json: str) -> list[str]: """Actions SimulatePrincipalPolicy says would be allowed.""" try: @@ -181,7 +220,14 @@ async def check_kube(run: Runner) -> dict[str, Any]: path = kubeconfig_path() if path is None: return {"present": False} - record: dict[str, Any] = {"present": True, "kubeconfig": str(path), "mutating": [], "errors": []} + record: dict[str, Any] = { + "present": True, + "kubeconfig": str(path), + "mutating": [], + "errors": [], + "reads_secrets": [], + "read_errors": [], + } code, out, err = await run(["kubectl", "auth", "can-i", "--list"]) if code == 127: # The binary is not in the image. Four more questions of it would be @@ -193,6 +239,7 @@ async def check_kube(run: Runner) -> dict[str, Any]: record["errors"].append(f"can-i --list: {(err or out).strip()[:200]}") else: record["mutating"].extend(kube_mutating_rules(out)) + record["reads_secrets"].extend(kube_secret_rules(out)) for verb, resource in KUBE_QUESTIONS: code, out, err = await run(["kubectl", "auth", "can-i", verb, resource, "--all-namespaces"]) answer = out.strip().lower() @@ -201,6 +248,16 @@ async def check_kube(run: Runner) -> dict[str, Any]: elif not answer.startswith("no"): record["errors"].append(f"can-i {verb} {resource}: {(err or out).strip()[:120]}") record["mutating"] = sorted(set(record["mutating"])) + # Reading questions. Their failures go to `read_errors`, never `errors`: + # an answer that decides nothing must not be able to change the verdict. + for verb, resource in KUBE_SECRET_QUESTIONS: + code, out, err = await run(["kubectl", "auth", "can-i", verb, resource, "--all-namespaces"]) + answer = out.strip().lower() + if answer.startswith("yes"): + record["reads_secrets"].append(f"{resource} (cluster-wide): {verb}") + elif not answer.startswith("no"): + record["read_errors"].append(f"can-i {verb} {resource}: {(err or out).strip()[:120]}") + record["reads_secrets"] = sorted(set(record["reads_secrets"])) return record @@ -252,7 +309,15 @@ async def check_aws(run: Runner) -> dict[str, Any]: if not await asyncio.to_thread(metadata_offers_credentials): return {"present": False} # A role is offered: its credentials are measured like any other source. - record: dict[str, Any] = {"present": True, "identity": None, "allowed": [], "errors": [], "unverifiable": False} + record: dict[str, Any] = { + "present": True, + "identity": None, + "allowed": [], + "errors": [], + "unverifiable": False, + "reads_secrets": [], + "read_errors": [], + } code, out, err = await run(["aws", "sts", "get-caller-identity", "--output", "json"]) if code != 0 and "Unable to locate credentials" in (err or out): # The CLI looked everywhere it looks and found nothing: an instance with @@ -314,9 +379,39 @@ async def check_aws(run: Runner) -> dict[str, Any]: except (ValueError, KeyError, TypeError, AttributeError): record["errors"].append("simulate-principal-policy: invalid or incomplete results") record["unverifiable"] = True + record["reads_secrets"], record["read_errors"] = await _secret_reads(run, role_arn(arn)) return record +async def _secret_reads(run: Runner, principal: str) -> tuple[list[str], list[str]]: + """Which secret-reading actions the identity holds, and what stopped the + question. A separate simulation from the dangerous one, so that a failure + here is reported and can never make a verdict unverifiable.""" + code, out, err = await run( + [ + "aws", + "iam", + "simulate-principal-policy", + "--policy-source-arn", + principal, + "--action-names", + *SECRET_AWS_ACTIONS, + "--output", + "json", + ] + ) + if code != 0: + return [], [f"simulate-principal-policy (reads): {(err or out).strip()[:200]}"] + try: + results = json.loads(out)["EvaluationResults"] + decided = {str(item["EvalActionName"]): str(item["EvalDecision"]) for item in results} + except (ValueError, KeyError, TypeError, AttributeError): + return [], ["simulate-principal-policy (reads): invalid results"] + missing = [action for action in SECRET_AWS_ACTIONS if action not in decided] + allowed = sorted(action for action in SECRET_AWS_ACTIONS if decided.get(action) == "allowed") + return allowed, ([f"simulate-principal-policy (reads): no answer for {', '.join(missing)}"] if missing else []) + + def declared_radius(path: Path | None) -> set[str] | None: """The blast radius an operator accepted for a WRITING node, or None. @@ -410,13 +505,17 @@ def summary(record: dict[str, Any]) -> str: f"kube mutating={len(kube.get('mutating') or [])}" + (f" errors={len(kube['errors'])}" if kube.get("errors") else "") + (f" tool-missing={kube['tool_missing']}" if kube.get("tool_missing") else "") + + (f" reads-secrets={len(kube['reads_secrets'])}" if kube.get("reads_secrets") else "") ) if aws.get("present"): parts.append( f"aws identity={aws.get('identity') or '?'} dangerous-allowed={len(aws.get('allowed') or [])}" + (" unverifiable" if aws.get("unverifiable") else "") + (f" tool-missing={aws['tool_missing']}" if aws.get("tool_missing") else "") + + (f" reads-secrets={len(aws['reads_secrets'])}" if aws.get("reads_secrets") else "") ) + if record.get("writable_inputs"): + parts.append(f"writable-inputs={len(record['writable_inputs'])}") return " · ".join(parts) @@ -432,6 +531,20 @@ def read(workdir: Path) -> dict[str, Any] | None: return None +async def _say_writable(paths: list[str], alarm: Callable[[str], Awaitable[bool]] | None) -> None: + """Name the configured files a run could rewrite, once per start.""" + if not paths: + return + text = ( + f"configured files this node's own user can rewrite, so a run can too: {', '.join(paths)}. " + "Mount them read-only, outside the run's volume." + ) + logger.warning("%s", text) + if alarm is not None: + with contextlib.suppress(Exception): # a courtesy; the record above is the fact + await alarm(f"posture: {text}") + + async def on_startup( workdir: Path, bash_guard: str, @@ -439,6 +552,7 @@ async def on_startup( run: Runner = run_cli, radius: Path | None = None, alarm: Callable[[str], Awaitable[bool]] | None = None, + steering: tuple[Path, ...] = (), ) -> dict[str, Any] | None: """Check, record, announce — and under `enforce`, refuse to start a runner whose credentials are wider than it declares. @@ -452,18 +566,29 @@ async def on_startup( credential that could not be measured is said there at once, on both postures: the readonly node is about to refuse and loop, and a writing node is about to start with a radius nobody measured. + + `steering` is the configured files only an operator writes + (`inputs.operator_files`). Any this process's own user could rewrite is + recorded as `writable_inputs`, said in the log and on the alarm channel, and + never refused: the input guard stops the obvious writes, and the read-only + mount that would stop the rest is the operator's to make. """ if mode not in MODES: mode = "enforce" # a typo in the switch that decides whether to refuse must fail closed record: dict[str, Any] + writable_inputs = inputs.writable(steering) if mode == "off": record = {"checked_at": round(time.time(), 3), "bash_guard": bash_guard, "verdict": "skipped", "mode": mode} + record["writable_inputs"] = writable_inputs write(workdir, record) + await _say_writable(writable_inputs, alarm) return record declared = declared_radius(radius) record = await check(bash_guard, run, declared) record["mode"] = mode + record["writable_inputs"] = writable_inputs write(workdir, record) + await _say_writable(writable_inputs, alarm) line = summary(record) if record["verdict"] == "wider-than-declared": # For a declared writing node the useful detail is the EXCESS, not the diff --git a/hookprobe/hookprobe/ui.html b/hookprobe/hookprobe/ui.html index 12323220..664446d2 100644 --- a/hookprobe/hookprobe/ui.html +++ b/hookprobe/hookprobe/ui.html @@ -1214,8 +1214,9 @@

Recipes

"sys.verdict": "verdict", "sys.guard": "bash guard", "sys.checked": "checked", - "sys.kube_v": "present · {m} mutating verbs allowed · {e} errors", - "sys.aws_v": "present · {n} write actions allowed", + "sys.kube_v": "present · {m} mutating verbs allowed · {e} errors · {r} secret-read grants", + "sys.aws_v": "present · {n} write actions allowed · {r} secret reads allowed", + "sys.writable": "config a run could rewrite", "sys.not_present": "not present", "sys.unverifiable": "unverifiable", "act.title": "Remediation proposals", @@ -1569,8 +1570,9 @@

Recipes

"sys.verdict": "结论", "sys.guard": "bash 守卫", "sys.checked": "检查时间", - "sys.kube_v": "已挂载 · 允许 {m} 个变更动作 · {e} 个错误", - "sys.aws_v": "已挂载 · 允许 {n} 个写操作", + "sys.kube_v": "已挂载 · 允许 {m} 个变更动作 · {e} 个错误 · {r} 项可读机密的授权", + "sys.aws_v": "已挂载 · 允许 {n} 个写操作 · 允许 {r} 个读机密动作", + "sys.writable": "运行能改写的配置", "sys.not_present": "未挂载", "sys.unverifiable": "无法验证", "act.title": "处置建议", @@ -3140,8 +3142,10 @@

Recipes

// it declared: counts and the verdict, never the identity strings. (posture ? `

${icon("shield")}${esc(t("sys.posture"))}

` + kv([ [t("sys.verdict"), posture.verdict], [t("sys.guard"), posture.bash_guard], [t("sys.checked"), fmtTime(posture.checked_at)], - ["kube", (posture.kube || {}).present ? t("sys.kube_v", { m: ((posture.kube || {}).mutating || []).length, e: ((posture.kube || {}).errors || []).length }) : t("sys.not_present")], - ["aws", (posture.aws || {}).unverifiable ? t("sys.unverifiable") : (posture.aws || {}).present ? t("sys.aws_v", { n: ((posture.aws || {}).allowed || []).length }) : t("sys.not_present")], + ["kube", (posture.kube || {}).present ? t("sys.kube_v", { m: ((posture.kube || {}).mutating || []).length, e: ((posture.kube || {}).errors || []).length, r: ((posture.kube || {}).reads_secrets || []).length }) : t("sys.not_present")], + ["aws", (posture.aws || {}).unverifiable ? t("sys.unverifiable") : (posture.aws || {}).present ? t("sys.aws_v", { n: ((posture.aws || {}).allowed || []).length, r: ((posture.aws || {}).reads_secrets || []).length }) : t("sys.not_present")], + // Paths, not counts: these are what the operator has to remount. + [t("sys.writable"), (posture.writable_inputs || []).length ? posture.writable_inputs.join(", ") : "0"], ]) + "
" : ""); } catch (e) { box.innerHTML = errorHtml(e); } } diff --git a/hookprobe/tests/test_guard.py b/hookprobe/tests/test_guard.py index 5ec55e7a..45ead363 100644 --- a/hookprobe/tests/test_guard.py +++ b/hookprobe/tests/test_guard.py @@ -288,3 +288,64 @@ def test_a_script_the_guard_never_sees_is_refused_under_every_posture(command: s ) def test_a_pipe_into_something_else_is_not_this_rule(command: str) -> None: assert not (bash_deny_reason(command, READONLY) or "").startswith("unseen-code guard"), command + + +# --- a secret's value, under every posture -------------------------------- + +SECRET_VALUE_READS = [ + "kubectl get secret db -o yaml", + "kubectl get secrets -A -ojson", + "kubectl -n prod get -o=jsonpath='{.data.pass}' secret/db | base64 -d", + "kubectl get secret/db --output yaml", + "kubectl get configmaps,secrets -o yaml", + "oc get secret db -o json", + "kubectl get --raw /api/v1/namespaces/prod/secrets/db", + "kubectl view-secret db", + "oc extract secret/db", + "aws secretsmanager get-secret-value --secret-id prod/db", + "aws secretsmanager batch-get-secret-value --secret-id-list prod/db", + "aws ssm get-parameter --name /prod/db --with-decryption", + "aws --region eu-west-1 ssm get-parameters-by-path --path /prod --with-decryption", + "aws kms decrypt --ciphertext-blob fileb://blob", + "aws kms generate-data-key --key-id alias/app", + "aws sts get-session-token", + "aws configure export-credentials", + "aws configure get default.aws_secret_access_key", +] + +SECRET_METADATA_READS = [ + "kubectl get secret db", + "kubectl get secrets -A -o wide", + "kubectl get secrets -o name", + "kubectl describe secret db", + "kubectl get pod secret-rotator-7d -o yaml", + "kubectl get pods -o yaml", + "aws secretsmanager describe-secret --secret-id prod/db", + "aws secretsmanager list-secrets", + "aws ssm get-parameter --name /prod/db", + "aws kms describe-key --key-id alias/app", + "aws kms generate-data-key-without-plaintext --key-id alias/app", + "aws sts get-caller-identity", + "aws configure get region", + "curl https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html", +] + + +@pytest.mark.parametrize("mode", [READONLY, DANGER_ONLY]) +@pytest.mark.parametrize("command", SECRET_VALUE_READS) +def test_printing_a_secrets_value_is_refused_under_every_posture(command: str, mode: str) -> None: + """A value printed here is in the model's context and the transcript, one + summary away from a chat card. Reading is what both postures may do; this is + the read that should not happen by accident.""" + reason = bash_deny_reason(command, mode) + assert reason is not None and reason.startswith("secret-value guard"), command + assert "describe secret" in reason, "the refusal says what to ask instead" + + +@pytest.mark.parametrize("mode", [READONLY, DANGER_ONLY]) +@pytest.mark.parametrize("command", SECRET_METADATA_READS) +def test_whether_a_secret_exists_and_when_it_changed_stays_open(command: str, mode: str) -> None: + """What an investigation nearly always needs: the secret is there, holds these + keys, changed at this time. A pod named after a secret is not one.""" + reason = bash_deny_reason(command, mode) + assert reason is None or not reason.startswith("secret-value guard"), command diff --git a/hookprobe/tests/test_inputs.py b/hookprobe/tests/test_inputs.py index 2f473eb1..a9a600b0 100644 --- a/hookprobe/tests/test_inputs.py +++ b/hookprobe/tests/test_inputs.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import os from pathlib import Path import pytest @@ -196,3 +197,128 @@ def test_the_hook_survives_a_malformed_tool_input(workdir: Path) -> None: hook = _input_guard_hook(workdir, None) assert asyncio.run(hook({"tool_name": "Write", "tool_input": "not a dict"}, None, None)) == {} + + +# --- the files configuration points at -------------------------------------- + + +def test_every_file_setting_is_classified(tmp_path: Path) -> None: + """A file the operator names by path steers every run as surely as the names + in the workdir do. A new file setting has to be placed here on purpose: as an + operator's file (guarded, and expected read-only), or as one of the two the + service itself writes. Unclassified, it would be a steering file nobody + guards — which is what the MCP config was until 2026-11-02.""" + import dataclasses + + from hookprobe.settings import Settings + + paths = {f.name for f in dataclasses.fields(Settings) if "Path" in str(f.type)} + service_written = {"workdir", "system_prompt_append"} + assert paths - service_written == set(inputs._OPERATOR_FILES) + + +def test_a_configured_file_is_refused_wherever_it_lives(workdir: Path, tmp_path: Path) -> None: + elsewhere = tmp_path / "operator" / "mcp.json" + elsewhere.parent.mkdir() + elsewhere.write_text("{}") + on_volume = workdir / "mcp.json" + configured = (elsewhere, on_volume) + reason = inputs.write_deny_reason(str(elsewhere), workdir=workdir, configured=configured) + assert reason is not None and "configuration" in reason + assert inputs.write_deny_reason("mcp.json", workdir=workdir, configured=configured) is not None, ( + "a relative path the agent writes from its cwd" + ) + assert inputs.write_deny_reason(str(elsewhere.parent / "notes.md"), workdir=workdir, configured=configured) is None + assert inputs.write_deny_reason("mcp.json", workdir=workdir) is None, "only because configuration names it" + + +def test_the_settings_name_every_configured_file(tmp_path: Path) -> None: + from tests.helpers import make_settings + + files = {name: tmp_path / f"{name}.txt" for name in inputs._OPERATOR_FILES} + settings = make_settings(tmp_path, system_prompt_append=tmp_path / "prompt.md", **files) + assert set(inputs.operator_files(settings)) == set(files.values()) + assert set(inputs.configured(settings)) == {*files.values(), tmp_path / "prompt.md"} + assert inputs.configured(make_settings(tmp_path)) == (), "nothing configured, nothing extra" + + +def test_a_shell_write_into_a_configured_file_is_refused_on_every_runtime(workdir: Path, tmp_path: Path) -> None: + """The shape of the 2026-11-02 measurement: one shell step wrote the MCP + config, and every later run started the command it named.""" + from hookprobe import gate + from hookprobe.engine import _bash_guard_hook + + mcp = workdir / "mcp.json" + command = f"printf '%s' '{{\"mcpServers\":{{}}}}' > {mcp}" + hook = _bash_guard_hook("readonly", None, workdir, None, (mcp,)) + out = asyncio.run(hook({"tool_name": "Bash", "tool_input": {"command": command}}, None, None)) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + verdict = gate.deny_reason("Bash", {"command": command}, workdir=workdir, configured=(mcp,)) + assert verdict is not None and verdict[0] == "input" + assert gate.deny_reason("Bash", {"command": command}, workdir=workdir) is None, "not without configuration" + + +def test_the_spawned_gate_learns_the_configured_files_from_its_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The gate is a separate process that knows only what the adapter put in + its environment, the same environment it reads its workdir from.""" + from hookprobe import gate + from tests.helpers import make_settings + + mcp = tmp_path / "operator" / "mcp.json" + settings = make_settings(tmp_path, mcp_config=mcp) + env = gate.environment(settings, "probe:t:1", package_root=str(tmp_path)) + assert str(mcp) in env["HOOKPROBE_GATE_CONFIGURED"].split(os.pathsep) + for name in ("HOOKPROBE_GATE_WORKDIR", "HOOKPROBE_GATE_HOME"): + monkeypatch.setenv(name, env[name]) + decision = gate.decide( + {"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": str(mcp)}}, + {**env, "HOOKPROBE_GATE_AUDIT": ""}, + ) + assert decision["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_the_hook_refuses_a_configured_file(workdir: Path, tmp_path: Path) -> None: + from hookprobe.engine import _input_guard_hook + + roles = tmp_path / "operator" / "agents.json" + hook = _input_guard_hook(workdir, None, None, (roles,)) + out = asyncio.run(hook({"tool_name": "Write", "tool_input": {"file_path": str(roles)}}, None, None)) + assert "configuration" in out["hookSpecificOutput"]["permissionDecisionReason"] + + +def test_a_changed_configured_file_is_caught_however_it_changed(workdir: Path, tmp_path: Path) -> None: + mcp = tmp_path / "operator" / "mcp.json" + mcp.parent.mkdir() + mcp.write_text("{}") + before = inputs.fingerprint(workdir, None, (mcp,)) + mcp.write_text('{"mcpServers": {"planted": {"command": "sh"}}}') + found = inputs.changes(before, inputs.fingerprint(workdir, None, (mcp,))) + assert found == [f"modified {mcp}"] + + +def test_a_configured_file_this_user_could_replace_is_reported(tmp_path: Path) -> None: + """A read-only file in a writable directory is replaceable, and a configured + path that does not exist yet in one can be created: both count.""" + volume = tmp_path / "volume" + volume.mkdir() + readonly_file = volume / "mcp.json" + readonly_file.write_text("{}") + readonly_file.chmod(0o444) + missing = volume / "agents.json" + assert inputs.writable((readonly_file, missing)) == [str(readonly_file), str(missing)] + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root can write anything that is not a read-only mount") +def test_a_file_this_user_cannot_write_is_not_reported(tmp_path: Path) -> None: + mounted = tmp_path / "mounted" + mounted.mkdir() + mcp = mounted / "mcp.json" + mcp.write_text("{}") + mcp.chmod(0o444) + mounted.chmod(0o555) + try: + assert inputs.writable((mcp,)) == [] + finally: + mounted.chmod(0o755) diff --git a/hookprobe/tests/test_output_guard.py b/hookprobe/tests/test_output_guard.py index 28101871..40f67ac7 100644 --- a/hookprobe/tests/test_output_guard.py +++ b/hookprobe/tests/test_output_guard.py @@ -1,11 +1,14 @@ """What came BACK from a tool, which nothing used to look at. -The input guard refuses changes, so `kubectl get secret db-creds -o yaml` and -`env` are both allowed under `readonly` — and they should be: an investigator -that cannot read cannot investigate. What was missing is that nothing then -examined the answer, so a run whose context was filled with a live credential -was indistinguishable from one that read a pod list. Nobody could decide to -discard the report or rotate the key, because nobody knew. +The input guard refuses changes, so `env`, a config file and a log line are all +allowed under `readonly` — and they should be: an investigator that cannot read +cannot investigate. Since 2026-11-03 one kind of read is refused under every +posture, the one whose whole content is a credential: a Secret's data, a +Secrets Manager value (guard.py, `_SECRET_RULES`). Everything else can still +carry one back. What was missing is that nothing then examined the answer, so +a run whose context was filled with a live credential was indistinguishable +from one that read a pod list. Nobody could decide to discard the report or +rotate the key, because nobody knew. Borrowed in idea from `ToolOutputGuardrail` in openai/openai-agents-python. """ @@ -20,11 +23,17 @@ from hookprobe import audit, gate -def test_the_input_guard_allows_reading_a_secret_which_is_the_whole_point(tmp_path: Path) -> None: +def test_the_input_guard_allows_the_reads_that_can_carry_a_secret_back(tmp_path: Path) -> None: """Pinned so the premise of this file cannot quietly change: these are - reads, `readonly` permits reads, and that is correct.""" - for command in ("kubectl get secret db-creds -o yaml", "env"): + reads, `readonly` permits reads, and that is correct. Printing a Secret's + data is the one read refused outright (2026-11-03); every other way a + credential can come back is this file's to record.""" + for command in ("env", "cat /etc/app/config.yaml", "kubectl logs deploy/api -n prod --since=1h"): assert gate.deny_reason("Bash", {"command": command}, bash_mode="readonly", workdir=tmp_path) is None + refused = gate.deny_reason( + "Bash", {"command": "kubectl get secret db-creds -o yaml"}, bash_mode="readonly", workdir=tmp_path + ) + assert refused is not None and refused[1].startswith("secret-value guard") @pytest.mark.parametrize( @@ -93,7 +102,7 @@ def test_a_flagged_answer_lands_in_the_audit_beside_the_call(tmp_path: Path, mon { "hook_event_name": "PostToolUse", "tool_name": "Bash", - "tool_input": {"command": "kubectl get secret db-creds -o yaml"}, + "tool_input": {"command": "cat /run/secrets/db-creds/credentials"}, "tool_response": response, }, dict(__import__("os").environ), @@ -113,7 +122,7 @@ def test_a_flagged_answer_lands_in_the_audit_beside_the_call(tmp_path: Path, mon assert [line.get("output_secret") for line in lines] == ["aws access key id", None] assert all(line["tool"] == "Bash" for line in lines) # The command is recorded as before — the flag is added, nothing replaced. - assert "kubectl get secret" in lines[0]["detail"] + assert "cat /run/secrets/db-creds" in lines[0]["detail"] def test_repeated_refusals_are_counted_where_one_refusal_used_to_look_the_same( @@ -192,7 +201,7 @@ def test_both_new_numbers_have_a_reader(tmp_path: Path, monkeypatch: pytest.Monk { "hook_event_name": "PostToolUse", "tool_name": "Bash", - "tool_input": {"command": "kubectl get secret x -o yaml"}, + "tool_input": {"command": "cat /run/secrets/x/credentials"}, "tool_response": {"stdout": "AKIAIOSFODNN7EXAMPLE"}, }, env, diff --git a/hookprobe/tests/test_posture.py b/hookprobe/tests/test_posture.py index 52519bbe..bb0bbca6 100644 --- a/hookprobe/tests/test_posture.py +++ b/hookprobe/tests/test_posture.py @@ -484,3 +484,134 @@ def test_a_missing_aws_cli_is_named_and_a_measured_violation_outranks_it(tmp_pat with pytest.raises(posture.PostureViolation, match=r"\*"): asyncio.run(posture.on_startup(tmp_path, "readonly", "enforce", run)) assert posture.read(tmp_path)["verdict"] == "wider-than-declared", "what was measured outranks what could not be" + + +# --- reported beside the verdict, never deciding it ------------------------- + +CAN_I_LIST_READS_SECRETS = ( + CAN_I_LIST_READONLY + + "secrets [] [] [get list watch]\n" +) + + +def test_reading_secrets_is_reported_and_decides_nothing(tmp_path, monkeypatch) -> None: + """A second deployment running copies of these files found its read-only + cluster identity reading Secret objects in every cluster. Allowed to read is + still read-only, so the verdict stands; what changes is that it is said.""" + _with_kube(tmp_path, monkeypatch) + run = _runner( + { + "kubectl auth can-i --list": (0, CAN_I_LIST_READS_SECRETS, ""), + "kubectl auth can-i get secrets": (0, "yes", ""), + } + ) + record = asyncio.run(posture.on_startup(tmp_path, "readonly", "enforce", run)) + assert record["verdict"] == "readonly-confirmed" + assert record["kube"]["reads_secrets"] == ["secrets (cluster-wide): get", "secrets: get list watch"] + assert "reads-secrets=2" in posture.summary(record) + + +def test_a_wildcard_grant_reads_secrets_too() -> None: + assert posture.kube_secret_rules(CAN_I_LIST_ADMIN) == ["*.*: *"] + assert posture.kube_secret_rules(CAN_I_LIST_READONLY) == [] + + +def test_a_reading_question_that_fails_cannot_change_the_verdict(tmp_path, monkeypatch) -> None: + _with_kube(tmp_path, monkeypatch) + run = _runner( + { + "kubectl auth can-i --list": (0, CAN_I_LIST_READONLY, ""), + "kubectl auth can-i list secrets": (1, "", "the server has asked for the client to provide credentials"), + } + ) + record = asyncio.run(posture.on_startup(tmp_path, "readonly", "enforce", run)) + assert record["verdict"] == "readonly-confirmed" and record["kube"]["errors"] == [] + assert record["kube"]["read_errors"] and "list secrets" in record["kube"]["read_errors"][0] + + +def _aws_reader(tmp_path, monkeypatch, reading_answer): + _no_aws_named(tmp_path, monkeypatch) + _with_aws_too(tmp_path, monkeypatch) + monkeypatch.setenv("KUBECONFIG", str(tmp_path / "no-kube")) + calls: list[list[str]] = [] + + async def run(argv: list[str]) -> tuple[int, str, str]: + calls.append(argv) + if argv[:2] == ["aws", "sts"]: + return 0, '{"Arn": "arn:aws:iam::123456789012:user/reader"}', "" + actions = argv[argv.index("--action-names") + 1 : argv.index("--output")] + if set(actions) == set(posture.SECRET_AWS_ACTIONS): + return reading_answer(actions) + denied = [{"EvalActionName": action, "EvalDecision": "implicitDeny"} for action in actions] + return 0, json.dumps({"EvaluationResults": denied}), "" + + return run, calls + + +def test_aws_secret_reads_are_a_second_simulation(tmp_path, monkeypatch) -> None: + def answer(actions): + allowed = {"s3:GetObject", "secretsmanager:GetSecretValue"} + results = [ + {"EvalActionName": a, "EvalDecision": "allowed" if a in allowed else "implicitDeny"} for a in actions + ] + return 0, json.dumps({"EvaluationResults": results}), "" + + run, calls = _aws_reader(tmp_path, monkeypatch, answer) + record = asyncio.run(posture.on_startup(tmp_path, "readonly", "enforce", run)) + assert record["verdict"] == "readonly-confirmed", "the dangerous simulation denied everything" + assert record["aws"]["reads_secrets"] == ["s3:GetObject", "secretsmanager:GetSecretValue"] + assert record["aws"]["allowed"] == [] and record["aws"]["read_errors"] == [] + assert len([a for a in calls if a[:2] == ["aws", "iam"]]) == 2 + assert "reads-secrets=2" in posture.summary(record) + + +def test_a_reading_simulation_that_fails_is_reported_not_judged(tmp_path, monkeypatch) -> None: + """Separate on purpose: a read-only identity that may not simulate the + reading actions must not become `unverifiable` because of a question that + decides nothing.""" + run, _ = _aws_reader(tmp_path, monkeypatch, lambda actions: (254, "", "AccessDenied")) + record = asyncio.run(posture.on_startup(tmp_path, "readonly", "enforce", run)) + assert record["verdict"] == "readonly-confirmed" and record["aws"]["unverifiable"] is False + assert record["aws"]["reads_secrets"] == [] and "AccessDenied" in record["aws"]["read_errors"][0] + + +def test_a_configured_file_a_run_could_rewrite_is_recorded_and_paged(tmp_path, monkeypatch) -> None: + """Measured 2026-11-02: an MCP config on the run's volume was written by one + run and started as a command by every run after it. Reported, never refused: + the input guard stops the obvious writes, the read-only mount is the + operator's to make.""" + _with_kube(tmp_path, monkeypatch) + volume = tmp_path / "volume" + volume.mkdir() + mcp = volume / "mcp.json" + mcp.write_text("{}") + alarm, pages = _pager() + run = _runner({"kubectl auth can-i --list": (0, CAN_I_LIST_READONLY, "")}) + workdir = tmp_path / "work" + record = asyncio.run(posture.on_startup(workdir, "readonly", "enforce", run, alarm=alarm, steering=(mcp,))) + assert record["verdict"] == "readonly-confirmed" + assert record["writable_inputs"] == [str(mcp)] == posture.read(workdir)["writable_inputs"] + assert "writable-inputs=1" in posture.summary(record) + assert len(pages) == 1 and str(mcp) in pages[0] + off = asyncio.run(posture.on_startup(workdir, "readonly", "off", run, steering=(mcp,))) + assert off["writable_inputs"] == [str(mcp)], "said even with the credential check switched off" + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root can write anything that is not a read-only mount") +def test_a_configured_file_nobody_here_can_write_is_quiet(tmp_path, monkeypatch) -> None: + _with_kube(tmp_path, monkeypatch) + mounted = tmp_path / "mounted" + mounted.mkdir() + mcp = mounted / "mcp.json" + mcp.write_text("{}") + mcp.chmod(0o444) + mounted.chmod(0o555) + alarm, pages = _pager() + run = _runner({"kubectl auth can-i --list": (0, CAN_I_LIST_READONLY, "")}) + try: + record = asyncio.run( + posture.on_startup(tmp_path / "work", "readonly", "enforce", run, alarm=alarm, steering=(mcp,)) + ) + finally: + mounted.chmod(0o755) + assert record["writable_inputs"] == [] and pages == [] diff --git a/scripts/assert_guards_are_tested.py b/scripts/assert_guards_are_tested.py index e630ca7f..f50ba2f6 100755 --- a/scripts/assert_guards_are_tested.py +++ b/scripts/assert_guards_are_tested.py @@ -41,6 +41,34 @@ # (name, file, exact text to replace, replacement, tests that must then fail) MUTATIONS: tuple[tuple[str, str, str, str, tuple[str, ...]], ...] = ( + ( + "a file the operator configured may be written by a run", + "hookprobe/hookprobe/inputs.py", + " paths.extend(configured)", + " paths.extend(())", + ("tests/test_inputs.py",), + ), + ( + "a configured file this user can rewrite is never reported", + "hookprobe/hookprobe/inputs.py", + " found.append(str(path))", + " pass", + ("tests/test_inputs.py", "tests/test_posture.py"), + ), + ( + "a secret's value may be printed", + "hookprobe/hookprobe/guard.py", + " for pattern, label in _SECRET_RULES:", + " for pattern, label in ():", + ("tests/test_guard.py",), + ), + ( + "posture stops asking whether secrets can be read", + "hookprobe/hookprobe/posture.py", + 'KUBE_SECRET_QUESTIONS = (("get", "secrets"), ("list", "secrets"))', + "KUBE_SECRET_QUESTIONS = ()", + ("tests/test_posture.py",), + ), ( "alert and chat text reach the CLI as typed input again", "hookprobe/hookprobe/engine.py", From 574c857fc6696720c6fe75655eff0f077dc671d7 Mon Sep 17 00:00:00 2001 From: itswl Date: Tue, 3 Nov 2026 08:47:18 +0800 Subject: [PATCH 2/2] hookprobe: the rule and question tables are named without the word code scanning reads as secret data CodeQL followed `_SECRET_RULES` into the refusal text, which the gate prints and the audit stores, and raised clear-text logging and storage of sensitive data on both (alerts 15 and 16). The table holds patterns and fixed descriptions, never a value: its name was the only "secret" in the flow. The guard's table is `_REVEAL_RULES` and its resource pattern `_RESOURCE_WORD` (the bandit annotation the old name needed goes with it), and posture's questions are `KUBE_READ_QUESTIONS`, `READ_VERBS` and `AWS_READ_ACTIONS`, with a comment beside each saying why. The record fields an operator reads keep their names. --- ...e-openai-agents-sdk-was-worth-borrowing.md | 2 +- hookprobe/hookprobe/guard.py | 11 +++++--- hookprobe/hookprobe/posture.py | 28 ++++++++++--------- hookprobe/tests/test_output_guard.py | 2 +- hookprobe/tests/test_posture.py | 6 ++-- scripts/assert_guards_are_tested.py | 6 ++-- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md b/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md index d5f22afb..cbd5ffb1 100644 --- a/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md +++ b/.agents/notes/proposed/2026-09-09-what-the-openai-agents-sdk-was-worth-borrowing.md @@ -50,7 +50,7 @@ the key. It records the KIND and never the match, because the reason string reaches an audit file and sometimes a chat card. *Corrected 2026-11-03:* printing a Secret's data is now refused under every posture, with Secrets Manager values, decrypted SSM parameters and KMS -plaintext (`_SECRET_RULES` in guard.py; note +plaintext (`_REVEAL_RULES` in guard.py; note 2026-11-03-read-only-still-reads-secret-values). `env` and every other read stay allowed, and the output guard still records what comes back from them. diff --git a/hookprobe/hookprobe/guard.py b/hookprobe/hookprobe/guard.py index cb7eb631..088e8ed9 100644 --- a/hookprobe/hookprobe/guard.py +++ b/hookprobe/hookprobe/guard.py @@ -221,16 +221,19 @@ # runtime, a pod that mounts the secret, or another vendor's CLI is not caught. # The credential is what holds, and posture reports whether it can read secrets # at all (`reads_secrets` on /v1/posture). +# Named without the word: code scanning reads any name holding it as secret +# data and follows it into the refusal text, and these hold patterns and +# descriptions, never a value. _KUBE = r"\b(?:kubectl|oc|kubecolor)\b" _AWS = r"(? str | None: f"unseen-code guard: {label} is blocked. The guard cannot read a script it never " f"sees; fetch it to a file, read it, and run the commands it holds one by one" ) - for pattern, label in _SECRET_RULES: + for pattern, label in _REVEAL_RULES: if pattern.search(command): return ( f"secret-value guard: {label} is blocked under every posture. A value printed here " diff --git a/hookprobe/hookprobe/posture.py b/hookprobe/hookprobe/posture.py index 2ef8b90e..40c71aaf 100644 --- a/hookprobe/hookprobe/posture.py +++ b/hookprobe/hookprobe/posture.py @@ -96,9 +96,11 @@ # measured it on 2026-11-02: its read-only cluster identity read Secret objects # in every cluster, and its read-only AWS policy read S3 object contents, which # is where state files and backups keep their secrets in plain text. -KUBE_SECRET_QUESTIONS = (("get", "secrets"), ("list", "secrets")) -SECRET_READ_VERBS = frozenset({"get", "list", "watch", "*"}) -SECRET_AWS_ACTIONS = ( +# Named without the word, like guard.py's table: code scanning reads such a +# name as secret data, and these are questions. +KUBE_READ_QUESTIONS = (("get", "secrets"), ("list", "secrets")) +READ_VERBS = frozenset({"get", "list", "watch", "*"}) +AWS_READ_ACTIONS = ( "secretsmanager:GetSecretValue", "ssm:GetParameter", "kms:Decrypt", @@ -154,7 +156,7 @@ def kube_mutating_rules(can_i_list: str) -> list[str]: return found -def kube_secret_rules(can_i_list: str) -> list[str]: +def kube_reading_rules(can_i_list: str) -> list[str]: """The rules in `kubectl auth can-i --list` output that let this identity read Secret objects here: `secrets` itself, or a wildcard resource, with a verb that returns the data.""" @@ -165,8 +167,8 @@ def kube_secret_rules(can_i_list: str) -> list[str]: continue resource = line.split()[0] verbs = {v.strip() for v in line.rsplit("[", 1)[1].rstrip("]").split() if v.strip()} - if resource.split(".")[0] in {"secrets", "*"} and verbs & SECRET_READ_VERBS: - found.append(f"{resource}: {' '.join(sorted(verbs & SECRET_READ_VERBS))}") + if resource.split(".")[0] in {"secrets", "*"} and verbs & READ_VERBS: + found.append(f"{resource}: {' '.join(sorted(verbs & READ_VERBS))}") return found @@ -239,7 +241,7 @@ async def check_kube(run: Runner) -> dict[str, Any]: record["errors"].append(f"can-i --list: {(err or out).strip()[:200]}") else: record["mutating"].extend(kube_mutating_rules(out)) - record["reads_secrets"].extend(kube_secret_rules(out)) + record["reads_secrets"].extend(kube_reading_rules(out)) for verb, resource in KUBE_QUESTIONS: code, out, err = await run(["kubectl", "auth", "can-i", verb, resource, "--all-namespaces"]) answer = out.strip().lower() @@ -250,7 +252,7 @@ async def check_kube(run: Runner) -> dict[str, Any]: record["mutating"] = sorted(set(record["mutating"])) # Reading questions. Their failures go to `read_errors`, never `errors`: # an answer that decides nothing must not be able to change the verdict. - for verb, resource in KUBE_SECRET_QUESTIONS: + for verb, resource in KUBE_READ_QUESTIONS: code, out, err = await run(["kubectl", "auth", "can-i", verb, resource, "--all-namespaces"]) answer = out.strip().lower() if answer.startswith("yes"): @@ -379,11 +381,11 @@ async def check_aws(run: Runner) -> dict[str, Any]: except (ValueError, KeyError, TypeError, AttributeError): record["errors"].append("simulate-principal-policy: invalid or incomplete results") record["unverifiable"] = True - record["reads_secrets"], record["read_errors"] = await _secret_reads(run, role_arn(arn)) + record["reads_secrets"], record["read_errors"] = await _aws_reading(run, role_arn(arn)) return record -async def _secret_reads(run: Runner, principal: str) -> tuple[list[str], list[str]]: +async def _aws_reading(run: Runner, principal: str) -> tuple[list[str], list[str]]: """Which secret-reading actions the identity holds, and what stopped the question. A separate simulation from the dangerous one, so that a failure here is reported and can never make a verdict unverifiable.""" @@ -395,7 +397,7 @@ async def _secret_reads(run: Runner, principal: str) -> tuple[list[str], list[st "--policy-source-arn", principal, "--action-names", - *SECRET_AWS_ACTIONS, + *AWS_READ_ACTIONS, "--output", "json", ] @@ -407,8 +409,8 @@ async def _secret_reads(run: Runner, principal: str) -> tuple[list[str], list[st decided = {str(item["EvalActionName"]): str(item["EvalDecision"]) for item in results} except (ValueError, KeyError, TypeError, AttributeError): return [], ["simulate-principal-policy (reads): invalid results"] - missing = [action for action in SECRET_AWS_ACTIONS if action not in decided] - allowed = sorted(action for action in SECRET_AWS_ACTIONS if decided.get(action) == "allowed") + missing = [action for action in AWS_READ_ACTIONS if action not in decided] + allowed = sorted(action for action in AWS_READ_ACTIONS if decided.get(action) == "allowed") return allowed, ([f"simulate-principal-policy (reads): no answer for {', '.join(missing)}"] if missing else []) diff --git a/hookprobe/tests/test_output_guard.py b/hookprobe/tests/test_output_guard.py index 40f67ac7..7b5fdcd1 100644 --- a/hookprobe/tests/test_output_guard.py +++ b/hookprobe/tests/test_output_guard.py @@ -4,7 +4,7 @@ allowed under `readonly` — and they should be: an investigator that cannot read cannot investigate. Since 2026-11-03 one kind of read is refused under every posture, the one whose whole content is a credential: a Secret's data, a -Secrets Manager value (guard.py, `_SECRET_RULES`). Everything else can still +Secrets Manager value (guard.py, `_REVEAL_RULES`). Everything else can still carry one back. What was missing is that nothing then examined the answer, so a run whose context was filled with a live credential was indistinguishable from one that read a pod list. Nobody could decide to discard the report or diff --git a/hookprobe/tests/test_posture.py b/hookprobe/tests/test_posture.py index bb0bbca6..3c436929 100644 --- a/hookprobe/tests/test_posture.py +++ b/hookprobe/tests/test_posture.py @@ -512,8 +512,8 @@ def test_reading_secrets_is_reported_and_decides_nothing(tmp_path, monkeypatch) def test_a_wildcard_grant_reads_secrets_too() -> None: - assert posture.kube_secret_rules(CAN_I_LIST_ADMIN) == ["*.*: *"] - assert posture.kube_secret_rules(CAN_I_LIST_READONLY) == [] + assert posture.kube_reading_rules(CAN_I_LIST_ADMIN) == ["*.*: *"] + assert posture.kube_reading_rules(CAN_I_LIST_READONLY) == [] def test_a_reading_question_that_fails_cannot_change_the_verdict(tmp_path, monkeypatch) -> None: @@ -540,7 +540,7 @@ async def run(argv: list[str]) -> tuple[int, str, str]: if argv[:2] == ["aws", "sts"]: return 0, '{"Arn": "arn:aws:iam::123456789012:user/reader"}', "" actions = argv[argv.index("--action-names") + 1 : argv.index("--output")] - if set(actions) == set(posture.SECRET_AWS_ACTIONS): + if set(actions) == set(posture.AWS_READ_ACTIONS): return reading_answer(actions) denied = [{"EvalActionName": action, "EvalDecision": "implicitDeny"} for action in actions] return 0, json.dumps({"EvaluationResults": denied}), "" diff --git a/scripts/assert_guards_are_tested.py b/scripts/assert_guards_are_tested.py index f50ba2f6..67dc4314 100755 --- a/scripts/assert_guards_are_tested.py +++ b/scripts/assert_guards_are_tested.py @@ -58,15 +58,15 @@ ( "a secret's value may be printed", "hookprobe/hookprobe/guard.py", - " for pattern, label in _SECRET_RULES:", + " for pattern, label in _REVEAL_RULES:", " for pattern, label in ():", ("tests/test_guard.py",), ), ( "posture stops asking whether secrets can be read", "hookprobe/hookprobe/posture.py", - 'KUBE_SECRET_QUESTIONS = (("get", "secrets"), ("list", "secrets"))', - "KUBE_SECRET_QUESTIONS = ()", + 'KUBE_READ_QUESTIONS = (("get", "secrets"), ("list", "secrets"))', + "KUBE_READ_QUESTIONS = ()", ("tests/test_posture.py",), ), (