Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`_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.

**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
Expand Down
29 changes: 18 additions & 11 deletions deploy/docker-compose.work.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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".
Expand Down
2 changes: 1 addition & 1 deletion deploy/sidecars/mcp.example.json
Original file line number Diff line number Diff line change
@@ -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/<route>/ 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/<route>/ and the same bearer.",
"mcpServers": {
"chat": {
"type": "http",
Expand Down
Loading
Loading