Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Changes

- **A Claude Code setting that runs a command, or an `env` entry that redirects requests or loads code, is a host-grant row, and a settings change no row shows is no longer "no change".** Adding `"statusLine": {"command": "curl … | sh"}`, `"apiKeyHelper": "curl … | sh"`, `env.ANTHROPIC_BASE_URL` or `env.NODE_OPTIONS` to `.claude/settings.json` gave no row: `diff` (text and `--json`), `verify`'s host comparison and the PR comment headlined `No static host-grant changes detected. No verdict is implied.`, and `audit --host` reported Claude Code coverage `complete`. Host-grants `0.9` (extended in place) now publishes `command_setting` grants for `statusLine`, `subagentStatusLine`, `fileSuggestion`, `apiKeyHelper`, `otelHeadersHelper`, `awsAuthRefresh` and `awsCredentialExport` (`high`, the command shown as a hook's is: name, digest and shape, never text) and `env` grants for endpoint, trust and loader names (`*_BASE_URL`, OpenTelemetry exporter endpoints, the proxies, `NODE_EXTRA_CA_CERTS`, `NODE_TLS_REJECT_UNAUTHORIZED`, `NODE_OPTIONS`, `BASH_ENV`, `CLAUDE_CODE_SHELL_PREFIX`, `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`; `high`, the value digested and never published), so adding one is a `⚠ high added` row and editing one a `changed` row naming the two digests. A settings file's other keys are published as display-only `unmodelled_keys` with digests; when one changes with no row, the coverage item names it in `detail` and the headline reads `Changed, not compared: .claude/settings.json (keys this entry does not compare: …)`, as it does, without keys, for any settings, MCP or hook file that changed with no row. A key no reader models that is shaped as a command (an object with a `command`, or a `…Helper` string) is a blocking limit, so `audit --host` reports coverage `partial`, as for `policyHelper`. The cold-start benchmark's oracle moves those seven settings and those `env` names from unsupported to supported. Migration: [STABILITY.md](STABILITY.md#host-commands-env-1027). (#1027)
- **An n8n MCP Client Tool that exposes every tool of its server is read as doing so.** n8n's node selects its tools with `include` (`all`, `selected` or `except`) and leaves it out of an exported workflow at its default, `all`; the reader recognised only `toolSelection`, `toolsToInclude` and `toolSelectionMode`, which n8n does not write. So the default export, `include: all` and `include: except` each entered the catalog as one concretely named tool, with no wildcard, no dynamic surface and no `SHIP-N8N-MCP-CLIENT-TOOLSET-UNFILTERED`. Each is now one `<node>.*` action, which raises `SHIP-INVENTORY-WILDCARD-TOOLS` and, with no local inventory declared, `SHIP-N8N-DYNAMIC-TOOL-SURFACE-NOT-ENUMERABLE` and the unfiltered finding (`high` in `production_like` and `production`, `medium` otherwise); an `except` node's `excludeTools` are listed in the finding's `excluded_tools`; `selected` names its `includeTools`, and a `selected` node naming none is read as `all`, as n8n runs it. The earlier spellings remain aliases, and `include` decides when both are present. A workflow whose MCP Client Tool keeps n8n's default now reports these findings where it passed; select explicit tools in the node, or declare a local MCP inventory. Migration: [STABILITY.md](STABILITY.md#mcp-reader-conformance-1025). (#1025)
- **`SHIP-MCP-ENV-SECRET-PASSTHROUGH` counts a secret an MCP server forwards by name, not only an `env` entry.** A secret-like variable in Codex's `env_vars` list or `env_http_headers`, any `bearer_token_env_var`, and a `${NAME}` reference in a `headers` value (`.mcp.json`) now fire the finding (an editor's `${env:NAME}` is read as `NAME` there and in `env`, and a prompted `${input:…}` names no variable) and add to the server's risk score as an `env` entry always did. A credential-named header (`Authorization`) counts the variable it carries whatever that variable is called, and a value written where a variable name belongs is redacted in the evidence, not echoed. (#1025)
- **A `codex_config` source names the other hosts' MCP config files it does not read.** Its coverage said it read `.mcp.json`, `.codex/config.toml` "and the other host MCP config files" under its root; it reads only the first two. A `.cursor/mcp.json` or `.vscode/mcp.json` there is now named in a source warning, so a workspace whose servers live only in one no longer passes silently, and the coverage text in [`docs/determinism-boundary.md`](docs/determinism-boundary.md) names exactly the files read and the files named. Reading those files is #663. (#1025)
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ Conditional review choices only; current control permissions still apply. A PR n

The answer is one of these, and they mean different things: named changes,
like these; `No static host-grant changes detected.` when no compared grant
differs; or `Cannot compare against <base>: <reason>` when an input could not be
read, which is an input limit and never a quiet pass. Either of the first two
can open with `Not compared:` and a list of sources the change did not touch
and `diff` could not read; nothing is claimed about those. Since `1.2.0` there
is also a fourth answer, `Partial comparison
differs and no settings file changed; `Changed, not compared: <file> (<keys>)`
when a settings, MCP or hook file changed with no row, such as in a key this
entry does not compare (#1027); or `Cannot compare against <base>: <reason>`
when an input could not be read, which is an input limit and never a quiet
pass. Any of the first three can open with `Not compared:` and a list of
sources the change did not touch and `diff` could not read; nothing is claimed
about those. Since `1.2.0` there is also a fifth answer, `Partial comparison
against <base> …: <reason>`, where the only inputs it could not read are
plugin directories whose references stop inside them: it names each one as
`Not compared: <directory>`, shows the changes outside it, and says they are
Expand Down
73 changes: 73 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ unreleased, gains two optional members on a reusable call. No control state
or `check` decision changes. See
[the migration note](#actions-token-scopes-921).

Also unreleased: Claude Code settings that run a command (`statusLine`,
`apiKeyHelper` and five more) and `env` entries that redirect requests or load
code (`ANTHROPIC_BASE_URL`, `NODE_OPTIONS`, …) are host grants and rows
(#1027). Host-grants `0.9`, unreleased, gains `command_setting` and `env`
grants and a display-only `unmodelled_keys` on settings artifacts; a settings
file that changed with no row headlines `Changed, not compared` instead of
`No static host-grant changes detected`. No `check` decision changes. See
[the migration note](#host-commands-env-1027).

New in 1.2.0, #829 adds a source-local residual-prefix explanation to the existing
host comparison row `why` text for supported Claude Code `git push` allows.
It reads all compared head deny rules in that source, including unchanged
Expand Down Expand Up @@ -347,6 +356,70 @@ the Action tag) for reproducible CI.

---

<a id="host-commands-env-1027"></a>

## Migration Note: Unreleased — Claude Code commands and endpoint `env` entries are grants, and a settings change no row shows is not "no change" (host-grants `0.9`, #1027)

**What was wrong.** Claude Code runs `statusLine.command` and `apiKeyHelper`
as shell commands, and an `env` entry such as `ANTHROPIC_BASE_URL` or
`NODE_OPTIONS` redirects its requests or injects code. No reader published a
grant for them, so adding `"statusLine": {"command": "curl … | sh"}` to
`.claude/settings.json` gave no row: `diff`, `verify`'s host comparison and
the PR comment headlined `No static host-grant changes detected. No verdict is
implied.`, and `audit --host` reported Claude Code coverage `complete`. Since
1.1.0 (#812) the coverage item said `changed_without_grant_change` with a note
blaming redaction, which was not why `statusLine` went unread. `check` already
routed the edit to review, so no gate was bypassed.

**What changes.**

- **Grants.** Host-grants `0.9`, extended in place (it has not shipped in a
tagged release), gains two grant kinds for Claude Code settings files:
`command_setting` (`statusLine`, `subagentStatusLine`, `fileSuggestion`,
`apiKeyHelper`, `otelHeadersHelper`, `awsAuthRefresh`,
`awsCredentialExport`; `execute`/`high`, with a display-only `command` as
a hook handler's) and `env` (endpoint, trust and loader names only, `high`,
the value digested and never published). Adding one is a widening, editing
one a `changed` row of unknown direction. Both merge across settings layers.
- **Artifacts.** A parsed Claude Code settings artifact gains display-only
`unmodelled_keys`: each key no reader compares, with a digest. Artifact
equality, the inventory digests and saved baselines leave it out, so
`base_inventory_sha256`, `head_inventory_sha256` and a baseline's
`inventory_sha256` move only where the new grants do.
- **Coverage.** A `changed_without_grant_change` or `changed_without_rows`
item may carry `detail`, naming the keys no reader compares that changed
(`keys this entry does not compare: …`). Verifier `0.21`'s schema already
allowed a `detail` on every item and is unchanged; a reader that applied this
engine's earlier cross-field rule would refuse such an item. The redacted-
values note now reads `(redacted values, such as env values this entry does
not model, are not compared)`.
- **Headline.** A comparable comparison with no row in which a settings, MCP or
hook file changed reads `Changed, not compared: <file> (<keys>). No
host-grant row describes this change. No verdict is implied.` in `diff`,
`verify --format text` and the PR comment, where it read `No static
host-grant changes detected`. That headline is kept where no such file
changed; a workflow or instruction document keeps its own coverage line.
- **Coverage partial.** A Claude Code settings key no reader compares that is
shaped as a command (an object holding a string `command`, or a string under
a `…Helper` key) is a blocking `unsupported` issue, as `policyHelper` is, so
`audit --host` reports Claude Code `partial`, a baseline cannot be saved, and
a comparison of a change to that file is refused.

**What does not change.** No CLI flag, check ID, control state, permission or
`check` decision. `minimum_control_contract_version` stays `21`. An `env`
entry other than those names keeps its value redacted, so rotating a token
there is quiet, and an MCP server's `env` is unchanged.

**Who must act.** A saved host-grants baseline from a file that declares one of
these settings or `env` names reads them as added on the first drift after
upgrading: review them and re-save with `audit --host --save-baseline`. A
consumer matching the old headline or the old redacted-values note on a
zero-row comparison reads the new text. A file holding a command-shaped key
this entry does not model must have the key removed, or wait for a reader,
before its comparison is complete.

---

<a id="mcp-reader-conformance-1025"></a>

## Migration Note: Unreleased — n8n's `include`, MCP secrets forwarded by name, and the host MCP files a `codex_config` source does not read (#1025)
Expand Down
44 changes: 40 additions & 4 deletions benchmark/cold-start/expected.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,33 @@
# "MCP restrictions" in the matrix: which project `.mcp.json` servers load.
"enabledMcpjsonServers", "disabledMcpjsonServers",
})
CLAUDE_UNSUPPORTED_CAPABILITY_KEYS = frozenset({
"apiKeyHelper", "awsAuthRefresh", "awsCredentialExport", "otelHeadersHelper",
"statusLine", "fileSuggestion", "subagentStatusLine", "env",
"forceLoginMethod", "forceLoginOrgUUID",
#: "settings it runs as commands" in the matrix (#1027): the settings
#: reference documents each as a shell command Claude Code runs, the first
#: three as an object's `command`, the rest as the string itself.
CLAUDE_COMMAND_KEYS = frozenset({
"statusLine", "subagentStatusLine", "fileSuggestion",
"apiKeyHelper", "otelHeadersHelper", "awsAuthRefresh", "awsCredentialExport",
})
#: "endpoint, trust and loader `env` entries" in the matrix (#1027): the names
#: that set where requests go (any `*_BASE_URL`, an OpenTelemetry exporter
#: endpoint, the documented proxies), which TLS certificates Node.js trusts,
#: or what code loads beside the agent. Any other `env` entry is unsupported.
CLAUDE_ENV_NAMES = frozenset({
"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy",
"NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
"NODE_OPTIONS", "BASH_ENV", "CLAUDE_CODE_SHELL_PREFIX", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES",
})
CLAUDE_UNSUPPORTED_CAPABILITY_KEYS = frozenset({"forceLoginMethod", "forceLoginOrgUUID"})


def claude_env_supported(name: str) -> bool:
"""Whether the support matrix claims a Claude Code `env` entry by this name (#1027)."""

return (
name in CLAUDE_ENV_NAMES
or name.endswith("_BASE_URL")
or (name.startswith("OTEL_EXPORTER_OTLP_") and name.endswith("ENDPOINT"))
)
#: Keys with no capability effect: presentation, model choice, housekeeping.
CLAUDE_BENIGN_KEYS = frozenset({
"$schema", "model", "fallbackModel", "availableModels", "theme", "outputStyle",
Expand Down Expand Up @@ -132,6 +154,20 @@ def claude_settings(before_text: str | None, after_text: str | None) -> dict[str
changes += _list_changes("additional_path", "", old, new)
elif key in {"enabledMcpjsonServers", "disabledMcpjsonServers"}:
changes += _list_changes("setting", f"{key}:", old, new)
elif key in CLAUDE_COMMAND_KEYS:
if change := _change("command_setting", key, old, new):
changes.append(change)
elif key == "env" and isinstance(old or {}, dict) and isinstance(new or {}, dict):
for change in _map_changes("env", "", old, new):
if claude_env_supported(change["key"]):
changes.append(change)
else:
changes.append({**change, "kind": "unsupported_setting", "key": f"env.{change['key']}"})
scope = "unsupported"
elif key == "env":
if change := _change("unsupported_setting", key, old, new):
changes.append(change)
scope = "unsupported"
elif key in CLAUDE_SUPPORTED_KEYS:
if change := _change("setting", key, old, new):
changes.append(change)
Expand Down
5 changes: 4 additions & 1 deletion benchmark/cold-start/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ def _tokens(change: dict[str, Any]) -> tuple[str | None, str | None]:
kind, key = change["kind"], change["key"]
if kind == "permission_rule":
name = key.split(":", 1)[1]
elif kind in {"mcp_server", "hook", "plugin"}:
elif kind in {"mcp_server", "hook", "plugin", "command_setting"}:
name = key
elif kind == "env":
# A row names an env entry, never its value (#1027).
name = f"env.{key}"
elif kind == "additional_path":
name = "additional_path"
elif kind == "setting":
Expand Down
2 changes: 1 addition & 1 deletion docs/distribution-surfaces.md

Large diffs are not rendered by default.

Loading
Loading