From d777d0e7d7b6dc4dc4d61c5b08d166773c2c9751 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:15:40 +0000 Subject: [PATCH 01/33] docs: route 2 is the builder; plugin repos marked retired README route 2 becomes one merged plugin from a chock.sh selection (chock install --selection '' --apply) and absorbs the old route 3. The five chock-*-plugins rows read "retired" until they are removed. docs/agent-plugins.md lists the builder command and install steps per client from install/data/install.json instead of the repos; ecosystem.md likewise. repos.json is the source; data.json is regenerated by derive.py repos. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- README.md | 15 +++++++-------- docs/agent-plugins.md | 36 +++++++++++++++++++++++++++-------- docs/assets/readme/data.json | 20 +++++++++---------- docs/assets/readme/render.mjs | 6 +++--- docs/assets/readme/repos.json | 10 +++++----- docs/ecosystem.md | 2 +- 6 files changed, 54 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d779e8bb..d7d443a6 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,7 @@ pip install "chock @ git+https://github.com/open-coder-ai/chock@992711af4cf8d4fd ### Two ways to adopt it 1. **In your repository, for teams.** Run `chock init .`, then `chock add --ref --verify-sha --skip-compile` for each policy, then `chock sync --repo . --ci`. Commit the result. A fresh clone arms itself from what is committed; a person runs `chock sync` to change it. The commit gates are enforced at commit and in CI. -2. **In your coding agent, as plugins.** Best-effort, and they fail open. One repo per client: [Claude Code](https://github.com/open-coder-ai/chock-claude-plugins), [Cursor](https://github.com/open-coder-ai/chock-cursor-plugins), [Copilot](https://github.com/open-coder-ai/chock-copilot-plugins), [Codex](https://github.com/open-coder-ai/chock-codex-plugins), [Devin](https://github.com/open-coder-ai/chock-devin-plugins). Each README has the install line for its client. -3. **One plugin for your agent from a selection.** The chock.sh builder (launching soon) gives a `chock install --selection '…' --apply` command for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, Gemini CLI, OpenHands, Kimi Code or Factory Droid. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. +2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. ## Application security for the code your agents write @@ -228,7 +227,7 @@ The 15 in the table above get a git hook, a CI gate and an `AGENTS.md` rule; mos ### How do I install it? -See [Install](#install): in your repo, or as plugins. +See [Install](#install): in your repo, or as one plugin from the builder. ### What does it cost? @@ -262,11 +261,11 @@ The 13 public repositories: | [chock-catalog](https://github.com/open-coder-ai/chock-catalog) | Policies: The policies, each labelled by what it enforces, with replayed evals. | | [context-report](https://github.com/open-coder-ai/context-report) | Evidence: A signed report of whether an agent artifact works. | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | Evidence: A weekly threat ledger, each entry scored against the catalog. | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Plugins: The catalog as Claude Code plugins (generated). | -| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Plugins: The catalog as Copilot CLI and VS Code plugins (generated). | -| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Plugins: The catalog as Cursor plugins (generated). | -| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Plugins: The catalog as Codex plugins (generated). | -| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Plugins: The catalog as Devin plugins (generated). | +| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Retired: plugins come from the chock.sh builder. | +| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Retired: plugins come from the chock.sh builder. | +| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Retired: plugins come from the chock.sh builder. | +| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Retired: plugins come from the chock.sh builder. | +| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Retired: plugins come from the chock.sh builder. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) | Template: What chock init leaves behind. | | [chock-example](https://github.com/open-coder-ai/chock-example) | Template: A working adoption, one policy per layer. | | [.github](https://github.com/open-coder-ai/.github) | Community: Org profile and community health files. | diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index b88905f5..bcf7f9e5 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -189,20 +189,40 @@ mere presence of two files. The Agent Plugins 1.0 standard carries no hooks, so an `agent-plugins` package is advisory by construction. Enforcement travels in five vendor plugin formats built from the same -policies (`chock plugin build --format claude|copilot|cursor|codex|devin`). Four of them are -published in a generated distribution repo of their own, and each of those is **witnessed -denying a destructive command on a real install**: +policies (`chock plugin build --format claude|copilot|cursor|codex|devin`). A user gets them as +one merged plugin from a chock.sh selection, built on their machine: -| Vendor repo | Client(s) | Deny dialect | +``` +chock install --selection '' --client --apply +``` + +`--apply` runs the client's own commands where it has them; otherwise the step is yours. +Four formats are each **witnessed denying a destructive command on a real install**: + +| Format | Client(s) | Deny dialect | | :--- | :--- | :--- | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Claude Code | exit 2 | -| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Copilot CLI, VS Code | exit 2 | -| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Cursor | stdout `{"permission": "deny"}` | -| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | +| claude | Claude Code | exit 2 | +| copilot | Copilot CLI, VS Code | exit 2 | +| cursor | Cursor | stdout `{"permission": "deny"}` | +| codex | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | One guard, one adapter, byte-identical across all four — only the envelope each client reads differs. +| `--client` | Client | `--apply` runs | You do | +| :--- | :--- | :--- | :--- | +| `claude-code` | Claude Code | `claude plugin marketplace add`, `claude plugin install` | restart the session or `/reload-plugins` | +| `copilot` | GitHub Copilot | `copilot plugin marketplace add`, `copilot plugin install` | VS Code without the CLI: add the plugin folder to `chat.pluginLocations` | +| `codex` | Codex | `codex plugin marketplace add` | install in `/plugins`, trust its hooks in `/hooks` | +| `cursor` | Cursor | nothing | Developer: Reload Window | +| `devin` | Devin | `devin plugins install --local` | nothing | +| `goose` | goose | nothing | start a new session | +| `grok` | Grok | `grok plugin install --trust` | nothing | +| `antigravity` | Antigravity | `agy plugin install` | nothing | +| `openhands` | OpenHands | nothing | `openhands --plugin `, or add it to `[plugins]` sources in `~/.openhands/config.toml`; keep the folder in place | +| `kimi-code` | Kimi Code | nothing | in its TUI, `/plugins install ` then `/reload`; again after a rebuild | +| `factory-droid` | Factory Droid | `droid plugin marketplace add`, `droid plugin install --scope project` | nothing | + Every hook-carrying format also carries a policy's **gate** when the gate declares `tool_use`: the compiled `scripts/gate.json`, the stdlib runner beside it as `scripts/gate.py`, and for `kind: script` the policy's whole `implementations/` under `scripts/`. The hooks file diff --git a/docs/assets/readme/data.json b/docs/assets/readme/data.json index 76165cd2..8b504cf4 100644 --- a/docs/assets/readme/data.json +++ b/docs/assets/readme/data.json @@ -140,28 +140,28 @@ }, { "name": "chock-claude-plugins", - "kind": "Plugins", - "line": "The catalog as Claude Code plugins (generated)." + "kind": "Retired", + "line": "plugins come from the chock.sh builder." }, { "name": "chock-copilot-plugins", - "kind": "Plugins", - "line": "The catalog as Copilot CLI and VS Code plugins (generated)." + "kind": "Retired", + "line": "plugins come from the chock.sh builder." }, { "name": "chock-cursor-plugins", - "kind": "Plugins", - "line": "The catalog as Cursor plugins (generated)." + "kind": "Retired", + "line": "plugins come from the chock.sh builder." }, { "name": "chock-codex-plugins", - "kind": "Plugins", - "line": "The catalog as Codex plugins (generated)." + "kind": "Retired", + "line": "plugins come from the chock.sh builder." }, { "name": "chock-devin-plugins", - "kind": "Plugins", - "line": "The catalog as Devin plugins (generated)." + "kind": "Retired", + "line": "plugins come from the chock.sh builder." }, { "name": "chock-quickstart", diff --git a/docs/assets/readme/render.mjs b/docs/assets/readme/render.mjs index 2fd99bc0..c3da623c 100644 --- a/docs/assets/readme/render.mjs +++ b/docs/assets/readme/render.mjs @@ -115,9 +115,9 @@ ${term([cmd('chock init .'), cmd('chock add <id> --ref <sha> \\'), '
Everyone and every agent on the repo. Enforced at commit and in CI. Travels with every clone.
Route 2no repo changes
-
In your coding agent, as plugins
-${term(['
Claude Code
', '
chock-claude-plugins
', '
Cursor · Copilot · Codex · Devin
', '
one plugin repo per client
'])} -
Your own sessions in that client. Best-effort hooks that fail open. Pair with route 1 for commit and CI.
+
In your coding agent, as one plugin
+${term([cmd('chock install --selection '<code>' --apply'), '
from the chock.sh builder
'])} +
Your own sessions in that client, any client chock install supports. Best-effort hooks that fail open. Pair with route 1 for commit and CI.
Or build a selection: the chock.sh builder (launching soon) gives one plugin for your agent from the policies you pick, your own included.
`); diff --git a/docs/assets/readme/repos.json b/docs/assets/readme/repos.json index b2a387ef..f29a0804 100644 --- a/docs/assets/readme/repos.json +++ b/docs/assets/readme/repos.json @@ -4,11 +4,11 @@ {"name": "chock-catalog", "kind": "Policies", "line": "The policies, each labelled by what it enforces, with replayed evals."}, {"name": "context-report", "kind": "Evidence", "line": "A signed report of whether an agent artifact works."}, {"name": "chock-threat-intel", "kind": "Evidence", "line": "A weekly threat ledger, each entry scored against the catalog."}, - {"name": "chock-claude-plugins", "kind": "Plugins", "line": "The catalog as Claude Code plugins (generated)."}, - {"name": "chock-copilot-plugins", "kind": "Plugins", "line": "The catalog as Copilot CLI and VS Code plugins (generated)."}, - {"name": "chock-cursor-plugins", "kind": "Plugins", "line": "The catalog as Cursor plugins (generated)."}, - {"name": "chock-codex-plugins", "kind": "Plugins", "line": "The catalog as Codex plugins (generated)."}, - {"name": "chock-devin-plugins", "kind": "Plugins", "line": "The catalog as Devin plugins (generated)."}, + {"name": "chock-claude-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, + {"name": "chock-copilot-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, + {"name": "chock-cursor-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, + {"name": "chock-codex-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, + {"name": "chock-devin-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, {"name": "chock-quickstart", "kind": "Template", "line": "What chock init leaves behind."}, {"name": "chock-example", "kind": "Template", "line": "A working adoption, one policy per layer."}, {"name": ".github", "kind": "Community", "line": "Org profile and community health files."} diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 019cdcc6..b401956c 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -9,7 +9,7 @@ claim must match a mechanism. This page says what each sibling is and how chock | [agentseam](https://github.com/open-coder-ai/agentseam) | The primitives layer for every coding agent: one handler API over their hooks, instruction files, plugin packaging and config, with a capability matrix that carries its provenance. chock is one thing built on it | | [context-report](https://github.com/open-coder-ai/context-report) | A signed report format for whether a plugin, hook, skill or `AGENTS.md` actually works; chock can export one | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | A weekly, human-reviewed digest of agentic-AI threats scored against the catalog: enforced, advisory, or `policy wanted`. Feeds the roadmap | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) · [copilot](https://github.com/open-coder-ai/chock-copilot-plugins) · [cursor](https://github.com/open-coder-ai/chock-cursor-plugins) · [codex](https://github.com/open-coder-ai/chock-codex-plugins) | The catalog compiled by `chock plugin build` into each client's native plugin format. Generated only: CI rebuilds from source and fails on any difference | +| Plugins | No per-client plugin repos: the five `chock-*-plugins` repos are retired. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection '' --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) · [chock-example](https://github.com/open-coder-ai/chock-example) | Template repositories: exactly what `chock init` leaves behind, and a working adoption with one policy per layer | Where a contribution goes: From 8db6a9a1e98d06c471067745c28ceafbae5690b9 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:28:32 +0000 Subject: [PATCH 02/33] docs: host the built plugin in your own marketplace Add "Host it in your own marketplace" to docs/agent-plugins.md: build with --dest and no --apply, review the folder and its marker, commit it to the organisation's marketplace repository, install from there, update by rebuilding on a newer catalog pin. Name the two absolute-path clients (Antigravity, OpenHands). Record what was measured on Claude Code and what is unmeasured. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index bcf7f9e5..3dc5ff42 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -244,3 +244,55 @@ case there reproduces a witnessed failure), and the probe evidence is recorded i 0.4.0 CHANGELOG entry. Codex additionally installs every hook **untrusted** until a human approves it, and that trust is bound to a hash of the hook command, so a plugin update silently voids it until re-approved. + +## Host it in your own marketplace + +An organisation that does not want every developer running a build builds once and hosts the result. +An admin runs the build without `--apply`, into a folder of their choice: + +``` +chock install --selection '' --client --dest +``` + +Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, +`copilot`, `codex`, `factory-droid`) the folder is a complete local marketplace: + +```text +/ +├── .claude-plugin/marketplace.json # the index the client reads (codex: .agents/plugins/marketplace.json) +├── chock.marketplace.json # which client's marketplace this is +└── claude/chock-guardrails/ # the plugin (/) + ├── .claude-plugin/plugin.json + ├── hooks/hooks.json # every guard, gate and the turn-end check + ├── scripts/ # the guards and gates, the vendored runtime, launch.sh + └── chock.selection.json # the marker +``` + +The marker records the selection, the catalog commit, the engine version and the sha256 of every +accepted custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, `antigravity`, +`openhands`, `kimi-code`) build the plugin folder itself, with no index. + +1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`. `chock bundle status` lists the + members of bundles installed in a client's default folder only, so it does not see a `--dest` build. +2. **Commit** the folder to the organisation's own marketplace repository. +3. **Developers install** from that repository with the client's own marketplace commands, for example + `claude plugin marketplace add /` and `claude plugin install chock-guardrails@chock-local`. +4. **Update** by building again on a newer catalog pin and committing the folder again. The marker's + catalog commit is the version. + +Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` +for Claude Code), so the folder works from wherever it is installed. **Antigravity and OpenHands +document no such variable** (`plugin_root: null` in their store data): their hooks name the build +folder by absolute path, so a hosted copy must be installed at that same path. + +Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, +`block-no-verify`, `block-curl-pipe-sh`) was moved to another folder; its `hooks.json` commands, run +with `CLAUDE_PLUGIN_ROOT` set to the new folder, refused `rm -rf /` and passed `ls -la`, and +`claude plugin marketplace add` and `claude plugin install chock-guardrails@chock-local` succeeded +from the new location. A refusal inside a live Claude Code session, and every other client, are +unmeasured. + +Pre-installing or pinning a marketplace on every machine is the vendor's feature, not chock's. For +Claude Code, its [marketplace docs](https://code.claude.com/docs/en/plugin-marketplaces) say an +administrator can require a marketplace on every machine; the settings are unmeasured here. Copilot, +Codex and the others: unmeasured. From 5d2063d2dab2fc5ade8451832c6c4b2b195c0f6b Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:30:37 +0000 Subject: [PATCH 03/33] docs: deny dialect of the claude and copilot rows is exit 0 plus JSON Claude Code accepts exit 2 and exit 0 with a permissionDecision deny JSON; chock's adapter emits the JSON form (measured on Claude Code 2.1.305), and the copilot format carries permissionDecision the same way through its respond template (not measured). Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 3dc5ff42..4553589b 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -201,8 +201,8 @@ Four formats are each **witnessed denying a destructive command on a real instal | Format | Client(s) | Deny dialect | | :--- | :--- | :--- | -| claude | Claude Code | exit 2 | -| copilot | Copilot CLI, VS Code | exit 2 | +| claude | Claude Code | exit 0 + `permissionDecision` deny JSON (measured 2026-10-21, Claude Code 2.1.305) | +| copilot | Copilot CLI, VS Code | exit 0 + `permissionDecision` deny JSON (from the adapter's copilot_respond template; unmeasured this session) | | cursor | Cursor | stdout `{"permission": "deny"}` | | codex | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | From 3235c3e26e83858f90fd673b4c554c096b13dcbb Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:33:29 +0000 Subject: [PATCH 04/33] fix(gate,install): a deny carries the guard's reason; --selection strips one quote pair A hook deny read only "Blocked by chock policy: " while an ask carried the guard's line. The deny now appends the guard's first line after the policy name. Exit codes and each client's JSON shape are unchanged; runtime goldens regenerated. chock install --selection now strips one surrounding pair of matching single or double quotes (cmd.exe passes them through), and a decode failure says so. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .../deny-reason-and-quoted-selection.md | 5 +++ src/chock/gate/guard_runner.py | 2 +- src/chock/install/selection.py | 16 ++++++++-- .../runtime_goldens/_chock_runtime.py | 2 +- tests/fixtures/runtime_goldens/antigravity.py | 2 +- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/fixtures/runtime_goldens/codex_cli.py | 2 +- tests/fixtures/runtime_goldens/cursor.py | 2 +- tests/fixtures/runtime_goldens/devin.py | 2 +- .../fixtures/runtime_goldens/factory_droid.py | 2 +- tests/fixtures/runtime_goldens/gemini_cli.py | 2 +- tests/fixtures/runtime_goldens/goose.py | 2 +- tests/fixtures/runtime_goldens/grok.py | 2 +- .../fixtures/runtime_goldens/mistral_vibe.py | 2 +- tests/fixtures/runtime_goldens/openhands.py | 2 +- tests/fixtures/runtime_goldens/tabnine.py | 2 +- .../runtime_goldens/vscode_copilot.py | 2 +- tests/fixtures/runtime_goldens/windsurf.py | 2 +- tests/test_guard_fail_to_ask.py | 23 +++++++++++++- tests/test_install_selection.py | 31 +++++++++++++++++++ 20 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 changelog.d/deny-reason-and-quoted-selection.md diff --git a/changelog.d/deny-reason-and-quoted-selection.md b/changelog.d/deny-reason-and-quoted-selection.md new file mode 100644 index 00000000..b2431454 --- /dev/null +++ b/changelog.d/deny-reason-and-quoted-selection.md @@ -0,0 +1,5 @@ +- **A refusal says why, and `--selection` takes a quoted code.** A hook deny now reads `Blocked by chock policy: + : ` (for example `BLOCKED: destructive rm command targeting '.git' is not allowed + without approval.`), the way an ask already carried its reason; exit codes and each client's JSON are unchanged. + `chock install --selection` strips one surrounding pair of single or double quotes before decoding, because + cmd.exe passes them through, and a code that still fails to decode says so. diff --git a/src/chock/gate/guard_runner.py b/src/chock/gate/guard_runner.py index c9e4a273..60ce57cc 100644 --- a/src/chock/gate/guard_runner.py +++ b/src/chock/gate/guard_runner.py @@ -245,7 +245,7 @@ def evaluate(argv: list[str], command: str, tool: str = "", *, can_ask: bool | N verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST["outcome"] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f"Blocked by chock policy: {guard.stem}") + return (VERDICT_DENY, f"Blocked by chock policy: {guard.stem}: {message}") if verdict == GUARD_ASKED: # The guard's own line is the prompt: it names what needs confirming, never the # raw command, which would put a live tool argument into the client's UI. diff --git a/src/chock/install/selection.py b/src/chock/install/selection.py index 9f6cbbeb..aa0fff31 100644 --- a/src/chock/install/selection.py +++ b/src/chock/install/selection.py @@ -15,6 +15,7 @@ from chock import yamlio from chock.validation.loading import load_schema +MIN_QUOTED = 2 SCHEMA = "selection.schema.json" FRAGMENT = "#s=" SCHEMA1, SCHEMA2 = 1, 2 @@ -37,7 +38,10 @@ def _decode(code: str) -> Any: raw = base64.urlsafe_b64decode(code + "=" * (-len(code) % 4)) return json.loads(raw.decode("utf-8")) except (binascii.Error, ValueError) as exc: - msg = f"the selection code is not base64url-encoded JSON ({exc})" + msg = ( + f"the selection code is not base64url-encoded JSON ({exc}); " + "cmd.exe passes quotes through to chock and the code needs none, so paste it bare" + ) raise SelectionError(msg) from exc @@ -49,8 +53,15 @@ def _is_file(path: Path) -> bool: return False +def _unquote(arg: str) -> str: + """`arg` without one surrounding pair of matching single or double quotes, which cmd.exe leaves on.""" + arg = arg.strip() + return arg[1:-1] if len(arg) >= MIN_QUOTED and arg[0] == arg[-1] and arg[0] in "'\"" else arg + + def _raw(arg: str) -> Any: """The selection object `arg` names: a URL fragment, a file, or a bare code, in that order.""" + arg = _unquote(arg) if FRAGMENT in arg: return _decode(arg.split(FRAGMENT, 1)[1]) path = Path(arg).expanduser() @@ -60,7 +71,7 @@ def _raw(arg: str) -> Any: except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc: msg = f"{path}: cannot read the selection ({exc})" raise SelectionError(msg) from exc - return _decode(arg.strip()) + return _decode(arg) def _validator(data: Any) -> jsonschema.Draft7Validator: @@ -104,6 +115,7 @@ def upgrade(data: dict[str, Any]) -> dict[str, Any]: def folder(arg: str) -> Path | None: """The folder of the selection file `arg` names, which local paths resolve against; None for a link or code.""" + arg = _unquote(arg) if FRAGMENT in arg: return None path = Path(arg).expanduser() diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 336e74a1..4227120c 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -807,7 +807,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 8ec79325..7ab7b780 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index ee95433b..66e900c8 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9f24d0d4..fee11725 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index ef075ec6..3fe9a48f 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 627c4116..5bf6cc15 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3af4298f..d546f890 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2990ff7e..bfd559dc 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index c1b03e5a..6c870f46 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 16b86489..c070987c 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index 8a6bcaa7..896c282f 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 4343f5b2..5c77ec19 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index d513535a..c3d49cb0 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index ab04974d..68231492 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index ee68c3b5..115244ba 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 364b09f1..e3737040 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -14,7 +14,7 @@ from runtime_support import write_runtime from chock import evidence -from chock.gate import budget, guard_runner, runtime_bundle +from chock.gate import budget, gate_outcome, guard_runner, runtime_bundle @pytest.fixture(scope="module") @@ -209,3 +209,24 @@ def test_every_gated_runtime_is_covered_here() -> None: assert set(ASK_ON_THE_WIRE) == set(runtime_bundle.RUNTIME_AGENTS) assert set(DENY_ON_THE_WIRE) == set(runtime_bundle.RUNTIME_AGENTS) assert set(PAYLOADS) == set(runtime_bundle.RUNTIME_AGENTS) + + +@pytest.mark.parametrize("agent", sorted(DENY_ON_THE_WIRE)) +def test_a_deny_carries_the_guards_own_reason_after_the_policy_name(agent: str, tmp_path: Path, runtimes) -> None: + guard = make_guard(tmp_path, "refuse.sh", 'echo "BLOCKED: rm -rf on .git is not allowed" >&2; exit 1') + + verdict = decision(run(runtimes, agent, guard, "rm -rf .git")) + + assert verdict["decision"] == DENY_ON_THE_WIRE[agent] + assert verdict["reason"].startswith("Blocked by chock policy: refuse") + assert "rm -rf on .git is not allowed" in verdict["reason"] + + +def test_a_runner_deny_without_a_reason_names_only_the_policy(tmp_path: Path) -> None: + gate = tmp_path / "pol" / "implementations" / "gate.py" + + assert gate_outcome.gate_decision(gate_outcome.GATE_BLOCKED, "", gate) == ( + guard_runner.VERDICT_DENY, + "Blocked by chock policy: pol", + ) + assert gate_outcome.gate_decision(gate_outcome.GATE_BLOCKED, "why", gate)[1] == "why" diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 23fb2a08..12f10ccc 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -205,3 +205,34 @@ def test_each_warning_kind_fires_only_on_its_condition(tmp_path: Path) -> None: (tmp_path / "allow.txt").write_text("x", encoding="utf-8") quiet = warnings.warnings_for(warnings.Context(["a-one", "a-two", "b-one", "c-one"], {}, tmp_path, "cursor"), RULES) assert quiet == [] + + +@pytest.mark.parametrize("quote", ["'", '"']) +def test_one_surrounding_quote_pair_is_stripped_from_a_code_or_link(valid: dict, quote: str) -> None: + """cmd.exe hands `--selection 'CODE'` over with the quotes still on.""" + bare = sel.load(code(valid)) + assert sel.load(f"{quote}{code(valid)}{quote}") == bare + assert sel.load(f"{quote}https://chock.sh/build#s={code(valid)}{quote}") == bare + + +@pytest.mark.parametrize( + ("given", "expected"), + [ + ("'abc'", "abc"), + ('"abc"', "abc"), + ("''abc''", "'abc'"), + ("'abc\"", "'abc\""), + ("'abc", "'abc"), + ("'", "'"), + ("", ""), + ], +) +def test_unquote_strips_exactly_one_matching_pair(given: str, expected: str) -> None: + assert sel._unquote(given) == expected + + +def test_a_code_that_fails_to_decode_says_cmd_exe_passes_quotes_through() -> None: + with pytest.raises( + sel.SelectionError, match=r"not base64url-encoded JSON.*cmd\.exe passes quotes through.*needs none" + ): + sel.load("'oops") From 8aeb861cafc299d6b559f41b26b0b31b9af1698c Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:45:43 +0000 Subject: [PATCH 05/33] install: print how to remove a build, per client (IR-12) Removing the plugin left the build folder and the shared chock-local marketplace behind, and `chock bundle status` kept listing the bundle. Each client with a confirmed remove command now carries `remove` steps in data/install.json, printed by `chock install` as "To remove it:": the agent's plugin removal, the build folder, then the marketplace only when no other chock build for that agent still uses it. Codex, Antigravity and Kimi Code carry none: their remove command is not confirmed. `chock bundle status` is unchanged: it reads only chock's own build folders, and telling whether an agent still has a build installed needs a probe of the agent's own plugin registry. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/install-removal-steps.md | 5 ++ src/chock/install/cli.py | 9 +++- src/chock/install/data/install.json | 36 ++++++++++++++ src/chock/install/steps.py | 5 ++ tests/test_install_remove.py | 74 ++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 changelog.d/install-removal-steps.md create mode 100644 tests/test_install_remove.py diff --git a/changelog.d/install-removal-steps.md b/changelog.d/install-removal-steps.md new file mode 100644 index 00000000..4fac134b --- /dev/null +++ b/changelog.d/install-removal-steps.md @@ -0,0 +1,5 @@ +- **`chock install` prints "To remove it:" after the install steps.** The agent's own plugin removal, then deleting the + build folder (`chock bundle status` lists a build for as long as its folder exists), then the shared `chock-local` + marketplace, only when no other chock build for that agent is still installed. Codex, Antigravity and Kimi Code print + none yet: their remove command is not confirmed. `chock bundle status` still reads the build folders only; it cannot + tell that an agent has uninstalled a build. diff --git a/src/chock/install/cli.py b/src/chock/install/cli.py index 09647611..ad44ddc4 100644 --- a/src/chock/install/cli.py +++ b/src/chock/install/cli.py @@ -40,7 +40,11 @@ def _report(client_id: str, labels: list[package.Label], lines: list[str]) -> No def _show(rendered: list[steps.Step], *, applying: bool) -> None: """Print every step; the header says whether `--apply` runs the commands now.""" - print("Running:" if applying and any(s.command for s in rendered) else "Next:") + _print_steps("Running:" if applying and any(s.command for s in rendered) else "Next:", rendered) + + +def _print_steps(header: str, rendered: list[steps.Step]) -> None: + print(header) for step in rendered: if step.command: print(f" {shlex.join(step.command)}") @@ -210,6 +214,9 @@ def main(argv: list[str] | None = None) -> int: } rendered = steps.render(client_id, tokens) _show(rendered, applying=args.apply) + removal = steps.render_remove(client_id, tokens) + if removal: + _print_steps("To remove it:", removal) build = claude_plugin.settings()["build_client"] build_dest = dest if client_id == build else place.default_dest(build).resolve() for line in claude_plugin.lines(client_id, claude_plugin.tokens(build_dest, name, args.selection)): diff --git a/src/chock/install/data/install.json b/src/chock/install/data/install.json index 792349aa..1ec23e37 100644 --- a/src/chock/install/data/install.json +++ b/src/chock/install/data/install.json @@ -35,6 +35,11 @@ {"command": ["claude", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["claude", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "Both commands are safe to repeat. The plugin loads in place from the directory: restart the Claude Code session, or run /reload-plugins in it, to pick up a rebuild."} + ], + "remove": [ + {"command": ["claude", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `claude plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] }, "copilot": { @@ -51,6 +56,13 @@ {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "VS Code finds plugins the Copilot CLI installed. Without the CLI, add the plugin folder to your VS Code user settings yourself:"}, {"settings": {"chat.pluginLocations": {"__PLUGIN_DIR__": true}}} + ], + "remove": [ + {"note": "`copilot plugin uninstall` only disables the plugin (seen in a live run): it removes nothing from disk and leaves the marketplace registered."}, + {"command": ["copilot", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `copilot plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."}, + {"note": "In VS Code, remove the \"__PLUGIN_DIR__\" entry from chat.pluginLocations in your user settings (chock does not edit them)."} ] }, "codex": { @@ -84,6 +96,9 @@ "gate_turn_end_only": false, "steps": [ {"note": "Cursor loads __PLUGIN_DIR__ as a local plugin: run \"Developer: Reload Window\" (or restart Cursor), then check it under Customize. There is no command to run."} + ], + "remove": [ + {"note": "Delete the build folder __PLUGIN_DIR__ (Cursor loads it from there), then run \"Developer: Reload Window\" (or restart Cursor). `chock bundle status` lists a build for as long as its folder exists."} ] }, "devin": { @@ -99,6 +114,10 @@ {"note": "On Windows, `devin plugins install --local` creates a symbolic link, so it needs Developer Mode (Settings > System > For developers) or an elevated shell; otherwise it fails with \"os error 1314: A required privilege is not held by the client\"."}, {"command": ["devin", "plugins", "install", "--local", "__PLUGIN_DIR__"]}, {"note": "The plugin is linked in place: a rebuild applies from the next Devin session."} + ], + "remove": [ + {"command": ["devin", "plugins", "remove", "__PLUGIN__", "--local"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "goose": { @@ -112,6 +131,9 @@ "gate_turn_end_only": false, "steps": [ {"note": "goose discovers __PLUGIN_DIR__ as a user plugin, in every project: start a new goose session. There is no command to run (`goose plugin install` takes a git URL only). To turn it off, add __PLUGIN__ to disabledPlugins in ~/.config/goose/settings.json."} + ], + "remove": [ + {"note": "Delete the build folder __PLUGIN_DIR__ (goose discovers it there), then start a new goose session. `chock bundle status` lists a build for as long as its folder exists."} ] }, "grok": { @@ -126,6 +148,10 @@ "steps": [ {"command": ["grok", "plugin", "install", "__PLUGIN_DIR__", "--trust"]}, {"note": "Plugins are off in grok until enabled, and chock does not edit grok's config: run `grok plugin list` for the name grok gave this plugin (chock expects __PLUGIN__, its folder name; the docs do not say how a plugin with no plugin.json is named), then `grok plugin enable `, or add it to `enabled` under [plugins] in ~/.grok/config.toml, or press Space on it in the Plugins tab. Re-run the install command after a rebuild."} + ], + "remove": [ + {"note": "Run `grok plugin list` for the name grok gave this plugin, then `grok plugin uninstall `."}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "antigravity": { @@ -153,6 +179,10 @@ "gate_turn_end_only": false, "steps": [ {"note": "OpenHands loads a local plugin when it starts: run `openhands --plugin __PLUGIN_DIR__`, or add \"__PLUGIN_DIR__\" to sources under [plugins] in ~/.openhands/config.toml (chock does not edit it). Its hooks name __PLUGIN_DIR__ by absolute path (OpenHands documents no plugin-root variable), so keep that folder where it is; a rebuild applies from the next start."} + ], + "remove": [ + {"note": "Stop passing `--plugin __PLUGIN_DIR__`, and remove \"__PLUGIN_DIR__\" from sources under [plugins] in ~/.openhands/config.toml if you added it (chock does not edit it)."}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."} ] }, "kimi-code": { @@ -182,6 +212,12 @@ {"command": ["droid", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["droid", "plugin", "install", "__PLUGIN__@__MARKETPLACE__", "--scope", "project"]}, {"note": "Droid names a local marketplace from its source; if `droid plugin marketplace list` shows a name other than __MARKETPLACE__ (a --dest folder named otherwise), install __PLUGIN__@. Droid copies the plugin into its cache: after a rebuild run `droid plugin marketplace update` and `droid plugin update __PLUGIN__@__MARKETPLACE__ --scope project`, then start a new session."} + ], + "remove": [ + {"note": "Run these from the root of the project the plugin was installed for."}, + {"command": ["droid", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__", "--scope", "project"]}, + {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `droid plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] } } diff --git a/src/chock/install/steps.py b/src/chock/install/steps.py index 75a5f093..bfbdae60 100644 --- a/src/chock/install/steps.py +++ b/src/chock/install/steps.py @@ -36,6 +36,11 @@ def render(client_id: str, tokens: dict[str, str]) -> list[Step]: return render_steps(package.client(client_id)["steps"], tokens) +def render_remove(client_id: str, tokens: dict[str, str]) -> list[Step]: + """The client's removal steps with every token filled in; empty when chock has no confirmed removal for it.""" + return render_steps(package.client(client_id).get("remove", []), tokens) + + def render_steps(data: list[dict[str, Any]], tokens: dict[str, str]) -> list[Step]: """Step records (`command`, `settings` or `note`) with every token filled in.""" out = [] diff --git a/tests/test_install_remove.py b/tests/test_install_remove.py new file mode 100644 index 00000000..6fc8d935 --- /dev/null +++ b/tests/test_install_remove.py @@ -0,0 +1,74 @@ +"""`chock install` prints what removes a build: the plugin, then its folder, then the shared marketplace (IR-12).""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from install_fixtures import make_catalog, selection, write_selection + +from chock.install import cli, package, place, steps + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git required") +#: Clients whose remove command chock could not confirm from the client's docs or a live run; they print no removal. +UNCONFIRMED = {"codex", "antigravity", "kimi-code"} +WITH_REMOVE = [c for c in package.settings()["clients"] if c not in UNCONFIRMED] +MARKETPLACE_CLIENTS = [c for c in WITH_REMOVE if package.client(c)["layout"] == place.MARKETPLACE] +TOKENS = { + "__DEST__": "/d", + "__PLUGIN_DIR__": "/d/claude/chock-guardrails", + "__PLUGIN__": "chock-guardrails", + "__MARKETPLACE__": "chock-local", +} + + +def _text(client: str) -> list[str]: + return [s.text or " ".join(s.command or []) for s in steps.render_remove(client, TOKENS)] + + +def test_exactly_the_unconfirmed_clients_have_no_removal() -> None: + assert {c for c in package.settings()["clients"] if "remove" not in package.client(c)} == UNCONFIRMED + + +@pytest.mark.parametrize("client", WITH_REMOVE) +def test_every_removal_names_the_build_folder_and_fills_every_token(client: str) -> None: + lines = _text(client) + assert any("/d/claude/chock-guardrails" in line for line in lines), "the build folder is named" + assert not any("__" in line for line in lines), "every token is filled" + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_the_shared_marketplace_is_removed_only_on_the_readers_say_so(client: str) -> None: + rendered = steps.render_remove(client, TOKENS) + removing = [s for s in rendered if "marketplace remove" in s.text] + assert len(removing) == 1 + assert "Only if no other chock build" in removing[0].text + assert not any(s.command and "marketplace" in s.command and "remove" in s.command for s in rendered) + + +def test_claude_code_removes_the_plugin_then_the_folder_then_the_marketplace() -> None: + lines = _text("claude-code") + assert lines[0] == "claude plugin uninstall chock-guardrails@chock-local" + assert "Delete the build folder /d/claude/chock-guardrails" in lines[1] + assert lines[2].endswith("`claude plugin marketplace remove chock-local`. Otherwise leave it registered.") + assert len(lines) == 3 + + +def test_copilot_says_its_uninstall_only_disables() -> None: + assert "only disables" in _text("copilot")[0] + + +def test_install_prints_the_removal_after_the_install_steps(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + root, ref = make_catalog(tmp_path) + base = selection(root, ref) + picks = [{"from": "catalog", **p} for p in base["policies"]] + data = {**base, "schema": 2, "client": "claude-code", "bundle": {"version": "2.0.0"}, "policies": picks} + path = write_selection(tmp_path / "chock.selection.yaml", data) + assert cli.main(["--selection", str(path), "--allow-source", data["catalog"]["source"]]) == 0 + out = capsys.readouterr().out + assert out.index("Next:") < out.index("To remove it:") + assert " claude plugin uninstall chock-guardrails@chock-local" in out + assert "Delete the build folder" in out From a2708ca74f63f100f90669550f7183c4eddc83d9 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:24:05 +0000 Subject: [PATCH 06/33] install: --marketplace-name names the build's local marketplace A team's own build can now install beside a personal one without re-pointing the shared chock-local registration. The flag sets the name in the marketplace file and marker, and in every next step, removal step and note, including the Copilot CLI "already registered" note and the Claude build command printed for other agents. The name is lowercase letters, digits and single hyphens, at most 64; anything else is refused, as is rebuilding a folder under another name. Without the flag nothing changes. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/install-marketplace-name.md | 7 ++ src/chock/install/claude_plugin.py | 5 +- src/chock/install/cli.py | 23 +++- src/chock/install/data/claude_plugin.json | 4 +- src/chock/install/data/install.json | 6 +- src/chock/install/package.py | 19 +++ src/chock/install/place.py | 30 +++-- tests/test_install_coexist.py | 2 +- tests/test_install_marketplace_name.py | 134 ++++++++++++++++++++++ 9 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 changelog.d/install-marketplace-name.md create mode 100644 tests/test_install_marketplace_name.py diff --git a/changelog.d/install-marketplace-name.md b/changelog.d/install-marketplace-name.md new file mode 100644 index 00000000..d703b992 --- /dev/null +++ b/changelog.d/install-marketplace-name.md @@ -0,0 +1,7 @@ +- **`chock install --marketplace-name ` names the build's local marketplace.** Without it nothing changes + (`chock-local`). A team's own build, for example `--marketplace-name acme-chock`, installs as + `chock-guardrails@acme-chock` beside a personal `chock-guardrails@chock-local`, so adding the team folder no longer + re-points the shared `chock-local` registration and every plugin installed from it. The marketplace file, the next + steps, `--apply`, the removal steps and the Copilot CLI "already registered" note all use the chosen name. The name + is lowercase letters, digits and single hyphens (at most 64); anything else is refused, and so is rebuilding a + folder under a different name than it was built with. diff --git a/src/chock/install/claude_plugin.py b/src/chock/install/claude_plugin.py index 243d4c21..32adbf5f 100644 --- a/src/chock/install/claude_plugin.py +++ b/src/chock/install/claude_plugin.py @@ -36,14 +36,15 @@ def routes(client_id: str, data: dict[str, Any] | None = None) -> list[dict[str, return [r for r in entry["routes"] if r["witness"] in witnessed] -def tokens(dest: Path, name: str, selection_arg: str) -> dict[str, str]: +def tokens(dest: Path, name: str, selection_arg: str, marketplace: str) -> dict[str, str]: """The Claude build's own folder and marketplace, for the steps another agent runs on it.""" build = settings()["build_client"] return { "__DEST__": str(dest), "__PLUGIN_DIR__": str(place.plugin_dir(build, dest, name)), "__PLUGIN__": name, - "__MARKETPLACE__": package.settings()["marketplace"], + "__MARKETPLACE__": marketplace, + "__MARKETPLACE_FLAG__": "" if marketplace == package.marketplace() else f" --marketplace-name {marketplace}", "__SELECTION__": shlex.quote(selection_arg), "__BUILD_CLIENT__": build, } diff --git a/src/chock/install/cli.py b/src/chock/install/cli.py index ad44ddc4..9f8d0f6d 100644 --- a/src/chock/install/cli.py +++ b/src/chock/install/cli.py @@ -76,12 +76,13 @@ def _apply(client_id: str, rendered: list[steps.Step]) -> int: @dataclass(frozen=True) class Options: - """What the command line adds to a selection: trusted sources, the selection's folder, and local-code trust.""" + """What the command line adds to a selection: trusted sources, folder, local-code trust and marketplace name.""" allowed: list[str] base: Path | None = None trust_local: dict[str, str] = field(default_factory=dict) interactive: bool = False + marketplace: str = field(default_factory=package.marketplace) def _catalog(chosen: dict[str, Any], allowed: list[str], work: Path) -> Path: @@ -121,7 +122,7 @@ def _members(chosen: dict[str, Any], catalog: Path | None, found: list[local.Loc def install(chosen: dict[str, Any], dest: Path, options: Options) -> tuple[list[package.Label], list[str]]: """Trust, fetch, verify, label and build the selection for its client; returns (labels, warnings).""" client_id, name = chosen["client"], chosen["bundle"]["name"] - place.check_owned(client_id, dest, name) + place.check_owned(client_id, dest, name, options.marketplace) target = place.plugin_dir(client_id, dest, name) with tempfile.TemporaryDirectory(prefix="chock-install-") as tmp: work = Path(tmp) @@ -142,7 +143,7 @@ def install(chosen: dict[str, Any], dest: Path, options: Options) -> tuple[list[ place.migrate_legacy(dest) record = package.marker(chosen, accepted) package.replace(target, package.stage(members, repo_root, record, version, target)) - place.write_marketplace(client_id, dest) + place.write_marketplace(client_id, dest, options.marketplace) return labels, lines @@ -161,6 +162,11 @@ def main(argv: list[str] | None = None) -> int: help="The agent to build for (default: the selection's client)", ) parser.add_argument("--dest", help="Install folder (default: the client's own, e.g. ~/.chock/marketplace/)") + parser.add_argument( + "--marketplace-name", + metavar="NAME", + help=f"Name the build's local marketplace (default: {package.marketplace()}); a team's own build uses its own", + ) parser.add_argument("--apply", action="store_true", help="Also run the client's own install commands, if any") parser.add_argument( "--allow-source", @@ -179,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: + marketplace = package.marketplace(args.marketplace_name) chosen = selection.load(args.selection) chosen = {**chosen, "client": args.client or chosen["client"]} if chosen["client"] == RETIRED: @@ -193,7 +200,11 @@ def main(argv: list[str] | None = None) -> int: print(f"Catalog source: {chosen['catalog']['source']}") print(f"Catalog ref: {chosen['catalog']['ref']}", flush=True) options = Options( - args.allow_source, selection.folder(args.selection), local.given(args.trust_local), local.interactive() + args.allow_source, + selection.folder(args.selection), + local.given(args.trust_local), + local.interactive(), + marketplace, ) labels, lines = install(chosen, dest, options) except PinError as exc: @@ -210,7 +221,7 @@ def main(argv: list[str] | None = None) -> int: "__DEST__": str(dest), "__PLUGIN_DIR__": str(folder), "__PLUGIN__": name, - "__MARKETPLACE__": package.settings()["marketplace"], + "__MARKETPLACE__": marketplace, } rendered = steps.render(client_id, tokens) _show(rendered, applying=args.apply) @@ -219,7 +230,7 @@ def main(argv: list[str] | None = None) -> int: _print_steps("To remove it:", removal) build = claude_plugin.settings()["build_client"] build_dest = dest if client_id == build else place.default_dest(build).resolve() - for line in claude_plugin.lines(client_id, claude_plugin.tokens(build_dest, name, args.selection)): + for line in claude_plugin.lines(client_id, claude_plugin.tokens(build_dest, name, args.selection, marketplace)): print(line) return _apply(client_id, rendered) if args.apply else 0 diff --git a/src/chock/install/data/claude_plugin.json b/src/chock/install/data/claude_plugin.json index e9b52bdd..7b2a5091 100644 --- a/src/chock/install/data/claude_plugin.json +++ b/src/chock/install/data/claude_plugin.json @@ -3,7 +3,7 @@ "offer_when": {"deny_reply": ["yes", "partial"]}, "heading": "This agent can also run the Claude Code plugin (agentseam loads_claude_plugins, seen in a live run):", "listing": "This build also runs in other agents (agentseam loads_claude_plugins, seen in a live run):", - "build": "Build it: chock install --selection __SELECTION__ --client __BUILD_CLIENT__ (then, in __WHERE__:)", + "build": "Build it: chock install --selection __SELECTION__ --client __BUILD_CLIENT____MARKETPLACE_FLAG__ (then, in __WHERE__:)", "warnings": [ "Install one build per agent. If you install both the agent's own chock plugin and the Claude Code plugin, each one checks every action.", "When another agent runs the Claude Code plugin, chock logs its events as claude_code (~/.chock/log/claude_code/), not under that agent." @@ -45,7 +45,7 @@ "witness": "Copilot CLI: guard (shell command) and gate (file write) refused by preToolUse deny", "label": "guard and gate refused", "steps": [ - {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (every chock build's local marketplace is named __MARKETPLACE__): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, + {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (this build's local marketplace is named __MARKETPLACE__; every chock build made with that name shares it): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, {"command": ["copilot", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "It loads live from __PLUGIN_DIR__ (nothing is copied); `copilot plugin uninstall` only disables it."} diff --git a/src/chock/install/data/install.json b/src/chock/install/data/install.json index 1ec23e37..6292f67d 100644 --- a/src/chock/install/data/install.json +++ b/src/chock/install/data/install.json @@ -39,7 +39,7 @@ "remove": [ {"command": ["claude", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, - {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `claude plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `claude plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] }, "copilot": { @@ -61,7 +61,7 @@ {"note": "`copilot plugin uninstall` only disables the plugin (seen in a live run): it removes nothing from disk and leaves the marketplace registered."}, {"command": ["copilot", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, - {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `copilot plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."}, + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `copilot plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."}, {"note": "In VS Code, remove the \"__PLUGIN_DIR__\" entry from chat.pluginLocations in your user settings (chock does not edit them)."} ] }, @@ -217,7 +217,7 @@ {"note": "Run these from the root of the project the plugin was installed for."}, {"command": ["droid", "plugin", "uninstall", "__PLUGIN__@__MARKETPLACE__", "--scope", "project"]}, {"note": "Delete the build folder __PLUGIN_DIR__: `chock bundle status` lists a build for as long as its folder exists."}, - {"note": "Only if no other chock build for this agent is still installed (every chock build uses the marketplace name __MARKETPLACE__, so removing it takes their registration too): `droid plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} + {"note": "Only if no other chock build for this agent is still installed (every chock build made with the marketplace name __MARKETPLACE__ shares it, so removing it takes their registration too): `droid plugin marketplace remove __MARKETPLACE__`. Otherwise leave it registered."} ] } } diff --git a/src/chock/install/package.py b/src/chock/install/package.py index 61acc08c..4583b6cd 100644 --- a/src/chock/install/package.py +++ b/src/chock/install/package.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import shutil import tempfile from dataclasses import dataclass @@ -33,6 +34,24 @@ def settings() -> dict[str, Any]: return json.loads(package_data_dir("chock.install", "data").joinpath("install.json").read_text(encoding="utf-8")) +#: Lowercase letters and digits in hyphen-separated words: the part of every client's marketplace naming rule they share. +MARKETPLACE_NAME = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +MARKETPLACE_NAME_MAX = 64 + + +def marketplace(chosen: str | None = None) -> str: + """The local marketplace name a build gets: `chosen` once valid, else the default in data/install.json.""" + if chosen is None: + return str(settings()["marketplace"]) + if not MARKETPLACE_NAME.fullmatch(chosen) or len(chosen) > MARKETPLACE_NAME_MAX: + msg = ( + f"--marketplace-name {chosen!r} is not a valid marketplace name: use lowercase letters, digits and " + f"single hyphens, 1 to {MARKETPLACE_NAME_MAX} characters, not starting or ending with a hyphen" + ) + raise ValueError(msg) + return chosen + + def client(client_id: str) -> dict[str, Any]: """One client's packaging, layout and steps data.""" return settings()["clients"][client_id] diff --git a/src/chock/install/place.py b/src/chock/install/place.py index d9374f9c..47f58204 100644 --- a/src/chock/install/place.py +++ b/src/chock/install/place.py @@ -44,8 +44,16 @@ def _marketplace_client(dest: Path) -> str | None: return selection.SCHEMA1_CLIENT if (dest / package.MARKER).is_file() else None -def check_owned(client_id: str, dest: Path, name: str) -> None: - """Refuse a destination chock did not build, or built for another client; never touch either.""" +def _marketplace_name(dest: Path) -> str: + """The marketplace name a chock marketplace at `dest` was built with; the default for one that records none.""" + try: + return str(json.loads((dest / MARKETPLACE_MARKER).read_text(encoding="utf-8"))["marketplace"]) + except (OSError, ValueError, KeyError, TypeError): + return package.marketplace() + + +def check_owned(client_id: str, dest: Path, name: str, marketplace: str) -> None: + """Refuse a destination chock did not build, built for another client or under another marketplace name.""" if package.client(client_id)["layout"] == MARKETPLACE and dest.exists() and any(dest.iterdir()): found = _marketplace_client(dest) if found is None: @@ -54,6 +62,13 @@ def check_owned(client_id: str, dest: Path, name: str) -> None: if found != client_id: msg = f"{dest} is chock's marketplace for {found}, not {client_id}; choose another --dest" raise FileExistsError(msg) + if _marketplace_name(dest) != marketplace: + msg = ( + f"{dest} is the {_marketplace_name(dest)} marketplace; renaming it to {marketplace} would re-point " + f"every plugin installed from it. Choose another --dest, or build with --marketplace-name " + f"{_marketplace_name(dest)}" + ) + raise FileExistsError(msg) return marker = package.read_marker(plugin_dir(client_id, dest, name)) if marker is not None and marker.get("client") != client_id: @@ -77,12 +92,12 @@ def migrate_legacy(dest: Path) -> None: folder, marker = legacy if folder.is_dir() and package.read_marker(folder) is None: write_generated(folder / package.MARKER, json.dumps(marker, indent=2, sort_keys=True) + "\n") - _write_marketplace_marker(dest, selection.SCHEMA1_CLIENT) + _write_marketplace_marker(dest, selection.SCHEMA1_CLIENT, package.marketplace()) (dest / package.MARKER).unlink() -def _write_marketplace_marker(dest: Path, client_id: str) -> None: - marker = {"client": client_id, "marketplace": package.settings()["marketplace"]} +def _write_marketplace_marker(dest: Path, client_id: str, marketplace: str) -> None: + marker = {"client": client_id, "marketplace": marketplace} write_generated(dest / MARKETPLACE_MARKER, json.dumps(marker, indent=2, sort_keys=True) + "\n") @@ -118,13 +133,12 @@ def _index(spec: dict[str, Any], dest: Path, fmt: str, marketplace: str) -> dict return {"name": marketplace, "plugins": plugins} -def write_marketplace(client_id: str, dest: Path) -> list[Path]: +def write_marketplace(client_id: str, dest: Path, marketplace: str) -> list[Path]: """The marketplace marker and the client's index files, rebuilt over every plugin now in `dest`.""" data = package.client(client_id) if data["layout"] != MARKETPLACE: return [] - marketplace = package.settings()["marketplace"] - _write_marketplace_marker(dest, client_id) + _write_marketplace_marker(dest, client_id, marketplace) written = [] for spec in data["index"]: path = dest / spec["path"] diff --git a/tests/test_install_coexist.py b/tests/test_install_coexist.py index cf079c3b..86c51edc 100644 --- a/tests/test_install_coexist.py +++ b/tests/test_install_coexist.py @@ -130,4 +130,4 @@ def test_migrating_a_schema_1_marketplace_keeps_it_chocks(catalog, tmp_path: Pat place.migrate_legacy(dest) assert not (dest / package.MARKER).exists() assert _read(dest / place.MARKETPLACE_MARKER)["client"] == "claude-code" - place.check_owned("claude-code", dest, "set-two") + place.check_owned("claude-code", dest, "set-two", package.marketplace()) diff --git a/tests/test_install_marketplace_name.py b/tests/test_install_marketplace_name.py new file mode 100644 index 00000000..9de3201e --- /dev/null +++ b/tests/test_install_marketplace_name.py @@ -0,0 +1,134 @@ +"""`chock install --marketplace-name`: the build's local marketplace under a name of the team's own (IR-13).""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest +from install_fixtures import make_catalog +from test_install_claude_plugin import DATA, OFFERED +from test_install_clients import _read, _run, v2 +from test_install_command import _stub_claude + +from chock.install import claude_plugin, package, place + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git required") +DEFAULT = package.settings()["marketplace"] +CUSTOM = "acme-chock" +MARKETPLACE_CLIENTS = [c for c in package.settings()["clients"] if package.client(c)["layout"] == place.MARKETPLACE] +CLIENTS = list(package.settings()["clients"]) + + +@pytest.fixture +def catalog(tmp_path: Path) -> tuple[Path, str]: + return make_catalog(tmp_path) + + +def _build(catalog: tuple[Path, str], tmp_path: Path, client: str, *extra: str) -> int: + root, ref = catalog + return _run(tmp_path, v2(root, ref, client), "--dest", str(tmp_path / "market"), *extra) + + +def _index(dest: Path, client: str) -> dict: + return _read(dest / package.client(client)["index"][0]["path"]) + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_without_the_flag_every_marketplace_is_chock_local(catalog, tmp_path: Path, capsys, client: str) -> None: + assert _build(catalog, tmp_path, client) == 0 + dest = tmp_path / "market" + assert _index(dest, client)["name"] == DEFAULT == "chock-local" + assert _read(dest / place.MARKETPLACE_MARKER)["marketplace"] == DEFAULT + assert f"@{DEFAULT}" in capsys.readouterr().out or client == "codex" + + +@pytest.mark.parametrize("client", MARKETPLACE_CLIENTS) +def test_a_custom_name_is_the_manifest_and_marker_name(catalog, tmp_path: Path, client: str) -> None: + assert _build(catalog, tmp_path, client, "--marketplace-name", CUSTOM) == 0 + dest = tmp_path / "market" + assert _index(dest, client)["name"] == CUSTOM + assert _read(dest / place.MARKETPLACE_MARKER)["marketplace"] == CUSTOM + + +@pytest.mark.parametrize("client", CLIENTS) +def test_a_custom_name_replaces_the_default_in_every_printed_step(catalog, tmp_path: Path, capsys, client: str) -> None: + assert _build(catalog, tmp_path, client, "--marketplace-name", CUSTOM) == 0 + out = capsys.readouterr().out + assert DEFAULT not in out, "no next step, removal step or note keeps the default name" + if client == "claude-code": + assert f" claude plugin install chock-guardrails@{CUSTOM}" in out + assert f" claude plugin uninstall chock-guardrails@{CUSTOM}" in out + assert f"`claude plugin marketplace remove {CUSTOM}`" in out + if client == "copilot": + assert f" copilot plugin uninstall chock-guardrails@{CUSTOM}" in out + + +def test_the_copilot_note_and_the_build_command_name_the_chosen_marketplace(tmp_path: Path) -> None: + filled = claude_plugin.tokens(tmp_path / "d", "chock-guardrails", "CODE", CUSTOM) + out = "\n".join(claude_plugin.lines(DATA["build_client"], filled)) + if "copilot" in OFFERED: + assert f'Marketplace "{CUSTOM}" already registered' in out + assert f"copilot plugin marketplace remove {CUSTOM}" in out + assert DEFAULT not in out + other = next((c for c in OFFERED if c != DATA["build_client"]), None) + if other: + text = "\n".join(claude_plugin.lines(other, filled)) + assert f"--client claude-code --marketplace-name {CUSTOM}" in text + + +def test_the_default_build_command_has_no_flag(tmp_path: Path) -> None: + filled = claude_plugin.tokens(tmp_path / "d", "chock-guardrails", "CODE", DEFAULT) + other = next(c for c in OFFERED if c != DATA["build_client"]) + text = "\n".join(claude_plugin.lines(other, filled)) + assert "--client claude-code (then" in text + assert "--marketplace-name" not in text + + +@pytest.mark.skipif(os.name == "nt", reason="the stub is a POSIX shell script") +def test_apply_runs_the_commands_with_the_chosen_name(catalog, tmp_path: Path, monkeypatch) -> None: + log = _stub_claude(tmp_path, monkeypatch) + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM, "--apply") == 0 + assert log.read_text(encoding="utf-8").splitlines() == [ + f"plugin marketplace add {(tmp_path / 'market').resolve()}", + f"plugin install chock-guardrails@{CUSTOM}", + ] + + +@pytest.mark.parametrize( + "bad", + ["", "Acme", "acme_chock", "acme chock", "-acme", "acme-", "acme--chock", "acme.chock", "../acme", "é", "a" * 65], +) +def test_an_invalid_name_is_refused_and_nothing_is_built(catalog, tmp_path: Path, capsys, bad: str) -> None: + assert _build(catalog, tmp_path, "claude-code", f"--marketplace-name={bad}") == 1 + assert "not a valid marketplace name" in capsys.readouterr().err + assert not (tmp_path / "market").exists() + + +@pytest.mark.parametrize("good", ["a", "acme", "acme-chock", "team-2", "0", "a" * 64]) +def test_a_name_of_lowercase_letters_digits_and_single_hyphens_is_accepted(good: str) -> None: + assert package.marketplace(good) == good + + +def test_a_marketplace_folder_is_not_renamed_under_the_plugins_installed_from_it( + catalog, tmp_path: Path, capsys +) -> None: + assert _build(catalog, tmp_path, "claude-code") == 0 + capsys.readouterr() + dest = tmp_path / "market" + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 1 + err = capsys.readouterr().err + assert "chock-local marketplace" in err + assert "--dest" in err + assert _index(dest, "claude-code")["name"] == DEFAULT + assert _build(catalog, tmp_path, "claude-code") == 0, "the same name rebuilds" + + +def test_a_custom_folder_rebuilds_under_its_own_name_only(catalog, tmp_path: Path, capsys) -> None: + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 0 + capsys.readouterr() + assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 0 + assert _build(catalog, tmp_path, "claude-code") == 1 + assert json.loads((tmp_path / "market" / place.MARKETPLACE_MARKER).read_text())["marketplace"] == CUSTOM From 51ade7e61fa74f835bf77c32ccb7592545f9bdd2 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:46:56 +0000 Subject: [PATCH 07/33] docs: name the hosted marketplace with --marketplace-name (IR-13b) The "Host it in your own marketplace" steps now build with `--marketplace-name -chock --dest ` and install `chock-guardrails@`. Without a name, adding a second folder re-points the shared `chock-local` registration and every plugin installed from it. Depends on #320 (the flag); this branch does not contain it. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 50 ++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 4553589b..7631951a 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -251,7 +251,7 @@ An organisation that does not want every developer running a build builds once a An admin runs the build without `--apply`, into a folder of their choice: ``` -chock install --selection '' --client --dest +chock install --selection '' --client --dest --marketplace-name -chock ``` Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, @@ -268,31 +268,33 @@ Nothing is installed and no client command runs. For the marketplace-layout clie └── chock.selection.json # the marker ``` -The marker records the selection, the catalog commit, the engine version and the sha256 of every -accepted custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, `antigravity`, -`openhands`, `kimi-code`) build the plugin folder itself, with no index. +The marker records the selection, the catalog commit, the engine version and the sha256 of every accepted +custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, `antigravity`, `openhands`, `kimi-code`) +build the plugin folder itself, with no index. -1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`. `chock bundle status` lists the - members of bundles installed in a client's default folder only, so it does not see a `--dest` build. +**Name the marketplace.** Every build calls its local marketplace `chock-local`; adding a second folder under +that name re-points the existing `chock-local` registration, and every plugin installed from it, to the new +folder. `--marketplace-name ` (lowercase letters, digits, single hyphens; at most 64 characters) names +the hosted build instead: developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local` +and neither moves the other. A folder keeps its name. + +1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`; `chock bundle status` lists only + bundles installed in a client's default folder, so it does not see a `--dest` build. 2. **Commit** the folder to the organisation's own marketplace repository. -3. **Developers install** from that repository with the client's own marketplace commands, for example - `claude plugin marketplace add /` and `claude plugin install chock-guardrails@chock-local`. -4. **Update** by building again on a newer catalog pin and committing the folder again. The marker's - catalog commit is the version. +3. **Developers install** with the client's own commands, for example `claude plugin marketplace add + /` and `claude plugin install chock-guardrails@`. +4. **Update** by building on a newer catalog pin and committing again; the marker's catalog commit is the version. Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` for Claude Code), so the folder works from wherever it is installed. **Antigravity and OpenHands -document no such variable** (`plugin_root: null` in their store data): their hooks name the build -folder by absolute path, so a hosted copy must be installed at that same path. - -Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, -`block-no-verify`, `block-curl-pipe-sh`) was moved to another folder; its `hooks.json` commands, run -with `CLAUDE_PLUGIN_ROOT` set to the new folder, refused `rm -rf /` and passed `ls -la`, and -`claude plugin marketplace add` and `claude plugin install chock-guardrails@chock-local` succeeded -from the new location. A refusal inside a live Claude Code session, and every other client, are -unmeasured. - -Pre-installing or pinning a marketplace on every machine is the vendor's feature, not chock's. For -Claude Code, its [marketplace docs](https://code.claude.com/docs/en/plugin-marketplaces) say an -administrator can require a marketplace on every machine; the settings are unmeasured here. Copilot, -Codex and the others: unmeasured. +document no such variable** (`plugin_root: null`): their hooks name the build folder by absolute path, so +a hosted copy must be installed at that same path. + +Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, `block-no-verify`, +`block-curl-pipe-sh`) was moved to another folder; its `hooks.json` commands, run with `CLAUDE_PLUGIN_ROOT` +set to the new folder, refused `rm -rf /` and passed `ls -la`, and `claude plugin marketplace add` and +`claude plugin install chock-guardrails@chock-local` succeeded from the new location. That run used the default +name; a `--marketplace-name` build (tests only), a live refusal and every other client are unmeasured. + +Pre-installing a marketplace on every machine is the vendor's feature, not chock's: Claude Code's +[docs](https://code.claude.com/docs/en/plugin-marketplaces) say an administrator can require one (unmeasured). From 1e76ed73a75fe3524e01420fdb37a4b706a310ed Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:47:10 -0400 Subject: [PATCH 08/33] chore: chock sync for the deny-message change Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/_chock_runtime.py | 2 +- .chock/bin/claude_code.py | 2 +- .chock/bin/codex_cli.py | 2 +- .chock/bin/cursor.py | 2 +- .chock/bin/devin.py | 2 +- .chock/bin/gemini_cli.py | 2 +- .chock/bin/grok.py | 2 +- .chock/bin/tabnine.py | 2 +- .chock/bin/vscode_copilot.py | 2 +- .chock/bin/windsurf.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 336e74a1..4227120c 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -807,7 +807,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index ee95433b..66e900c8 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9f24d0d4..fee11725 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index ef075ec6..3fe9a48f 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 627c4116..5bf6cc15 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2990ff7e..bfd559dc 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 16b86489..c070987c 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index d513535a..c3d49cb0 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index ab04974d..68231492 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index ee68c3b5..115244ba 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'd9abcaa8f1ac60144f1cdbaced70d94f6ea7f0308b2c8f836df1cafe78bb4a91' def _chock_load_runtime(): From 22654d112f48ef7110d308a492ec7b269193cbe0 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:11:57 +0000 Subject: [PATCH 09/33] IR-8: render the vendored runtime identically on every Python patch level ast.unparse quotes an f-string that nests a quoted string differently on 3.12.3 (f'{m['k']}') than on 3.12.13 (f"{m['k']}"), so chock sync on one patch level wrote 15 different runtime files and sync --check failed on another. Hoist the quoted part of the five affected f-strings into locals, so no handler f-string nests a string. Add a scan that fails on any Python if one comes back, and regenerate the runtime goldens. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/runtime-render-patch-level.md | 4 +++ src/chock/gate/decision_log.py | 8 +++-- src/chock/gate/decision_target.py | 3 +- src/chock/gate/patch_image.py | 3 +- src/chock/gate/runner/journal.py | 3 +- .../runtime_goldens/_chock_runtime.py | 17 +++++++---- tests/fixtures/runtime_goldens/antigravity.py | 2 +- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/fixtures/runtime_goldens/codex_cli.py | 2 +- tests/fixtures/runtime_goldens/cursor.py | 2 +- tests/fixtures/runtime_goldens/devin.py | 2 +- .../fixtures/runtime_goldens/factory_droid.py | 2 +- tests/fixtures/runtime_goldens/gemini_cli.py | 2 +- tests/fixtures/runtime_goldens/goose.py | 2 +- tests/fixtures/runtime_goldens/grok.py | 2 +- .../fixtures/runtime_goldens/mistral_vibe.py | 2 +- tests/fixtures/runtime_goldens/openhands.py | 2 +- tests/fixtures/runtime_goldens/tabnine.py | 2 +- .../runtime_goldens/vscode_copilot.py | 2 +- tests/fixtures/runtime_goldens/windsurf.py | 2 +- tests/test_runtime_goldens.py | 30 ++++++++++++++++++- 21 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 changelog.d/runtime-render-patch-level.md diff --git a/changelog.d/runtime-render-patch-level.md b/changelog.d/runtime-render-patch-level.md new file mode 100644 index 00000000..aae9698e --- /dev/null +++ b/changelog.d/runtime-render-patch-level.md @@ -0,0 +1,4 @@ +- The vendored runtime renders byte-identically on every supported Python. Five f-strings whose replacement fields held + quoted strings were printed with different quotes by `ast.unparse` on Python 3.12.3 than on 3.12.13, so `chock sync` + on one patch level failed `sync --check` on another. They are rewritten, so the next `chock sync` rewrites `.chock/bin` + once. diff --git a/src/chock/gate/decision_log.py b/src/chock/gate/decision_log.py index d4bd8e5b..93b4f742 100644 --- a/src/chock/gate/decision_log.py +++ b/src/chock/gate/decision_log.py @@ -132,8 +132,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == "guard" and "outcome" in GUARD_LAST: - return f"guard-{GUARD_LAST['outcome']}" - return source if source == "fault" else f"{source}-{_DLOG_SPOKEN.get(outcome, 'allow')}" + guarded = GUARD_LAST["outcome"] + return f"guard-{guarded}" + if source == "fault": + return source + spoken = _DLOG_SPOKEN.get(outcome, "allow") + return f"{source}-{spoken}" def _dlog_paths(route, runtime, agent, event): diff --git a/src/chock/gate/decision_target.py b/src/chock/gate/decision_target.py index 53f6f54f..bee39af8 100644 --- a/src/chock/gate/decision_target.py +++ b/src/chock/gate/decision_target.py @@ -155,7 +155,8 @@ def dlog_target(event, repo): tool = str(getattr(event, "tool", None) or "") mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(f"{mcp['server']}/{mcp['tool']}")[:_DLOG_MCP_CAP] + server, name = mcp["server"], mcp["tool"] + return log_redact(f"{server}/{name}")[:_DLOG_MCP_CAP] command, path = getattr(event, "command", None), getattr(event, "path", None) if command: return dlog_command_print(command) diff --git a/src/chock/gate/patch_image.py b/src/chock/gate/patch_image.py index 9a65005a..d59465e9 100644 --- a/src/chock/gate/patch_image.py +++ b/src/chock/gate/patch_image.py @@ -88,7 +88,8 @@ def _patch_not_empty(state, text): if last is None or last["op"] != "update": return if not last["body"] and state["mode"] == "update": - _patch_refuse(state["number"], text, f"update of {last['path']!r} has no hunk") + target = last["path"] + _patch_refuse(state["number"], text, f"update of {target!r} has no hunk") if last["body"] and not (last["body"][-1]["old"] or last["body"][-1]["new"]): _patch_refuse(state["number"], text, "a hunk has no lines") diff --git a/src/chock/gate/runner/journal.py b/src/chock/gate/runner/journal.py index c6098ea1..3d1434f8 100644 --- a/src/chock/gate/runner/journal.py +++ b/src/chock/gate/runner/journal.py @@ -77,7 +77,8 @@ def log_settings_read(path): problems.append(f"{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576") unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ", ".join(map(str, unknown)) + problems.append(f"{path}: unknown key(s) {names}; only level and max_bytes are read") return found, problems diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 336e74a1..aba141e7 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -235,7 +235,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -898,7 +899,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(f"{mcp['server']}/{mcp['tool']}")[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1024,8 +1026,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return f"guard-{GUARD_LAST['outcome']}" - return source if source == 'fault' else f"{source}-{_DLOG_SPOKEN.get(outcome, 'allow')}" + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1335,7 +1341,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, f"update of {last['path']!r} has no hunk") + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 8ec79325..5712ca99 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index ee95433b..a4adb414 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9f24d0d4..5c8c6830 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index ef075ec6..ad4c1941 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 627c4116..2c5678a5 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3af4298f..fcc3d343 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2990ff7e..9f5dcf14 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index c1b03e5a..f3d12e95 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 16b86489..5aa78ba1 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index 8a6bcaa7..ae1a32d9 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 4343f5b2..55b6f647 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index d513535a..d9b67afa 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index ab04974d..03a694d0 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index ee68c3b5..61f1f74b 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/tests/test_runtime_goldens.py b/tests/test_runtime_goldens.py index 8c1afefa..e4d40a0e 100644 --- a/tests/test_runtime_goldens.py +++ b/tests/test_runtime_goldens.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import inspect import os import shutil from pathlib import Path @@ -10,7 +11,7 @@ import pytest from agentseam import bundler, contract -from chock.gate import runtime_bundle, write_gate +from chock.gate import runtime_bundle, runtime_shared, write_gate GOLDEN = Path(__file__).resolve().parent / "fixtures" / "runtime_goldens" @@ -81,3 +82,30 @@ def test_chock_handler_shadows_no_agentseam_name(agent: str) -> None: shared = _top_level_names(head + tail) & _top_level_names(runtime_bundle._handler_source(agent)) assert shared <= {"PRE_TOOL"}, f"{agent}: chock's handler rebinds agentseam's {sorted(shared)}" assert write_gate.PRE_TOOL == contract.PRE_TOOL + + +def _quoting_fstrings(source: str) -> list[int]: + """Lines of f-strings whose replacement fields hold a string or another f-string: `ast.unparse` quotes those by Python patch level.""" + return sorted( + { + node.lineno + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.FormattedValue) + for inner in ast.walk(node.value) + if isinstance(inner, ast.JoinedStr) or (isinstance(inner, ast.Constant) and isinstance(inner.value, str)) + } + ) + + +def test_the_scan_finds_a_nested_quote_however_deep() -> None: + assert _quoting_fstrings('a = f"{x[\'k\']}"\nb = f"{\', \'.join(y)}"\nc = f"{x}-{y!r}"\n') == [1, 2] + + +def test_no_handler_module_has_an_fstring_whose_unparse_depends_on_the_python_patch_level() -> None: + """3.12.3 prints f"{m['k']}" as f'{m['k']}', 3.12.13 as f"{m['k']}": the rendered bytes would follow the interpreter.""" + found = { + module.__name__: lines + for module in runtime_shared._HANDLER_MODULES + if (lines := _quoting_fstrings(inspect.getsource(module))) + } + assert not found, f"hoist the quoted part of these f-strings into a local: {found}" From 19e13e57bff8231b06e46c013c7ebac676bfe0a6 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:28:18 +0000 Subject: [PATCH 10/33] install: keep the marketplace argument optional for existing callers claude_plugin.tokens, place.check_owned and place.write_marketplace gained a required marketplace argument. Other repos call them with the old signature, so the argument now defaults to the engine's default name. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/install/claude_plugin.py | 3 ++- src/chock/install/place.py | 6 ++++-- tests/test_install_marketplace_name.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/chock/install/claude_plugin.py b/src/chock/install/claude_plugin.py index 32adbf5f..d833d081 100644 --- a/src/chock/install/claude_plugin.py +++ b/src/chock/install/claude_plugin.py @@ -36,8 +36,9 @@ def routes(client_id: str, data: dict[str, Any] | None = None) -> list[dict[str, return [r for r in entry["routes"] if r["witness"] in witnessed] -def tokens(dest: Path, name: str, selection_arg: str, marketplace: str) -> dict[str, str]: +def tokens(dest: Path, name: str, selection_arg: str, marketplace: str | None = None) -> dict[str, str]: """The Claude build's own folder and marketplace, for the steps another agent runs on it.""" + marketplace = package.marketplace(marketplace) build = settings()["build_client"] return { "__DEST__": str(dest), diff --git a/src/chock/install/place.py b/src/chock/install/place.py index 47f58204..e908a6e6 100644 --- a/src/chock/install/place.py +++ b/src/chock/install/place.py @@ -52,8 +52,9 @@ def _marketplace_name(dest: Path) -> str: return package.marketplace() -def check_owned(client_id: str, dest: Path, name: str, marketplace: str) -> None: +def check_owned(client_id: str, dest: Path, name: str, marketplace: str | None = None) -> None: """Refuse a destination chock did not build, built for another client or under another marketplace name.""" + marketplace = package.marketplace(marketplace) if package.client(client_id)["layout"] == MARKETPLACE and dest.exists() and any(dest.iterdir()): found = _marketplace_client(dest) if found is None: @@ -133,8 +134,9 @@ def _index(spec: dict[str, Any], dest: Path, fmt: str, marketplace: str) -> dict return {"name": marketplace, "plugins": plugins} -def write_marketplace(client_id: str, dest: Path, marketplace: str) -> list[Path]: +def write_marketplace(client_id: str, dest: Path, marketplace: str | None = None) -> list[Path]: """The marketplace marker and the client's index files, rebuilt over every plugin now in `dest`.""" + marketplace = package.marketplace(marketplace) data = package.client(client_id) if data["layout"] != MARKETPLACE: return [] diff --git a/tests/test_install_marketplace_name.py b/tests/test_install_marketplace_name.py index 9de3201e..d93a71d1 100644 --- a/tests/test_install_marketplace_name.py +++ b/tests/test_install_marketplace_name.py @@ -132,3 +132,17 @@ def test_a_custom_folder_rebuilds_under_its_own_name_only(catalog, tmp_path: Pat assert _build(catalog, tmp_path, "claude-code", "--marketplace-name", CUSTOM) == 0 assert _build(catalog, tmp_path, "claude-code") == 1 assert json.loads((tmp_path / "market" / place.MARKETPLACE_MARKER).read_text())["marketplace"] == CUSTOM + + +def test_three_argument_tokens_are_the_default_name_tokens(tmp_path: Path) -> None: + old = claude_plugin.tokens(tmp_path, "set-two", "sel.yaml") + assert old == claude_plugin.tokens(tmp_path, "set-two", "sel.yaml", DEFAULT) + assert old["__MARKETPLACE__"] == DEFAULT + assert old["__MARKETPLACE_FLAG__"] == "" + + +def test_place_helpers_default_to_the_default_name(tmp_path: Path) -> None: + place.check_owned("claude-code", tmp_path / "empty", "set-two") + (tmp_path / "market").mkdir() + assert place.write_marketplace("claude-code", tmp_path / "market") + assert json.loads((tmp_path / "market" / place.MARKETPLACE_MARKER).read_text())["marketplace"] == DEFAULT From fe32a9f38e2eb48facb7fb7197475c3906871561 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:59:47 -0400 Subject: [PATCH 11/33] chore: chock sync for the stable runtime render Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/_chock_runtime.py | 17 ++++++++++++----- .chock/bin/claude_code.py | 2 +- .chock/bin/codex_cli.py | 2 +- .chock/bin/cursor.py | 2 +- .chock/bin/devin.py | 2 +- .chock/bin/gate.py | 3 ++- .chock/bin/gemini_cli.py | 2 +- .chock/bin/grok.py | 2 +- .chock/bin/tabnine.py | 2 +- .chock/bin/vscode_copilot.py | 2 +- .chock/bin/windsurf.py | 2 +- 11 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 336e74a1..aba141e7 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -235,7 +235,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -898,7 +899,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(f"{mcp['server']}/{mcp['tool']}")[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1024,8 +1026,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return f"guard-{GUARD_LAST['outcome']}" - return source if source == 'fault' else f"{source}-{_DLOG_SPOKEN.get(outcome, 'allow')}" + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1335,7 +1341,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, f"update of {last['path']!r} has no hunk") + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index ee95433b..a4adb414 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9f24d0d4..5c8c6830 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index ef075ec6..ad4c1941 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 627c4116..2c5678a5 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 153e73dc..76b0c801 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -1760,7 +1760,8 @@ def log_settings_read(path): problems.append(f"{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576") unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ", ".join(map(str, unknown)) + problems.append(f"{path}: unknown key(s) {names}; only level and max_bytes are read") return found, problems diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2990ff7e..9f5dcf14 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 16b86489..5aa78ba1 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index d513535a..d9b67afa 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index ab04974d..03a694d0 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index ee68c3b5..61f1f74b 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = 'ac48bd12a2911f4882985fe67e7d9b0a7257ff06ea065753f10487ef4d832c8e' def _chock_load_runtime(): From 56741eaf197b412e18b4134b3bc8b5d4e3f4e43c Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:02 +0000 Subject: [PATCH 12/33] docs: plugin repos stay as the per-policy route for machines without chock (IR-17) README route 2 and the repository table, docs/ecosystem.md and the repos.json source say the five chock-*-plugins repos are a per-policy marketplace for machines that cannot install chock, not retired. data.json regenerated with derive.py repos. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- README.md | 12 ++++++------ docs/assets/readme/data.json | 20 ++++++++++---------- docs/assets/readme/repos.json | 10 +++++----- docs/ecosystem.md | 2 +- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d7d443a6..669c6604 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pip install "chock @ git+https://github.com/open-coder-ai/chock@992711af4cf8d4fd ### Two ways to adopt it 1. **In your repository, for teams.** Run `chock init .`, then `chock add --ref --verify-sha --skip-compile` for each policy, then `chock sync --repo . --ci`. Commit the result. A fresh clone arms itself from what is committed; a person runs `chock sync` to change it. The commit gates are enforced at commit and in CI. -2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. +2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. If chock cannot be installed on a machine, add a per-policy marketplace instead (the `chock-*-plugins` repos); use one route per policy, never both. ## Application security for the code your agents write @@ -261,11 +261,11 @@ The 13 public repositories: | [chock-catalog](https://github.com/open-coder-ai/chock-catalog) | Policies: The policies, each labelled by what it enforces, with replayed evals. | | [context-report](https://github.com/open-coder-ai/context-report) | Evidence: A signed report of whether an agent artifact works. | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | Evidence: A weekly threat ledger, each entry scored against the catalog. | -| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Retired: plugins come from the chock.sh builder. | -| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Retired: plugins come from the chock.sh builder. | -| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Retired: plugins come from the chock.sh builder. | -| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Retired: plugins come from the chock.sh builder. | -| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Retired: plugins come from the chock.sh builder. | +| [chock-claude-plugins](https://github.com/open-coder-ai/chock-claude-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-copilot-plugins](https://github.com/open-coder-ai/chock-copilot-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-cursor-plugins](https://github.com/open-coder-ai/chock-cursor-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Plugins: per-policy marketplace, for machines without chock. | +| [chock-devin-plugins](https://github.com/open-coder-ai/chock-devin-plugins) | Plugins: per-policy marketplace, for machines without chock. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) | Template: What chock init leaves behind. | | [chock-example](https://github.com/open-coder-ai/chock-example) | Template: A working adoption, one policy per layer. | | [.github](https://github.com/open-coder-ai/.github) | Community: Org profile and community health files. | diff --git a/docs/assets/readme/data.json b/docs/assets/readme/data.json index 8b504cf4..7faf55aa 100644 --- a/docs/assets/readme/data.json +++ b/docs/assets/readme/data.json @@ -140,28 +140,28 @@ }, { "name": "chock-claude-plugins", - "kind": "Retired", - "line": "plugins come from the chock.sh builder." + "kind": "Plugins", + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-copilot-plugins", - "kind": "Retired", - "line": "plugins come from the chock.sh builder." + "kind": "Plugins", + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-cursor-plugins", - "kind": "Retired", - "line": "plugins come from the chock.sh builder." + "kind": "Plugins", + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-codex-plugins", - "kind": "Retired", - "line": "plugins come from the chock.sh builder." + "kind": "Plugins", + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-devin-plugins", - "kind": "Retired", - "line": "plugins come from the chock.sh builder." + "kind": "Plugins", + "line": "per-policy marketplace, for machines without chock." }, { "name": "chock-quickstart", diff --git a/docs/assets/readme/repos.json b/docs/assets/readme/repos.json index f29a0804..3fbde8bc 100644 --- a/docs/assets/readme/repos.json +++ b/docs/assets/readme/repos.json @@ -4,11 +4,11 @@ {"name": "chock-catalog", "kind": "Policies", "line": "The policies, each labelled by what it enforces, with replayed evals."}, {"name": "context-report", "kind": "Evidence", "line": "A signed report of whether an agent artifact works."}, {"name": "chock-threat-intel", "kind": "Evidence", "line": "A weekly threat ledger, each entry scored against the catalog."}, - {"name": "chock-claude-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, - {"name": "chock-copilot-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, - {"name": "chock-cursor-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, - {"name": "chock-codex-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, - {"name": "chock-devin-plugins", "kind": "Retired", "line": "plugins come from the chock.sh builder."}, + {"name": "chock-claude-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-copilot-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-cursor-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-codex-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, + {"name": "chock-devin-plugins", "kind": "Plugins", "line": "per-policy marketplace, for machines without chock."}, {"name": "chock-quickstart", "kind": "Template", "line": "What chock init leaves behind."}, {"name": "chock-example", "kind": "Template", "line": "A working adoption, one policy per layer."}, {"name": ".github", "kind": "Community", "line": "Org profile and community health files."} diff --git a/docs/ecosystem.md b/docs/ecosystem.md index b401956c..e0040bba 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -9,7 +9,7 @@ claim must match a mechanism. This page says what each sibling is and how chock | [agentseam](https://github.com/open-coder-ai/agentseam) | The primitives layer for every coding agent: one handler API over their hooks, instruction files, plugin packaging and config, with a capability matrix that carries its provenance. chock is one thing built on it | | [context-report](https://github.com/open-coder-ai/context-report) | A signed report format for whether a plugin, hook, skill or `AGENTS.md` actually works; chock can export one | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | A weekly, human-reviewed digest of agentic-AI threats scored against the catalog: enforced, advisory, or `policy wanted`. Feeds the roadmap | -| Plugins | No per-client plugin repos: the five `chock-*-plugins` repos are retired. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection '' --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). | +| Plugins | Two routes. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection '' --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). For machines that cannot install chock, the five `chock-*-plugins` repos stay as a per-policy marketplace, republished with every chock release; use one route per policy, never both. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) · [chock-example](https://github.com/open-coder-ai/chock-example) | Template repositories: exactly what `chock init` leaves behind, and a working adoption with one policy per layer | Where a contribution goes: From 8adec9aba102efff198eaafb1d3ba02e45ece9f7 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:33 +0000 Subject: [PATCH 13/33] docs: shorten the README no-chock sentence to the word budget (IR-17) Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 669c6604..a9d81b71 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pip install "chock @ git+https://github.com/open-coder-ai/chock@992711af4cf8d4fd ### Two ways to adopt it 1. **In your repository, for teams.** Run `chock init .`, then `chock add --ref --verify-sha --skip-compile` for each policy, then `chock sync --repo . --ci`. Commit the result. A fresh clone arms itself from what is committed; a person runs `chock sync` to change it. The commit gates are enforced at commit and in CI. -2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. If chock cannot be installed on a machine, add a per-policy marketplace instead (the `chock-*-plugins` repos); use one route per policy, never both. +2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. No chock on the machine? Add a per-policy marketplace (the `chock-*-plugins` repos) instead; never both for one policy. ## Application security for the code your agents write From 433888991f2870898398277997f5e5f529c19905 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:22:02 +0000 Subject: [PATCH 14/33] docs: the installed plugin needs only Python and git; the plugin repos are the no-chock route (IR-15, IR-17) Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 7631951a..7285a8a7 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -296,5 +296,16 @@ set to the new folder, refused `rm -rf /` and passed `ls -la`, and `claude plugi `claude plugin install chock-guardrails@chock-local` succeeded from the new location. That run used the default name; a `--marketplace-name` build (tests only), a live refusal and every other client are unmeasured. +**Developers need no chock.** The built plugin's scripts import nothing from chock or PyYAML. It needs Python 3.11+ and +git on `PATH`: the hook command runs `launch.sh` through `git`, and without Python every call is refused. Measured +on a built Claude Code plugin from a fixture catalog: its hooks ran in a virtual environment with no chock and no +site-packages (Python 3.11.15 and 3.12.3), refused a write to `.chock/guardrails.json` and allowed `notes.txt`. +Windows and a full catalog selection are unmeasured. + +**No machine can run chock at all?** Then nothing here can build the plugin. The `chock-*-plugins` repos +([claude](https://github.com/open-coder-ai/chock-claude-plugins), copilot, cursor, codex, devin) stay as a +per-policy marketplace, republished with every chock release: add one as a marketplace, or mirror it into your own +git server, and install the policies you want. Use one route per policy, never both: each copy runs its own hook. + Pre-installing a marketplace on every machine is the vendor's feature, not chock's: Claude Code's [docs](https://code.claude.com/docs/en/plugin-marketplaces) say an administrator can require one (unmeasured). From b63399681c86fef70ba602afd40cc645330d2ddf Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:23:25 +0000 Subject: [PATCH 15/33] docs: fit agent-plugins.md in the line budget and drop a path the docs gate rejects (IR-15) Reflow only; the no-chock paragraph is shortened. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 55 +++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 7285a8a7..501a3f4e 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -247,15 +247,14 @@ voids it until re-approved. ## Host it in your own marketplace -An organisation that does not want every developer running a build builds once and hosts the result. -An admin runs the build without `--apply`, into a folder of their choice: +An organisation that does not want every developer running a build builds once and hosts the result. An admin runs the build without `--apply`, into a folder of their choice: ``` chock install --selection '' --client --dest --marketplace-name -chock ``` -Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, -`copilot`, `codex`, `factory-droid`) the folder is a complete local marketplace: +Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, `copilot`, `codex`, `factory-droid`) the folder is +a complete local marketplace: ```text / @@ -268,15 +267,12 @@ Nothing is installed and no client command runs. For the marketplace-layout clie └── chock.selection.json # the marker ``` -The marker records the selection, the catalog commit, the engine version and the sha256 of every accepted -custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, `antigravity`, `openhands`, `kimi-code`) -build the plugin folder itself, with no index. +The marker records the selection, the catalog commit, the engine version and the sha256 of every accepted custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, +`antigravity`, `openhands`, `kimi-code`) build the plugin folder itself, with no index. -**Name the marketplace.** Every build calls its local marketplace `chock-local`; adding a second folder under -that name re-points the existing `chock-local` registration, and every plugin installed from it, to the new -folder. `--marketplace-name ` (lowercase letters, digits, single hyphens; at most 64 characters) names -the hosted build instead: developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local` -and neither moves the other. A folder keeps its name. +**Name the marketplace.** Every build calls its local marketplace `chock-local`; adding a second folder under that name re-points the existing `chock-local` registration, and +every plugin installed from it, to the new folder. `--marketplace-name ` (lowercase letters, digits, single hyphens; at most 64 characters) names the hosted build +instead: developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local` and neither moves the other. A folder keeps its name. 1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`; `chock bundle status` lists only bundles installed in a client's default folder, so it does not see a `--dest` build. @@ -285,27 +281,20 @@ and neither moves the other. A folder keeps its name. /` and `claude plugin install chock-guardrails@`. 4. **Update** by building on a newer catalog pin and committing again; the marker's catalog commit is the version. -Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` -for Claude Code), so the folder works from wherever it is installed. **Antigravity and OpenHands -document no such variable** (`plugin_root: null`): their hooks name the build folder by absolute path, so -a hosted copy must be installed at that same path. - -Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, `block-no-verify`, -`block-curl-pipe-sh`) was moved to another folder; its `hooks.json` commands, run with `CLAUDE_PLUGIN_ROOT` -set to the new folder, refused `rm -rf /` and passed `ls -la`, and `claude plugin marketplace add` and -`claude plugin install chock-guardrails@chock-local` succeeded from the new location. That run used the default -name; a `--marketplace-name` build (tests only), a live refusal and every other client are unmeasured. - -**Developers need no chock.** The built plugin's scripts import nothing from chock or PyYAML. It needs Python 3.11+ and -git on `PATH`: the hook command runs `launch.sh` through `git`, and without Python every call is refused. Measured -on a built Claude Code plugin from a fixture catalog: its hooks ran in a virtual environment with no chock and no -site-packages (Python 3.11.15 and 3.12.3), refused a write to `.chock/guardrails.json` and allowed `notes.txt`. -Windows and a full catalog selection are unmeasured. - -**No machine can run chock at all?** Then nothing here can build the plugin. The `chock-*-plugins` repos -([claude](https://github.com/open-coder-ai/chock-claude-plugins), copilot, cursor, codex, devin) stay as a -per-policy marketplace, republished with every chock release: add one as a marketplace, or mirror it into your own -git server, and install the policies you want. Use one route per policy, never both: each copy runs its own hook. +Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` for Claude Code), so the folder works from wherever it is installed. +**Antigravity and OpenHands document no such variable** (`plugin_root: null`): their hooks name the build folder by absolute path, so a hosted copy must be installed at that +same path. + +Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, `block-no-verify`, `block-curl-pipe-sh`) was moved to another folder; its `hooks.json` +commands, run with `CLAUDE_PLUGIN_ROOT` set to the new folder, refused `rm -rf /` and passed `ls -la`, and `claude plugin marketplace add` and `claude plugin install +chock-guardrails@chock-local` succeeded from the new location. That run used the default name; a `--marketplace-name` build (tests only), a live refusal and every other client +are unmeasured. + +**Developers need no chock.** The built plugin imports nothing from chock or PyYAML; it needs Python 3.11+ and git on `PATH` (the hook runs `launch.sh` through `git`; without +Python every call is refused). Measured on a fixture-catalog Claude Code plugin in a venv with no chock (Python 3.11.15, 3.12.3): it refused a write to a guarded file and +allowed `notes.txt`. Windows and a full catalog selection are unmeasured. **No machine can run chock?** Then nothing here builds the plugin: the `chock-*-plugins` repos +([claude](https://github.com/open-coder-ai/chock-claude-plugins), copilot, cursor, codex, devin) stay as a per-policy marketplace, republished with every chock release. Add +one, or mirror it into your own git server. One route per policy, never both: each copy runs its own hook. Pre-installing a marketplace on every machine is the vendor's feature, not chock's: Claude Code's [docs](https://code.claude.com/docs/en/plugin-marketplaces) say an administrator can require one (unmeasured). From b3c86ced4341b8021cad2a278911aa6a5b67ea01 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:27:46 +0000 Subject: [PATCH 16/33] IR-14: every rendered runtime file compiles on Python 3.11 Add tests/test_runtime_compiles_on_oldest_python.py. It compiles .chock/bin/*.py and tests/fixtures/runtime_goldens/*.py on 3.11: directly on the 3.11 matrix job, via a python3.11 subprocess elsewhere when one is on PATH. A 3.12-only nested-quote f-string from ast.unparse now fails CI instead of refusing every hook call on a 3.11 machine (IR-F13). Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .../test_runtime_compiles_on_oldest_python.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_runtime_compiles_on_oldest_python.py diff --git a/tests/test_runtime_compiles_on_oldest_python.py b/tests/test_runtime_compiles_on_oldest_python.py new file mode 100644 index 00000000..5c6952f6 --- /dev/null +++ b/tests/test_runtime_compiles_on_oldest_python.py @@ -0,0 +1,39 @@ +"""Every rendered runtime file compiles on the oldest supported Python (3.11). + +`ast.unparse` writes nested-quote f-strings that only parse on 3.12+. A runtime built that way +raises SyntaxError on a 3.11 machine, and every hook call is then refused (IR-F13). The 3.11 +matrix job runs this on 3.11 itself; on a newer interpreter the files are compiled by a 3.11 +interpreter when one is on PATH. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +RENDERED = sorted([*(ROOT / ".chock" / "bin").glob("*.py"), *(ROOT / "tests" / "fixtures" / "runtime_goldens").glob("*.py")]) +OLDEST = (3, 11) +_COMPILE = "import sys\nfor p in sys.argv[1:]:\n compile(open(p, encoding='utf-8').read(), p, 'exec')\n" + + +def test_the_rendered_runtime_files_are_found() -> None: + kinds = {p.parent.name for p in RENDERED} + assert kinds == {"bin", "runtime_goldens"}, kinds + assert len(RENDERED) >= 20 + + +def test_every_rendered_runtime_file_compiles_on_the_oldest_supported_python() -> None: + if sys.version_info[:2] == OLDEST: + for path in RENDERED: + compile(path.read_text(encoding="utf-8"), str(path), "exec") + return + exe = shutil.which(f"python{OLDEST[0]}.{OLDEST[1]}") + if exe is None: + pytest.skip("no Python 3.11 on PATH; the 3.11 matrix job runs this check") + run = subprocess.run([exe, "-c", _COMPILE, *map(str, RENDERED)], capture_output=True, text=True, check=False) + assert run.returncode == 0, f"not valid on Python 3.11:\n{run.stderr[-600:]}" From ccc4dbf4a9e8ec9c77fe17abe1fd28505bdf0e1e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:27:54 +0000 Subject: [PATCH 17/33] IR-14: format the new test with ruff Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_runtime_compiles_on_oldest_python.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_runtime_compiles_on_oldest_python.py b/tests/test_runtime_compiles_on_oldest_python.py index 5c6952f6..0abfaea6 100644 --- a/tests/test_runtime_compiles_on_oldest_python.py +++ b/tests/test_runtime_compiles_on_oldest_python.py @@ -16,7 +16,9 @@ import pytest ROOT = Path(__file__).resolve().parent.parent -RENDERED = sorted([*(ROOT / ".chock" / "bin").glob("*.py"), *(ROOT / "tests" / "fixtures" / "runtime_goldens").glob("*.py")]) +RENDERED = sorted( + [*(ROOT / ".chock" / "bin").glob("*.py"), *(ROOT / "tests" / "fixtures" / "runtime_goldens").glob("*.py")] +) OLDEST = (3, 11) _COMPILE = "import sys\nfor p in sys.argv[1:]:\n compile(open(p, encoding='utf-8').read(), p, 'exec')\n" From aed80327b0f8bfab86f344b276bc779241655a06 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:28:08 +0000 Subject: [PATCH 18/33] docs: unquote the selection code; name the no-chock requirements (IR-F1) cmd.exe passes single quotes literally, so the examples read --selection . README route 2 names Python 3.11+, git and a bash for the plugin repos. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- README.md | 2 +- docs/agent-plugins.md | 2 +- docs/assets/readme/render.mjs | 2 +- docs/ecosystem.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a9d81b71..cfc98287 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pip install "chock @ git+https://github.com/open-coder-ai/chock@992711af4cf8d4fd ### Two ways to adopt it 1. **In your repository, for teams.** Run `chock init .`, then `chock add --ref --verify-sha --skip-compile` for each policy, then `chock sync --repo . --ci`. Commit the result. A fresh clone arms itself from what is committed; a person runs `chock sync` to change it. The commit gates are enforced at commit and in CI. -2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection '' --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. No chock on the machine? Add a per-policy marketplace (the `chock-*-plugins` repos) instead; never both for one policy. +2. **In your coding agent, as one plugin.** Pick policies in the chock.sh builder (launching soon) and run `chock install --selection --apply`. It builds one plugin for Claude Code, Cursor, Codex, Copilot or Devin, and, untested by chock, for goose, Grok, Antigravity, OpenHands, Kimi Code or Factory Droid. Best-effort, and it fails open. A selection can add [your own policies](docs/custom-policies.md): install shows their code and asks first. No chock? Add a per-policy marketplace (`chock-*-plugins`; needs Python 3.11+, git, a bash: Git for Windows has one), never both. ## Application security for the code your agents write diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index bcf7f9e5..63a71731 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -193,7 +193,7 @@ policies (`chock plugin build --format claude|copilot|cursor|codex|devin`). A us one merged plugin from a chock.sh selection, built on their machine: ``` -chock install --selection '' --client --apply +chock install --selection --client --apply ``` `--apply` runs the client's own commands where it has them; otherwise the step is yours. diff --git a/docs/assets/readme/render.mjs b/docs/assets/readme/render.mjs index c3da623c..a5cbc617 100644 --- a/docs/assets/readme/render.mjs +++ b/docs/assets/readme/render.mjs @@ -116,7 +116,7 @@ ${term([cmd('chock init .'), cmd('chock add <id> --ref <sha> \\'), '
Route 2no repo changes
In your coding agent, as one plugin
-${term([cmd('chock install --selection '<code>' --apply'), '
from the chock.sh builder
'])} +${term([cmd('chock install --selection <code> --apply'), '
from the chock.sh builder
'])}
Your own sessions in that client, any client chock install supports. Best-effort hooks that fail open. Pair with route 1 for commit and CI.
Or build a selection: the chock.sh builder (launching soon) gives one plugin for your agent from the policies you pick, your own included.
`); diff --git a/docs/ecosystem.md b/docs/ecosystem.md index e0040bba..0b17a3bc 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -9,7 +9,7 @@ claim must match a mechanism. This page says what each sibling is and how chock | [agentseam](https://github.com/open-coder-ai/agentseam) | The primitives layer for every coding agent: one handler API over their hooks, instruction files, plugin packaging and config, with a capability matrix that carries its provenance. chock is one thing built on it | | [context-report](https://github.com/open-coder-ai/context-report) | A signed report format for whether a plugin, hook, skill or `AGENTS.md` actually works; chock can export one | | [chock-threat-intel](https://github.com/open-coder-ai/chock-threat-intel) | A weekly, human-reviewed digest of agentic-AI threats scored against the catalog: enforced, advisory, or `policy wanted`. Feeds the roadmap | -| Plugins | Two routes. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection '' --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). For machines that cannot install chock, the five `chock-*-plugins` repos stay as a per-policy marketplace, republished with every chock release; use one route per policy, never both. | +| Plugins | Two routes. The chock.sh builder produces one plugin for any client from a selection, via `chock install --selection --apply` (see [agent plugins](agent-plugins.md#the-hook-carrying-vendor-formats)). For machines that cannot install chock, the five `chock-*-plugins` repos stay as a per-policy marketplace, republished with every chock release; use one route per policy, never both. | | [chock-quickstart](https://github.com/open-coder-ai/chock-quickstart) · [chock-example](https://github.com/open-coder-ai/chock-example) | Template repositories: exactly what `chock init` leaves behind, and a working adoption with one policy per layer | Where a contribution goes: From af196fe1c5ac06927001c0e04af952db4be8d63e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:31:16 +0000 Subject: [PATCH 19/33] docs: restore the section wrapping, unquote the selection code, name the bash requirement (IR-F1) Undoes the 175-column reflow; the line budget is met by cutting words in the hosted-marketplace section. Requirements read Python 3.11+, git and a bash (Git for Windows includes one). Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/agent-plugins.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 3e39b340..26588dc5 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -247,14 +247,15 @@ voids it until re-approved. ## Host it in your own marketplace -An organisation that does not want every developer running a build builds once and hosts the result. An admin runs the build without `--apply`, into a folder of their choice: +An organisation that does not want every developer running a build builds once and hosts the result. +An admin runs the build without `--apply`, into a folder of their choice: ``` -chock install --selection '' --client --dest --marketplace-name -chock +chock install --selection --client --dest --marketplace-name -chock ``` -Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, `copilot`, `codex`, `factory-droid`) the folder is -a complete local marketplace: +Nothing is installed and no client command runs. For the marketplace-layout clients (`claude-code`, +`copilot`, `codex`, `factory-droid`) the folder is a complete local marketplace: ```text / @@ -267,12 +268,12 @@ a complete local marketplace: └── chock.selection.json # the marker ``` -The marker records the selection, the catalog commit, the engine version and the sha256 of every accepted custom policy. The other clients (`cursor`, `devin`, `goose`, `grok`, -`antigravity`, `openhands`, `kimi-code`) build the plugin folder itself, with no index. +The marker records the selection, catalog commit, engine version and each custom policy's sha256. The other clients +(`cursor`, `devin`, `goose`, `grok`, `antigravity`, `openhands`, `kimi-code`) build the plugin folder. -**Name the marketplace.** Every build calls its local marketplace `chock-local`; adding a second folder under that name re-points the existing `chock-local` registration, and -every plugin installed from it, to the new folder. `--marketplace-name ` (lowercase letters, digits, single hyphens; at most 64 characters) names the hosted build -instead: developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local` and neither moves the other. A folder keeps its name. +**Name the marketplace.** Every build calls its marketplace `chock-local`; a second folder under that name re-points +the first. `--marketplace-name ` (lowercase, digits, hyphens; at most 64 characters) names it instead: +developers install `chock-guardrails@` beside a personal `chock-guardrails@chock-local`, and neither moves. 1. **Review.** Read `chock.selection.json` and `hooks/hooks.json`; `chock bundle status` lists only bundles installed in a client's default folder, so it does not see a `--dest` build. @@ -281,20 +282,19 @@ instead: developers install `chock-guardrails@` beside a personal `chock-g /` and `claude plugin install chock-guardrails@`. 4. **Update** by building on a newer catalog pin and committing again; the marker's catalog commit is the version. -Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` for Claude Code), so the folder works from wherever it is installed. -**Antigravity and OpenHands document no such variable** (`plugin_root: null`): their hooks name the build folder by absolute path, so a hosted copy must be installed at that -same path. +Hook commands reach their scripts through the client's plugin-root variable (`${CLAUDE_PLUGIN_ROOT}` for Claude Code), +so the folder works anywhere. **Antigravity and OpenHands document none** (`plugin_root: null`): their hooks use the +build folder's absolute path, so install a hosted copy at that path. -Measured for Claude Code 2.1.305: a three-policy build (`block-destructive-commands`, `block-no-verify`, `block-curl-pipe-sh`) was moved to another folder; its `hooks.json` -commands, run with `CLAUDE_PLUGIN_ROOT` set to the new folder, refused `rm -rf /` and passed `ls -la`, and `claude plugin marketplace add` and `claude plugin install -chock-guardrails@chock-local` succeeded from the new location. That run used the default name; a `--marketplace-name` build (tests only), a live refusal and every other client -are unmeasured. +Measured for Claude Code 2.1.305: a moved three-policy build's `hooks.json` commands, with `CLAUDE_PLUGIN_ROOT` set to +the new folder, refused `rm -rf /` and passed `ls -la`; `claude plugin marketplace add` and `install` succeeded. +Unmeasured: a `--marketplace-name` build (tests only), a live refusal, other clients. -**Developers need no chock.** The built plugin imports nothing from chock or PyYAML; it needs Python 3.11+ and git on `PATH` (the hook runs `launch.sh` through `git`; without -Python every call is refused). Measured on a fixture-catalog Claude Code plugin in a venv with no chock (Python 3.11.15, 3.12.3): it refused a write to a guarded file and -allowed `notes.txt`. Windows and a full catalog selection are unmeasured. **No machine can run chock?** Then nothing here builds the plugin: the `chock-*-plugins` repos -([claude](https://github.com/open-coder-ai/chock-claude-plugins), copilot, cursor, codex, devin) stay as a per-policy marketplace, republished with every chock release. Add -one, or mirror it into your own git server. One route per policy, never both: each copy runs its own hook. +**Developers need no chock.** The plugin imports nothing from chock; it needs Python 3.11+, git and a bash (Git for +Windows includes one). Measured in a venv without chock (Python 3.11.15, 3.12.3), a fixture-catalog plugin refused a +guarded write and allowed `notes.txt`; Windows unmeasured. **No machine can run chock?** The +[`chock-*-plugins`](https://github.com/open-coder-ai/chock-claude-plugins) repos stay as a per-policy marketplace, +republished each release. One route per policy. Pre-installing a marketplace on every machine is the vendor's feature, not chock's: Claude Code's [docs](https://code.claude.com/docs/en/plugin-marketplaces) say an administrator can require one (unmeasured). From e0e4a9f838233816643ec5a911e5287390eb2972 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:34:53 +0000 Subject: [PATCH 20/33] install: drop the semicolons from the marketplace-name sentences (IR-21a) The --marketplace-name help and the Copilot "already registered" note each joined two clauses with a semicolon. Same meaning, now a comma. No test or doc quotes either sentence. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/install/cli.py | 2 +- src/chock/install/data/claude_plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chock/install/cli.py b/src/chock/install/cli.py index 9f8d0f6d..7c1366a5 100644 --- a/src/chock/install/cli.py +++ b/src/chock/install/cli.py @@ -165,7 +165,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--marketplace-name", metavar="NAME", - help=f"Name the build's local marketplace (default: {package.marketplace()}); a team's own build uses its own", + help=f"Name the build's local marketplace (default: {package.marketplace()}), so a team's own build can use its own", ) parser.add_argument("--apply", action="store_true", help="Also run the client's own install commands, if any") parser.add_argument( diff --git a/src/chock/install/data/claude_plugin.json b/src/chock/install/data/claude_plugin.json index 7b2a5091..5e4829bd 100644 --- a/src/chock/install/data/claude_plugin.json +++ b/src/chock/install/data/claude_plugin.json @@ -45,7 +45,7 @@ "witness": "Copilot CLI: guard (shell command) and gate (file write) refused by preToolUse deny", "label": "guard and gate refused", "steps": [ - {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (this build's local marketplace is named __MARKETPLACE__; every chock build made with that name shares it): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, + {"note": "If `copilot plugin marketplace add` says Marketplace \"__MARKETPLACE__\" already registered, that is chock's Copilot build (this build's local marketplace is named __MARKETPLACE__, and every chock build made with that name shares it): keep one build and run `copilot plugin marketplace remove __MARKETPLACE__` first."}, {"command": ["copilot", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, {"note": "It loads live from __PLUGIN_DIR__ (nothing is copied); `copilot plugin uninstall` only disables it."} From f9a76aa9f7cb0d413de36b21b472c7afa2dab85e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:14:34 +0000 Subject: [PATCH 21/33] Harden path handling, turn-end reads and person-only checks (SR-3) - cli: `--check` opens a person-only command only where its parser defines the flag (sync, recompile) and only before `--`. - gate: writes of empty content and apply_patch deletes/moves are judged by path; the turn-end listing names deleted files and a rename's old name; path gates also cover a policy's own folders (content scans still skip them). - gate: plugin gates judge from the repository top level; the turn-end read uses lstat/O_NOFOLLOW/O_NONBLOCK and refuses what it cannot read safely; on-disk case is judged on case-insensitive platforms. - gateway: judge the folded, resolved and cased spellings write_gate uses; a scoped gate treats a path it cannot place in the repository as in scope. - guardrails: in a repository a member is off only where HEAD's toggle file says so; the shell guard refuses a write through a path built by expansion. - tests for each; runtime goldens regenerated. .chock/bin is re-rendered by the owner's sync. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/engine-hardening-round-2.md | 7 + src/chock/cli.py | 11 +- src/chock/gate/edit_image.py | 11 +- src/chock/gate/patch_image.py | 10 +- src/chock/gate/repo_names.py | 64 +++++++ src/chock/gate/runner/context.py | 16 +- src/chock/gate/runner/kinds.py | 7 +- src/chock/gate/runtime_shared.py | 4 + src/chock/gate/stop_roots.py | 20 +- src/chock/gate/worktree_read.py | 45 +++++ src/chock/gate/write_gate.py | 62 ++----- src/chock/gateway/gates.py | 22 ++- src/chock/guardrails/chock_bundle_log.py | 22 ++- src/chock/guardrails/cli.py | 3 +- .../chock_guardrails_protect/__init__.py | 80 +++----- .../chock_guardrails_protect/names.py | 50 +++++ src/chock/guardrails/toggle.py | 18 +- .../fixtures/plugin_targets/antigravity.json | 2 + tests/fixtures/plugin_targets/droid.json | 2 + tests/fixtures/plugin_targets/gemini.json | 2 + tests/fixtures/plugin_targets/goose.json | 2 + tests/fixtures/plugin_targets/grok.json | 1 + tests/fixtures/plugin_targets/kimi.json | 2 + tests/fixtures/plugin_targets/openhands.json | 2 + .../runtime_goldens/_chock_runtime.py | 171 +++++++++++++----- tests/fixtures/runtime_goldens/antigravity.py | 2 +- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/fixtures/runtime_goldens/codex_cli.py | 2 +- tests/fixtures/runtime_goldens/cursor.py | 2 +- tests/fixtures/runtime_goldens/devin.py | 2 +- .../fixtures/runtime_goldens/factory_droid.py | 2 +- tests/fixtures/runtime_goldens/gemini_cli.py | 2 +- tests/fixtures/runtime_goldens/goose.py | 2 +- tests/fixtures/runtime_goldens/grok.py | 2 +- .../fixtures/runtime_goldens/mistral_vibe.py | 2 +- tests/fixtures/runtime_goldens/openhands.py | 2 +- tests/fixtures/runtime_goldens/tabnine.py | 2 +- .../runtime_goldens/vscode_copilot.py | 2 +- tests/fixtures/runtime_goldens/windsurf.py | 2 +- tests/guardrails_support.py | 23 ++- tests/test_gate_write_path.py | 25 +++ tests/test_guardrails_cli.py | 17 ++ tests/test_guardrails_protect.py | 5 + tests/test_guardrails_turn_end.py | 6 +- tests/test_mcp_gateway_scope.py | 36 ++++ tests/test_person_only_commands.py | 20 ++ tests/test_write_gate_handler.py | 74 +++++++- tests/test_write_gate_patch.py | 14 +- tests/test_write_gate_patch_parse.py | 2 +- 49 files changed, 676 insertions(+), 210 deletions(-) create mode 100644 changelog.d/engine-hardening-round-2.md create mode 100644 src/chock/gate/repo_names.py create mode 100644 src/chock/gate/worktree_read.py create mode 100644 src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py diff --git a/changelog.d/engine-hardening-round-2.md b/changelog.d/engine-hardening-round-2.md new file mode 100644 index 00000000..86046b66 --- /dev/null +++ b/changelog.d/engine-hardening-round-2.md @@ -0,0 +1,7 @@ +- **Security: engine hardening, round 2.** Path handling and the person-only checks are stricter. + - The write gate, the turn-end check and the MCP gateway judge the same canonical spellings of a path. + - Every kind of file change, deletions and moves included, reaches the gates that judge paths. + - A policy's path gate also covers that policy's own folders. + - A plugin gate judges from the repository top level, and the turn-end read of the worktree is bounded to regular files. + - A guardrails member is off in a repository only when the committed toggle file says so. + - `--check` opens a person-only command to an agent only where the command defines it. diff --git a/src/chock/cli.py b/src/chock/cli.py index 53dc0c9b..a77eaf3d 100644 --- a/src/chock/cli.py +++ b/src/chock/cli.py @@ -17,6 +17,9 @@ #: A person-only command given this flag reports drift and writes nothing, so it stays open to an agent. _CHECK_FLAG = "--check" +#: The person-only commands whose parser defines `--check`; for any other it is a positional value, not a flag. +_CHECK_COMMANDS = ("sync", "recompile") +_END_OF_OPTIONS = "--" def _module_main(module_name: str): @@ -64,6 +67,12 @@ def _repo_root(argv: list[str]) -> Path: return Path() +def _is_check_form(command: str, argv: list[str]) -> bool: + """`--check` as the flag of a command that defines it: before any `--`, where argparse stops reading flags.""" + options = argv[: argv.index(_END_OF_OPTIONS)] if _END_OF_OPTIONS in argv else argv + return command in _CHECK_COMMANDS and _CHECK_FLAG in options + + def _is_sole_flag_form(command: str, argv: list[str]) -> bool: """`argv` is the command's agent-ok flag with at most a `--repo`, and nothing else.""" flag = AGENT_OK_FLAG.get(command) @@ -112,7 +121,7 @@ def _dispatch(argv: list[str]) -> int: print(f"Unknown command: {command!r}. Run 'chock --help'.", file=sys.stderr) return 2 fn, _, person_only = COMMANDS[command] - if person_only and _CHECK_FLAG not in rest: + if person_only and not _is_check_form(command, rest): repo = _repo_root(rest).resolve() if _is_sole_flag_form(command, rest): if committed_refused(repo, command): diff --git a/src/chock/gate/edit_image.py b/src/chock/gate/edit_image.py index 75de559f..df7838c5 100644 --- a/src/chock/gate/edit_image.py +++ b/src/chock/gate/edit_image.py @@ -18,6 +18,7 @@ _CRLF = "\r\n" #: Copilot CLI's `create` carries the new file as `file_text`, a key no adapter reads as content. _CREATE_TEXT = "file_text" +_CREATE_KEYS = ("content", _CREATE_TEXT) def _edit_call_input(event): @@ -86,12 +87,16 @@ def edited_text(event, root=None): def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, "content", None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): diff --git a/src/chock/gate/patch_image.py b/src/chock/gate/patch_image.py index 9a65005a..42296592 100644 --- a/src/chock/gate/patch_image.py +++ b/src/chock/gate/patch_image.py @@ -229,7 +229,11 @@ def _read(root, path): def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -241,7 +245,7 @@ def patched_files(event, root=None): disk[key] = files[key] = "".join(line + "\n" for line in body) elif op == "delete": disk[key] = None - files.pop(key, None) + files[key] = "" else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -249,7 +253,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = "" disk[target] = files[target] = after return files diff --git a/src/chock/gate/repo_names.py b/src/chock/gate/repo_names.py new file mode 100644 index 00000000..6aa929e7 --- /dev/null +++ b/src/chock/gate/repo_names.py @@ -0,0 +1,64 @@ +"""Every repo-relative name a write is judged under: as written, through symlinks, and in the case the disk stores. + +Extracted into the vendored per-agent runtime beside write_gate, so it is stdlib-only and may not import chock. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath + +from .outside_repo import is_outside +from .workspace_root import _DRIVE_COLON, _folded + +#: Filesystems that fold case: a differently-cased spelling of a protected path writes the protected file. +_CASE_BLIND = sys.platform in ("darwin", "win32") + + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = os.name == "nt" or str(root)[1:2] == _DRIVE_COLON + flavour = PureWindowsPath if windows else PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (os.name == "nt"): + return text + try: + return Path(root, text).resolve().relative_to(Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + + +def on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = Path(root), [] + for part in PurePosixPath(name).parts: + try: + stored = os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return PurePosixPath(*spelled).as_posix() + + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (os.name == "nt"): + return (lexical,) + try: + resolved = Path(root, str(path)).resolve().relative_to(Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) diff --git a/src/chock/gate/runner/context.py b/src/chock/gate/runner/context.py index ad44c6de..60818c3b 100644 --- a/src/chock/gate/runner/context.py +++ b/src/chock/gate/runner/context.py @@ -62,14 +62,18 @@ def __init__( #: Path prefixes this gate never judges: its own policy's source and compiled folders. self.own = tuple(own) - def in_scope(self, path: str) -> bool: + def is_own(self, path: str) -> bool: + """Whether `path` is in this policy's own source or compiled folder.""" + return bool(self.own) and path.startswith(self.own) + + def in_scope(self, path: str, *, judge_own: bool = False) -> bool: """Whether this policy may judge this file at all. fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ - if path.startswith(self.own): + if not judge_own and self.is_own(path): return False if _is_outside(path): return True @@ -117,9 +121,9 @@ def rev_exists(self, ref: str) -> bool: """True when `ref` resolves to a commit. Used to fail CI closed on a missing base.""" return bool(self._git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").strip()) - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: paths = self._listing("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [path for path in paths if self.in_scope(path)] + return [path for path in paths if self.in_scope(path, judge_own=judge_own)] def _diff_text(self, path: str) -> str: """`path`'s `-U0` diff as git writes it, unconverted; raises DiffError when git fails.""" @@ -221,11 +225,11 @@ def __init__( self._added = dict(added or {}) self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: """Every write is a present file, so a filter asking only for deletions finds none.""" if not set(diff_filter) & set("ACMRT"): return [] - return [path for path in self._writes if self.in_scope(path)] + return [path for path in self._writes if self.in_scope(path, judge_own=judge_own)] def staged_blob(self, path: str) -> str: return self._writes.get(path, "") diff --git a/src/chock/gate/runner/kinds.py b/src/chock/gate/runner/kinds.py index 280adb5b..7cc9e1d0 100644 --- a/src/chock/gate/runner/kinds.py +++ b/src/chock/gate/runner/kinds.py @@ -49,14 +49,15 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul pragma = _waiver_pattern(params, event) agent = event in HEAD_WAIVER_EVENTS whole = params.get("scan", "added_lines") == "staged_blob" - paths = ctx.staged_paths(params.get("diff_filter", "ACMRT")) + paths = ctx.staged_paths(params.get("diff_filter", "ACMRT"), judge_own=True) judge = pattern_judge() try: forbidden = sorted(_hits(judge, [(params.get("forbidden_path_regex"), paths)])[0]) heads = {path: ctx.committed_blob(path) if agent else "" for path in paths} blobs = [heads[paths[index]] if agent else ctx.staged_blob(paths[index]) for index in forbidden] spans, texts = _lines( - paths, lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path) + [path for path in paths if not ctx.is_own(path)], + lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path), ) jobs = [(pragma, blobs), (pragma, texts), (params["content_pattern"], texts)] blob_waivers, line_waivers, hits = _hits(judge, jobs) @@ -67,7 +68,7 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul if index in forbidden and forbidden.index(index) not in blob_waivers: matches.append(f"{path}: forbidden path") head = frozenset(heads[path].splitlines()) if agent else None - if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans[path]): + if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans.get(path, ())): matches.append(f"{path}: content pattern") return GateResult(allowed=not matches, matches=matches) diff --git a/src/chock/gate/runtime_shared.py b/src/chock/gate/runtime_shared.py index 9b1aef42..a00c3960 100644 --- a/src/chock/gate/runtime_shared.py +++ b/src/chock/gate/runtime_shared.py @@ -20,6 +20,7 @@ outside_repo, patch_apply, patch_image, + repo_names, repo_stop, session_log, stop_reentry, @@ -27,6 +28,7 @@ tool_call_gate, unreadable_stop, workspace_root, + worktree_read, write_gate, ) from .runner import chain, halt, journal @@ -103,6 +105,8 @@ def _extract(module) -> str: stop_reentry, unreadable_stop, workspace_root, + repo_names, + worktree_read, repo_stop, stop_roots, write_gate, diff --git a/src/chock/gate/stop_roots.py b/src/chock/gate/stop_roots.py index 0c5fc294..320cbe03 100644 --- a/src/chock/gate/stop_roots.py +++ b/src/chock/gate/stop_roots.py @@ -13,19 +13,31 @@ _GIT_DIR = ".git" -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = Path(named) + return (repository_of(path) or path) if path.is_absolute() else path + + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, "cwd", None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] diff --git a/src/chock/gate/worktree_read.py b/src/chock/gate/worktree_read.py new file mode 100644 index 00000000..6ba2baad --- /dev/null +++ b/src/chock/gate/worktree_read.py @@ -0,0 +1,45 @@ +"""Reading a changed worktree file without following a link or waiting on a pipe. + +Extracted into the vendored per-agent runtime beside write_gate, so it is stdlib-only and may not import chock. +""" + +from __future__ import annotations + +import os + +from .stop_reentry import STOP_MODE_DIR, STOP_MODE_FILE, STOP_MODE_MASK + +#: st_mode type bits (`stat` is not importable in the vendored runtime): a link is read as git stores it. +_MODE_LINK = 0o120000 + + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return os.readlink(target), None + if kind != STOP_MODE_FILE: + return None, f"{target} is neither a file nor a link" + fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(fd, "rb") as handle: + return handle.read().decode("utf-8"), None + except UnicodeDecodeError: + return None, None + except OSError as exc: + return None, f"{target} could not be read ({exc})" + + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return "", None + except OSError as exc: + return None, f"{target} could not be examined ({exc})" + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index e1662388..5e699206 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -12,10 +12,9 @@ from __future__ import annotations import json -import os import subprocess import sys -from pathlib import Path, PurePosixPath, PureWindowsPath +from pathlib import Path from .budget import allowed_seconds, engine_deadline, time_left from .decision_log import GATE_LAST @@ -24,10 +23,12 @@ from .gate_outcome import GATE_ERRORED, gate_decision, runner_outcome from .outside_repo import judged_files, outside_globs from .patch_image import patch_added, patched_files +from .repo_names import repo_paths from .session_log import session_for from .stop_reentry import settle_stop -from .stop_roots import per_root, stop_roots -from .workspace_root import _DRIVE_COLON, _folded, workspace_root +from .stop_roots import judged_root, per_root, stop_roots +from .workspace_root import workspace_root +from .worktree_read import worktree_text GATE_FLAG = "--gate" #: A plugin's Stop hook shares the pre-tool `gate.json`; this flag tells the runtime which event it serves. @@ -98,39 +99,8 @@ def writes_from_event(event, root=None): return {str(path): content} -def repo_relative(path, root): - """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" - text = str(path) - if root is None: - return text - windows = os.name == "nt" or str(root)[1:2] == _DRIVE_COLON - flavour = PureWindowsPath if windows else PurePosixPath - try: - return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() - except ValueError: - pass - if windows != (os.name == "nt"): - return text - try: - return Path(root, text).resolve().relative_to(Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return text - - -def repo_paths(path, root): - """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" - lexical = repo_relative(path, root) - if root is None or (str(root)[1:2] == _DRIVE_COLON) != (os.name == "nt"): - return (lexical,) - try: - resolved = Path(root, str(path)).resolve().relative_to(Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return (lexical,) - return tuple(dict.fromkeys((lexical, resolved))) - - def changed_paths(repo_root, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = engine_deadline() if deadline is None else deadline, [] if why is None else why argv = [_GIT, "-C", str(repo_root), "status", "--porcelain=v1", "--untracked-files=all", "-z"] try: @@ -156,8 +126,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = field[:2], field[3:] if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, "")) + if path: paths.append(path) return paths @@ -174,10 +144,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = Path(repo_root, path).read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f"chock: {unsafe}, worktree not checked\n") + return None + if text is not None: + writes[path] = text return writes @@ -233,7 +207,7 @@ def root_for(gate): def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -241,7 +215,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, "cwd", None) or workspace_root(event) - return Path(cwd) if cwd else Path.cwd() + return judged_root(cwd) if cwd else Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): diff --git a/src/chock/gateway/gates.py b/src/chock/gateway/gates.py index d748931f..e8d7518d 100644 --- a/src/chock/gateway/gates.py +++ b/src/chock/gateway/gates.py @@ -11,6 +11,7 @@ from urllib.parse import unquote from chock.gate.session_log import SESSION_PATH_KEYS +from chock.gate.write_gate import repo_paths GATEWAY_DIRNAME = "mcp-gateway" GATE_FILENAME = "gateway-gate.json" @@ -95,20 +96,21 @@ def _emit(authority: str) -> Iterator[str]: def _call_paths(spec: dict[str, Any], arguments: Any) -> list[str]: - """Write targets the call names, repo-relative: the same argument keys the session log reads.""" + """Write targets the call names, under every spelling `write_gate.repo_paths` judges (folded, resolved, cased).""" if not isinstance(arguments, dict): return [] - root = str(spec.get("repo_root") or "").replace("\\", "/").rstrip("/") + root = str(spec.get("repo_root") or "") or None paths: list[str] = [] for key in SESSION_PATH_KEYS: value = arguments.get(key) - if not isinstance(value, str) or not value: - continue - path = value.replace("\\", "/") - if root and path.startswith(root + "/"): - path = path[len(root) + 1 :] - paths.append(path.removeprefix("./")) - return paths + if isinstance(value, str) and value: + paths.extend(repo_paths(value.replace("\\", "/"), root)) + return list(dict.fromkeys(paths)) + + +def _in_scope(path: str, scope: list[str]) -> bool: + """A path is in a gate's bound when a glob matches it, or when it cannot be placed inside the repository.""" + return ".." in path.split("/") or any(fnmatch.fnmatchcase(path, g) for g in scope) def _eval_content_regex(spec: dict[str, Any], arguments: Any) -> str | None: @@ -121,7 +123,7 @@ def _eval_content_regex(spec: dict[str, Any], arguments: Any) -> str | None: paths = _call_paths(spec, arguments) if scope: # A path-bounded gate judges only what it can place inside its bound. - paths = [p for p in paths if any(fnmatch.fnmatchcase(p, g) for g in scope)] + paths = [p for p in paths if _in_scope(p, scope)] if not paths: return None forbidden = params.get("forbidden_path_regex") diff --git a/src/chock/guardrails/chock_bundle_log.py b/src/chock/guardrails/chock_bundle_log.py index eae42724..89028234 100644 --- a/src/chock/guardrails/chock_bundle_log.py +++ b/src/chock/guardrails/chock_bundle_log.py @@ -1,7 +1,8 @@ -"""The toggle wrapper's log line. Stdlib only: shipped beside the wrapper, with the gate's journal, in every merged plugin.""" +"""The toggle wrapper's log line and HEAD lookup. Stdlib only: shipped beside the wrapper in every merged plugin.""" from __future__ import annotations +import subprocess from pathlib import Path try: # beside the wrapper in a merged plugin; inside the chock package everywhere else @@ -10,6 +11,25 @@ from chock.gate.runner.journal import LOG_INFO, LOG_OFF, log_append, log_settings _LOG_PARTS = ("log", "gate-events.jsonl") +_GIT_SECONDS = 5 + + +def head_text(root: Path, name: str) -> str | None: + """`name` as HEAD of the repository at `root` holds it; None when HEAD has no such file or git fails.""" + argv = ["git", "-C", str(root), "show", f"HEAD:{name}"] + try: + proc = subprocess.run(argv, capture_output=True, text=True, timeout=_GIT_SECONDS, check=False) # noqa: S603 + except (OSError, subprocess.SubprocessError): + return None + return proc.stdout if proc.returncode == 0 else None + + +def vouched(toggles: dict[str, dict[str, str]], head: dict[str, dict[str, str]]) -> tuple[dict, list[str]]: + """(`toggles` keeping only the offs `head` also has, the `bundle/member` offs it dropped).""" + dropped = [ + f"{b}/{m}" for b, ms in toggles.items() for m, s in ms.items() if s == "off" and head.get(b, {}).get(m) != "off" + ] + return {b: {m: s for m, s in ms.items() if f"{b}/{m}" not in dropped} for b, ms in toggles.items()}, dropped def _repo(start: Path) -> Path | None: diff --git a/src/chock/guardrails/cli.py b/src/chock/guardrails/cli.py index 88dfd62a..5cd79c32 100644 --- a/src/chock/guardrails/cli.py +++ b/src/chock/guardrails/cli.py @@ -140,7 +140,8 @@ def switch(policy_id: str, state: str, bundle: str | None, scope: str | None) -> if scope == toggle.USER and root is not None and os.path.lexists(root / toggle.FILENAME): print(f"note: {root / toggle.FILENAME} governs this repository; the user file applies only outside it.") if scope == toggle.REPO and state == toggle.OFF: - print("Commit it: `chock check --only baseline` reports a policy switched off against the base as a loosening.") + print("Commit it: a policy is off in a repository only once HEAD's copy says so, and") + print("`chock check --only baseline` reports a policy switched off against the base as a loosening.") return 0 diff --git a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py index ebc9f382..410f1adb 100644 --- a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py +++ b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py @@ -8,15 +8,14 @@ from __future__ import annotations -import fnmatch import itertools -import os -import posixpath import re import shlex from collections.abc import Callable from pathlib import Path +from .names import base_is, fold, real_path + NAME = "guardrails.json" #: The sha256 `chock bundle` records of the file it wrote, under `.chock/state/`: protected as the file is. RECORD = "guardrails.sha256" @@ -74,88 +73,48 @@ FOLDER_WRITERS = frozenset({"mv", "cp", "ln", "rsync", "install", "tar", "unzip", "ditto", "move-item", "copy-item"}) _SEPARATORS = frozenset({";", "&", "&&", "|", "||", "|&", "(", ")", ";;"}) -_GLOB = re.compile(r"[*?\[]") -_BRACES = re.compile(r"\{([^{}]*)\}") _ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\+?=") _EXE = ".exe" -def fold(path: str) -> str: - """`path` lower-cased, backslashes read as slashes, `//`, `/./` and `..` folded.""" - text = path.replace("\\", "/").lower() - if not text: - return text - return posixpath.normpath(re.sub(r"/{2,}", "/", text)) - - -def _braces(text: str) -> list[str]: - """Every spelling a brace list stands for (`{a,b}`), at most a few dozen.""" - found, todo = [], [text] - while todo and len(found) < 64: # noqa: PLR2004 -- a bound on expansion, not a rule - item = todo.pop() - match = _BRACES.search(item) - if match is None: - found.append(item) - continue - todo += [item[: match.start()] + part + item[match.end() :] for part in match.group(1).split(",")] - return found - - -def _base_is(text: str, wanted: str) -> bool: - """Whether the last segment of a folded path is `wanted`, or a glob or brace list that can name it.""" - for spelling in _braces(text): - base = spelling.rstrip("/").rsplit("/", 1)[-1] - if base == wanted or (_GLOB.search(base) and fnmatch.fnmatchcase(wanted, base)): - return True - return False - - -def _real(token: str, cwd: Path) -> str: - """The folded path `token` names once `~` is expanded and links are followed, from `cwd`.""" - try: - return fold(os.path.realpath(os.path.join(cwd, os.path.expanduser(token)))) - except (OSError, ValueError): - return "" - - def names_toggle(token: str, cwd: Path) -> bool: """Whether a shell word can name a toggle file or its record: as written, folded, or through a link.""" - folded, real = fold(token), _real(token, cwd) - return any(name in folded or _base_is(folded, name) or _base_is(real, name) for name in NAMES) + folded, real = fold(token), real_path(token, cwd) + return any(name in folded or base_is(folded, name) or base_is(real, name) for name in NAMES) def names_folder(token: str, cwd: Path) -> bool: """Whether a shell word names the `.chock` folder itself.""" - return _base_is(fold(token), FOLDER) or _base_is(_real(token, cwd), FOLDER) + return base_is(fold(token), FOLDER) or base_is(real_path(token, cwd), FOLDER) def names_marker(token: str, cwd: Path) -> bool: """Whether a shell word can name an install marker: as written, folded, or through a link.""" folded = fold(token) - return MARKER in folded or _base_is(folded, MARKER) or _base_is(_real(token, cwd), MARKER) + return MARKER in folded or base_is(folded, MARKER) or base_is(real_path(token, cwd), MARKER) def names_log(token: str, cwd: Path) -> bool: """Whether a shell word can name a log settings file: as written, folded, or through a link.""" folded = fold(token) - return LOG_SETTINGS in folded or _base_is(folded, LOG_SETTINGS) or _base_is(_real(token, cwd), LOG_SETTINGS) + return LOG_SETTINGS in folded or base_is(folded, LOG_SETTINGS) or base_is(real_path(token, cwd), LOG_SETTINGS) def is_log_path(path: str, root: Path) -> bool: """Whether a written path is a log settings file (in any `.chock` folder), as written or through links.""" - return any(("/" + folded).endswith(f"/{FOLDER}/{LOG_SETTINGS}") for folded in (fold(path), _real(path, root))) + return any(("/" + folded).endswith(f"/{FOLDER}/{LOG_SETTINGS}") for folded in (fold(path), real_path(path, root))) def is_marker_path(path: str, root: Path) -> bool: """Whether a written path is an install marker, as written or through links.""" - return any(_base_is(folded, MARKER) for folded in (fold(path), _real(path, root))) + return any(base_is(folded, MARKER) for folded in (fold(path), real_path(path, root))) def is_toggle_path(path: str, root: Path) -> bool: """Whether a written path is a toggle file or its record (anywhere), as written or through links.""" return any( folded == want or folded.endswith("/" + want) - for folded in (fold(path), _real(path, root)) + for folded in (fold(path), real_path(path, root)) for want in PROTECTED ) @@ -255,6 +214,23 @@ def _segment_refused(words: list[str], cwd: Path) -> bool: return _writes(words, cwd, names_toggle) +#: Text that a name fragment held in a variable or an escape could complete into a protected path. +_EXPANDED = re.compile(r"\$|`") +#: Fragments a protected name can be assembled from; a write that expands a variable next to one is refused. +_FRAGMENTS = ("guard", "rails", "sha256") + + +def _expanded_write(words: list[str], command: str) -> bool: + """Whether a segment writes through a path word built by expansion, on a line that mentions a protected fragment.""" + name, _ = _command(words) + plain, targets = _split_redirects(words) + written = targets + ([] if name in READERS else plain) + if not any(_EXPANDED.search(word) for word in written): + return False + lowered = fold(command) + return any(fragment in lowered for fragment in _FRAGMENTS) + + def _hidden(command: str, segments: list[list[str]], wanted: list[str]) -> bool: """Whether a line that names every `wanted` text also runs other text it could hide them in.""" lowered = fold(command) @@ -276,7 +252,7 @@ def install_refused(command: str, cwd: Path) -> bool: def refuses(command: str, cwd: Path) -> bool: """Whether an agent's shell command could write a toggle file or switch a member.""" segments = _segments(_tokens(command)) - if any(_segment_refused(words, cwd) for words in segments): + if any(_segment_refused(words, cwd) or _expanded_write(words, command) for words in segments): return True named = any(name in fold(command) for name in NAMES) or any(names_toggle(t, cwd) for s in segments for t in s) if not named: diff --git a/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py new file mode 100644 index 00000000..4294cc33 --- /dev/null +++ b/src/chock/guardrails/data/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py @@ -0,0 +1,50 @@ +"""Path folding for the self-protection guard: case, slashes, dots, braces, globs and links (stdlib only).""" + +from __future__ import annotations + +import fnmatch +import os +import posixpath +import re +from pathlib import Path + +_GLOB = re.compile(r"[*?\[]") +_BRACES = re.compile(r"\{([^{}]*)\}") + + +def fold(path: str) -> str: + """`path` lower-cased, backslashes read as slashes, `//`, `/./` and `..` folded.""" + text = path.replace("\\", "/").lower() + if not text: + return text + return posixpath.normpath(re.sub(r"/{2,}", "/", text)) + + +def braces(text: str) -> list[str]: + """Every spelling a brace list stands for (`{a,b}`), at most a few dozen.""" + found, todo = [], [text] + while todo and len(found) < 64: # noqa: PLR2004 -- a bound on expansion, not a rule + item = todo.pop() + match = _BRACES.search(item) + if match is None: + found.append(item) + continue + todo += [item[: match.start()] + part + item[match.end() :] for part in match.group(1).split(",")] + return found + + +def base_is(text: str, wanted: str) -> bool: + """Whether the last segment of a folded path is `wanted`, or a glob or brace list that can name it.""" + for spelling in braces(text): + base = spelling.rstrip("/").rsplit("/", 1)[-1] + if base == wanted or (_GLOB.search(base) and fnmatch.fnmatchcase(wanted, base)): + return True + return False + + +def real_path(token: str, cwd: Path) -> str: + """The folded path `token` names once `~` is expanded and links are followed, from `cwd`.""" + try: + return fold(os.path.realpath(os.path.join(cwd, os.path.expanduser(token)))) + except (OSError, ValueError): + return "" diff --git a/src/chock/guardrails/toggle.py b/src/chock/guardrails/toggle.py index 3171cba8..8d8f3414 100644 --- a/src/chock/guardrails/toggle.py +++ b/src/chock/guardrails/toggle.py @@ -1,6 +1,7 @@ """The guardrails toggle file and its lookup. Stdlib only: shipped verbatim in every merged plugin as its hook wrapper. `.chock/guardrails.json` (repo; else `~/.chock/guardrails.json`) is all on when absent, invalid or unlike its record. +In a repository a member is off only where HEAD's copy says so too: the record is as rewritable as the file. `--bundle --member [args...]`: off runs the adapter with no args. `--verify `: a Stop is refused while a toggle file in its `cwd` or any workspace root loosened. """ @@ -19,9 +20,9 @@ from pathlib import Path try: # beside this wrapper in a merged plugin; inside the chock package everywhere else - from chock_bundle_log import log_off + from chock_bundle_log import head_text, log_off, vouched except ImportError: - from chock.guardrails.chock_bundle_log import log_off + from chock.guardrails.chock_bundle_log import head_text, log_off, vouched FILENAME = ".chock/guardrails.json" VERSION = 1 @@ -39,6 +40,7 @@ "guardrails.json differs from the last `chock bundle` change; every guardrail stays on until the person " "re-applies it or runs `chock bundle status --adopt`" ) +UNCOMMITTED = "is off in the working file but not in HEAD's, so it stays on until that change is committed" _VERDICT_DENY = "deny" _BLOCKING_EXIT = 2 # the launcher's refusal too: the wrapper speaks no client's dialect @@ -115,6 +117,14 @@ def read(path: Path) -> dict[str, dict[str, str]]: return parse(text) +def committed(root: Path) -> dict[str, dict[str, str]]: + """The toggles HEAD holds for the repository at `root`; none when HEAD has no valid copy or git fails.""" + try: + return parse(head_text(root, FILENAME) or "") + except ToggleError: + return {} + + def load(start: Path) -> tuple[Path | None, str | None, dict[str, dict[str, str]], str | None]: """(governing file, scope, toggles, warning). An invalid file yields no toggles: all on, plus the warning.""" path, scope = source(start) @@ -123,9 +133,11 @@ def load(start: Path) -> tuple[Path | None, str | None, dict[str, dict[str, str] if drift(path): return path, scope, {}, f"{path}: {DRIFTED}" try: - return path, scope, read(path), None + toggles = read(path) except ToggleError as exc: return path, scope, {}, f"{path} is ignored, every guardrail stays on: {exc}" + toggles, dropped = vouched(toggles, committed(path.parent.parent)) if scope == REPO else (toggles, []) + return path, scope, toggles, f"{path}: {', '.join(dropped)} {UNCOMMITTED}" if dropped else None def state(toggles: dict[str, dict[str, str]], bundle: str, member: str) -> str: diff --git a/tests/fixtures/plugin_targets/antigravity.json b/tests/fixtures/plugin_targets/antigravity.json index 313a3d70..cb7f70e2 100644 --- a/tests/fixtures/plugin_targets/antigravity.json +++ b/tests/fixtures/plugin_targets/antigravity.json @@ -7,11 +7,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/droid.json b/tests/fixtures/plugin_targets/droid.json index fa03d105..24ad5ac1 100644 --- a/tests/fixtures/plugin_targets/droid.json +++ b/tests/fixtures/plugin_targets/droid.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/gemini.json b/tests/fixtures/plugin_targets/gemini.json index 2acf66d3..5db1b88e 100644 --- a/tests/fixtures/plugin_targets/gemini.json +++ b/tests/fixtures/plugin_targets/gemini.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/goose.json b/tests/fixtures/plugin_targets/goose.json index 18ad8b31..624559cf 100644 --- a/tests/fixtures/plugin_targets/goose.json +++ b/tests/fixtures/plugin_targets/goose.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/grok.json b/tests/fixtures/plugin_targets/grok.json index fb512bcd..08412ded 100644 --- a/tests/fixtures/plugin_targets/grok.json +++ b/tests/fixtures/plugin_targets/grok.json @@ -5,6 +5,7 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/kimi.json b/tests/fixtures/plugin_targets/kimi.json index ee6580cc..24e33a54 100644 --- a/tests/fixtures/plugin_targets/kimi.json +++ b/tests/fixtures/plugin_targets/kimi.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/openhands.json b/tests/fixtures/plugin_targets/openhands.json index 98465b64..ff2bdda0 100644 --- a/tests/fixtures/plugin_targets/openhands.json +++ b/tests/fixtures/plugin_targets/openhands.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 336e74a1..6d50dfbf 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -1098,6 +1098,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1159,12 +1161,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1465,7 +1471,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1477,7 +1487,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1485,7 +1495,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2077,6 +2087,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2203,18 +2292,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2292,37 +2392,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -def repo_relative(path, root): - """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" - text = str(path) - if root is None: - return text - windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON - flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath - try: - return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() - except ValueError: - pass - if windows != (_chock_os.name == 'nt'): - return text - try: - return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return text - -def repo_paths(path, root): - """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" - lexical = repo_relative(path, root) - if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): - return (lexical,) - try: - resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return (lexical,) - return tuple(dict.fromkeys((lexical, resolved))) - def changed_paths(repo_root, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2346,8 +2417,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2363,10 +2434,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2405,7 +2480,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2413,7 +2488,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 8ec79325..21b68be6 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index ee95433b..7429bc33 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9f24d0d4..6a9bca35 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index ef075ec6..6d07feae 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 627c4116..419fce76 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3af4298f..3cf36a48 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2990ff7e..1e4048e1 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index c1b03e5a..5c22c5ed 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 16b86489..1e2fcc0f 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index 8a6bcaa7..b0468f34 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 4343f5b2..3ba3e772 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index d513535a..c4847fa5 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index ab04974d..1f873646 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index ee68c3b5..0975fa33 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/tests/guardrails_support.py b/tests/guardrails_support.py index 1935d601..9f6038c9 100644 --- a/tests/guardrails_support.py +++ b/tests/guardrails_support.py @@ -4,6 +4,7 @@ import json import os +import subprocess from pathlib import Path from typing import Any @@ -78,12 +79,30 @@ def any_refused(commands: list[str], plugin: Path, repo: Path, payload: dict, ho return any(refused(p) for p in outcomes(commands, plugin, repo, payload, home)) -def set_toggles(where: Path, bundles: dict[str, dict[str, str]] | str, *, recorded: bool = True) -> Path: - """Write a toggle file at `where/.chock/guardrails.json` (a string as is), recorded as `chock bundle` records it.""" +def commit_toggles(where: Path) -> None: + """Commit the toggle file in the repository at `where`, as a person does: a member is off only once HEAD says so.""" + git = ["git", "-C", str(where), "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"] + if subprocess.run([*git, "rev-parse", "--git-dir"], capture_output=True, check=False).returncode: + return + subprocess.run([*git, "add", "-f", TOGGLE], check=True, capture_output=True) + subprocess.run( + [*git, "commit", "-q", "--no-verify", "-m", "toggles", "--allow-empty"], check=True, capture_output=True + ) + + +def set_toggles( + where: Path, bundles: dict[str, dict[str, str]] | str, *, recorded: bool = True, committed: bool = True +) -> Path: + """Write a toggle file at `where/.chock/guardrails.json` (a string as is), recorded as `chock bundle` records it. + + In a git repository it is also committed, unless `committed=False`. + """ path = where / TOGGLE path.parent.mkdir(parents=True, exist_ok=True) text = bundles if isinstance(bundles, str) else json.dumps({"version": 1, "bundles": bundles}) path.write_text(text, encoding="utf-8") if recorded: record(path) + if committed and (where / ".git").exists(): + commit_toggles(where) return path diff --git a/tests/test_gate_write_path.py b/tests/test_gate_write_path.py index f6537ab9..40d26337 100644 --- a/tests/test_gate_write_path.py +++ b/tests/test_gate_write_path.py @@ -196,3 +196,28 @@ def test_unreadable_stdin_judges_nothing_rather_than_guessing(tmp_path: Path, ga init_repo(tmp_path) result = _cli(gate_py, _gate(tmp_path), "pre-tool-use", {}, tmp_path) assert result.returncode == 0 + + +# --- a policy's gate judges the paths of its own folders ------------------------------------------ + + +def _own_gate(tmp_path: Path, params: dict) -> Path: + gate = tmp_path / ".chock" / "compiled" / "protect-it" / "pre-tool-use" / "gate.json" + gate.parent.mkdir(parents=True) + spec = {"kind": "content_regex", "on": ["tool_use"], "action": "block", "message": "no", "params": params} + gate.write_text(json.dumps(spec), encoding="utf-8") + return gate + + +def test_a_path_gate_refuses_a_write_into_its_own_compiled_folder(tmp_path: Path) -> None: + init_repo(tmp_path) + gate = _own_gate(tmp_path, {"content_pattern": "(?!)", "forbidden_path_regex": r"\.chock[/\\]compiled"}) + own = ".chock/compiled/protect-it/pre-tool-use/gate.json" + assert run(gate, "pre-tool-use", None, tmp_path, writes={own: "{}"}) == 1 + + +def test_a_policys_own_folder_is_still_not_scanned_for_its_content_pattern(tmp_path: Path) -> None: + init_repo(tmp_path) + gate = _own_gate(tmp_path, {"content_pattern": r"AKIA[0-9A-Z]{16}"}) + own = ".agents/policies/protect-it/evals/fixture.txt" + assert run(gate, "pre-tool-use", None, tmp_path, writes={own: SECRET}) == 0 diff --git a/tests/test_guardrails_cli.py b/tests/test_guardrails_cli.py index 08a5da2d..a44d67e1 100644 --- a/tests/test_guardrails_cli.py +++ b/tests/test_guardrails_cli.py @@ -8,6 +8,7 @@ import pytest from conftest import init_repo +from guardrails_support import commit_toggles from chock.guardrails import cli, toggle from chock.guardrails.plugin import MEMBERS @@ -53,6 +54,7 @@ def test_off_then_on_round_trips_at_repo_scope(repo: Path, capsys: pytest.Captur assert cli.main(["off", P]) == 0 assert _doc(repo / toggle.FILENAME) == {"version": 1, "bundles": {B: {P: "off"}}} assert "check --only baseline" in capsys.readouterr().out + commit_toggles(repo) assert cli.main(["status"]) == 0 out = capsys.readouterr().out assert re.search(rf"{P} +off blocks: refuses a destructive command", out) @@ -178,4 +180,19 @@ def test_reset_needs_adopt_and_adopt_still_takes_a_valid_file(repo: Path) -> Non path.parent.mkdir() path.write_text(json.dumps({"version": 1, "bundles": {B: {P: "off"}}}), encoding="utf-8") assert cli.main(["status", "--adopt"]) == 0 + commit_toggles(repo) assert toggle.drift(path) is None and toggle.load(repo)[2] == {B: {P: "off"}} + + +def test_an_off_only_in_the_working_file_stays_on_until_committed(repo: Path) -> None: + """A record the agent can rewrite is not proof a person wrote the file: HEAD's copy is.""" + assert cli.main(["off", P]) == 0 + _path, _scope, toggles, warning = toggle.load(repo) + assert toggle.state(toggles, B, P) == toggle.ON and P in warning and "committed" in warning + commit_toggles(repo) + assert toggle.load(repo)[2] == {B: {P: "off"}} + path = repo / toggle.FILENAME + forged = {"version": 1, "bundles": {B: {P: "off", Q: "off"}}} + path.write_text(json.dumps(forged), encoding="utf-8") + cli.record(path) + assert toggle.load(repo)[2] == {B: {P: "off"}} diff --git a/tests/test_guardrails_protect.py b/tests/test_guardrails_protect.py index 079a1329..42b86932 100644 --- a/tests/test_guardrails_protect.py +++ b/tests/test_guardrails_protect.py @@ -61,6 +61,10 @@ "sha256sum .chock/guardrails.json | cut -c1-64 > ~/.chock/state/GUARDRAILS.sha256", "rm .chock/state/guardrails.sha256", "chock bundle status --adopt", + # a name put together by expansion + "n=rails; cp /tmp/x .chock/guard${n}.json", + "cp /tmp/x .chock/$'guard\\x72ails'.json", + "printf x > .chock/state/${h}.sha256", ] READS = [ "cat .chock/guardrails.json", @@ -74,6 +78,7 @@ "cat .chock/state/guardrails.sha256", "echo x > notes.md", "ls -la", + 'mkdir -p "$TMPDIR/build" && cp a.txt "$TMPDIR/build/b.txt"', ] diff --git a/tests/test_guardrails_turn_end.py b/tests/test_guardrails_turn_end.py index 0167caf2..20c64ea5 100644 --- a/tests/test_guardrails_turn_end.py +++ b/tests/test_guardrails_turn_end.py @@ -9,7 +9,7 @@ import client_payloads import pytest from bundle_fixtures import ALL_ROOTS, BUNDLE_ID, GUARD_ID -from conftest import run_hook_command +from conftest import init_repo, run_hook_command from guardrails_support import MERGED_CLIENTS, TOGGLE, any_refused, build, outcomes, refused, set_toggles, shell from chock.guardrails import cli, toggle @@ -244,8 +244,8 @@ def test_the_user_file_is_checked_too(tmp_path: Path, home: Path, monkeypatch: p def test_a_file_that_differs_from_its_record_switches_nothing_off(tmp_path: Path, home: Path) -> None: - repo = tmp_path / "r" - (repo / ".git").mkdir(parents=True) + (tmp_path / "r").mkdir() + repo = init_repo(tmp_path / "r") set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}, recorded=False) _path, _scope, toggles, warning = toggle.load(repo) assert toggles == {} and toggle.DRIFTED in (warning or "") diff --git a/tests/test_mcp_gateway_scope.py b/tests/test_mcp_gateway_scope.py index 58d6a268..cc779858 100644 --- a/tests/test_mcp_gateway_scope.py +++ b/tests/test_mcp_gateway_scope.py @@ -90,3 +90,39 @@ def test_forbidden_path_regex_is_bounded_by_scope(): def test_pragma_on_the_line_does_not_waive_at_the_gateway_even_when_in_scope(): args = {"path": ".github/workflows/ci.yml", "content": "uses: a/b@v4 # pragma: allow"} assert gateway_gates.evaluate(_scoped(), "write_file", args) is not None + + +def _protect(repo_root: str) -> list[dict]: + params = {"content_pattern": "(?!)", "forbidden_path_regex": r"\.chock[/\\](bin|compiled)|\.claude[/\\]settings"} + return [{**_gate("content_regex", params), "repo_root": repo_root}] + + +def test_a_path_spelled_with_dots_or_doubled_slashes_is_still_forbidden(tmp_path): + gates = _protect(str(tmp_path)) + for spelled in ( + ".chock/./bin/gate.py", + ".claude//settings.json", + "docs/../.chock/bin/gate.py", + f"{tmp_path}/.chock/./bin/x", + ): + assert gateway_gates.evaluate(gates, "write_file", {"path": spelled, "content": "x"}) is not None, spelled + + +def test_a_forbidden_path_reached_through_a_symlink_is_still_forbidden(tmp_path): + (tmp_path / ".chock" / "bin").mkdir(parents=True) + (tmp_path / "alias").symlink_to(tmp_path / ".chock" / "bin") + args = {"path": "alias/gate.py", "content": "x"} + assert gateway_gates.evaluate(_protect(str(tmp_path)), "write_file", args) is not None + + +def test_a_scoped_gate_sees_an_equivalent_spelling_of_an_in_scope_path(tmp_path): + gates = [{**_scoped()[0], "repo_root": str(tmp_path)}] + for spelled in ("docs/../.github/workflows/ci.yml", ".//.github/workflows/ci.yml", ".github//workflows/ci.yml"): + args = {"path": spelled, "content": "- uses: a/b@v4"} + assert gateway_gates.evaluate(gates, "write_file", args) is not None, spelled + + +def test_a_scoped_gate_judges_a_path_that_climbs_out_of_the_repository(tmp_path): + gates = [{**_scoped()[0], "repo_root": str(tmp_path)}] + args = {"path": "../elsewhere/../../x", "content": "- uses: a/b@v4"} + assert gateway_gates.evaluate(gates, "write_file", args) is not None diff --git a/tests/test_person_only_commands.py b/tests/test_person_only_commands.py index e5866346..5e577a11 100644 --- a/tests/test_person_only_commands.py +++ b/tests/test_person_only_commands.py @@ -100,6 +100,26 @@ def test_reading_commands_stay_open_to_an_agent( assert REFUSED not in capsys.readouterr().err +@pytest.mark.parametrize( + "argv", + [ + ["disable", "--", "--check"], + ["remove", "--", "--check"], + ["init", "--", "--check"], + ["install-hooks", "--check"], + ["sync", "--", "--check"], + ["enable", "--check", POLICY], + ], +) +def test_a_check_token_that_is_not_the_flag_does_not_open_the_command( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], argv: list[str] +) -> None: + """`--check` after `--`, or on a command that has no such flag, is a positional value.""" + monkeypatch.setenv("CLAUDECODE", "1") + assert cli.main([*argv, "--repo", str(repo)]) == 1 + assert REFUSED in capsys.readouterr().err + + def test_a_persons_shell_still_runs_them(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: """No vendor marker, with or without `CHOCK_AGENT_COMMIT=0`: the person's own shell.""" monkeypatch.setenv("CHOCK_AGENT_COMMIT", "0") diff --git a/tests/test_write_gate_handler.py b/tests/test_write_gate_handler.py index 3964bf25..29d26b6c 100644 --- a/tests/test_write_gate_handler.py +++ b/tests/test_write_gate_handler.py @@ -7,13 +7,14 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path from types import SimpleNamespace from conftest import init_repo -from chock.gate import write_gate +from chock.gate import repo_names, write_gate from chock.gate.assemble import runner_source from chock.gate.stop_reentry import REENTRY_CAP @@ -97,13 +98,66 @@ def test_a_stop_hook_already_active_still_reads_the_worktree(tmp_path: Path) -> assert write_gate.writes_for(_event("stop", raw={"stop_hook_active": True}), gate)["app.py"] == SECRET -def test_a_deletion_leaves_no_content_to_judge(tmp_path: Path) -> None: +def test_a_deletion_is_judged_by_path_with_no_content(tmp_path: Path) -> None: init_repo(tmp_path) (tmp_path / "gone.py").write_text("x = 1\n", encoding="utf-8") subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True, capture_output=True) subprocess.run(["git", "-C", str(tmp_path), "commit", "-m", "add"], check=True, capture_output=True) (tmp_path / "gone.py").unlink() - assert "gone.py" not in write_gate.writes_from_worktree(tmp_path) + assert write_gate.writes_from_worktree(tmp_path)["gone.py"] == "" + + +def test_a_rename_judges_both_names(tmp_path: Path) -> None: + init_repo(tmp_path) + (tmp_path / "old.py").write_text("x = 1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-m", "add"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(tmp_path), "mv", "old.py", "new.py"], check=True, capture_output=True) + assert write_gate.writes_from_worktree(tmp_path) == {"old.py": "", "new.py": "x = 1\n"} + + +def test_a_fifo_in_the_worktree_is_refused_not_read(tmp_path: Path) -> None: + """Opening a FIFO blocks the hook until its client times out, which a client may read as an allow.""" + os.mkfifo(tmp_path / "pipe") + text, why = write_gate.worktree_text(tmp_path / "pipe") + assert text is None + assert "neither a file nor a link" in why + + +def test_a_symlink_to_a_fifo_is_judged_as_a_link_and_never_opened(tmp_path: Path) -> None: + init_repo(tmp_path) + os.mkfifo(tmp_path / "pipe") + (tmp_path / "pipe").rename(tmp_path / ".pipe") + (tmp_path / "link").symlink_to(".pipe") + assert write_gate.writes_from_worktree(tmp_path)["link"] == ".pipe" + + +def test_a_stop_in_a_subdirectory_reads_the_worktree_from_the_top_level(tmp_path: Path) -> None: + init_repo(tmp_path) + (tmp_path / "sub").mkdir() + (tmp_path / "app.py").write_text(SECRET, encoding="utf-8") + event = _event("stop", raw={"cwd": str(tmp_path / "sub")}) + event.cwd = str(tmp_path / "sub") + assert write_gate.stop_roots(event, tmp_path / "sub") == [tmp_path] + assert write_gate.repo_root_for(event, tmp_path / "plugin" / "gate.json") == tmp_path + + +def test_a_write_of_nothing_is_still_a_judged_write() -> None: + event = _event( + path="/r/.chock/bin/gate.py", raw={"tool_input": {"file_path": "/r/.chock/bin/gate.py", "content": ""}} + ) + assert write_gate.writes_from_event(event) == {"/r/.chock/bin/gate.py": ""} + + +def test_an_edit_with_no_file_text_is_not_a_write() -> None: + assert write_gate.writes_from_event(_event(path="/r/a.py", raw={"tool_input": {"file_path": "/r/a.py"}})) == {} + + +def test_a_differently_cased_path_is_also_judged_as_stored(tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + names = write_gate.repo_paths(".CLAUDE/Settings.json", str(tmp_path), case_blind=True) + assert ".claude/Settings.json" in names + assert write_gate.repo_paths(".CLAUDE/x", str(tmp_path), case_blind=False) == (".CLAUDE/x",) # --- the decision it returns ------------------------------------------------------------------------- @@ -198,23 +252,23 @@ def test_a_write_through_a_symlinked_folder_inside_the_repo_is_judged_as_its_tar def test_paths_are_made_repository_relative_posix() -> None: - assert write_gate.repo_relative("/r/.github/workflows/ci.yml", "/r") == ".github/workflows/ci.yml" - assert write_gate.repo_relative("./a/../.github/x.yml", "/r") == ".github/x.yml" - assert write_gate.repo_relative(".github/x.yml", None) == ".github/x.yml" + assert repo_names.repo_relative("/r/.github/workflows/ci.yml", "/r") == ".github/workflows/ci.yml" + assert repo_names.repo_relative("./a/../.github/x.yml", "/r") == ".github/x.yml" + assert repo_names.repo_relative(".github/x.yml", None) == ".github/x.yml" def test_windows_paths_fold_drive_separator_and_case() -> None: windows = r"C:\Users\Dev\Repo\.github\workflows\ci.yml" - assert write_gate.repo_relative(windows, r"c:\users\dev\repo") == ".github/workflows/ci.yml" - assert write_gate.repo_relative("C:/Users/Dev/Repo/.github/x.yml", r"C:\Users\Dev\Repo") == ".github/x.yml" + assert repo_names.repo_relative(windows, r"c:\users\dev\repo") == ".github/workflows/ci.yml" + assert repo_names.repo_relative("C:/Users/Dev/Repo/.github/x.yml", r"C:\Users\Dev\Repo") == ".github/x.yml" def test_a_path_outside_the_repository_is_left_as_given() -> None: """Outside the root it matches no relative glob, which is what out of scope means.""" assert ( - write_gate.repo_relative("/elsewhere/.github/workflows/ci.yml", "/r") == "/elsewhere/.github/workflows/ci.yml" + repo_names.repo_relative("/elsewhere/.github/workflows/ci.yml", "/r") == "/elsewhere/.github/workflows/ci.yml" ) - assert write_gate.repo_relative(r"D:\x\ci.yml", r"C:\Repo") == r"D:\x\ci.yml" + assert repo_names.repo_relative(r"D:\x\ci.yml", r"C:\Repo") == r"D:\x\ci.yml" def test_a_gate_named_but_missing_refuses_and_says_to_sync(tmp_path: Path) -> None: diff --git a/tests/test_write_gate_patch.py b/tests/test_write_gate_patch.py index f954a3c0..f5621890 100644 --- a/tests/test_write_gate_patch.py +++ b/tests/test_write_gate_patch.py @@ -96,7 +96,12 @@ def test_several_files_hunks_a_move_and_a_delete(tmp_path: Path) -> None: *** Add File: extra.txt +hi *** End Patch""" - assert patch_image.patched_files(_codex(patch), root) == {"src/Moved.java": "A\nb\nc\nD\n", "extra.txt": "hi\n"} + assert patch_image.patched_files(_codex(patch), root) == { + REPO: "", + "gone.txt": "", + "src/Moved.java": "A\nb\nc\nD\n", + "extra.txt": "hi\n", + } def test_a_hunk_with_no_header_and_context_as_bare_lines(tmp_path: Path) -> None: @@ -209,3 +214,10 @@ def test_a_patch_with_no_end_marker_is_refused() -> None: def test_a_deleted_file_adds_no_lines() -> None: patch = "*** Begin Patch\n*** Delete File: gone.txt\n*** Add File: a.txt\n+x\n*** End Patch" assert patch_image.patch_added(_codex(patch)) == {"a.txt": "x"} + + +def test_a_delete_or_move_names_its_path_so_path_gates_see_it(tmp_path: Path) -> None: + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "settings.json").write_text("{}\n", encoding="utf-8") + patch = "*** Begin Patch\n*** Delete File: .claude/settings.json\n*** End Patch" + assert patch_image.patched_files(_codex(patch), tmp_path) == {".claude/settings.json": ""} diff --git a/tests/test_write_gate_patch_parse.py b/tests/test_write_gate_patch_parse.py index 0eac6e76..e9f8576c 100644 --- a/tests/test_write_gate_patch_parse.py +++ b/tests/test_write_gate_patch_parse.py @@ -177,7 +177,7 @@ def test_what_codex_writes_is_judged(tmp_path: Path, name: str) -> None: "an update in the agent's subdirectory": {"sub/f.txt": f"a\n{TOKEN}\n"}, "a pure addition after an anchor": {"f.txt": f"a\nb\n{TOKEN}\n"}, "an add then an update of the same file": {"f.txt": f"a\n{TOKEN}\n"}, - "an update moved to a file a later update changes": {"g.txt": f"b\n{TOKEN}\n"}, + "an update moved to a file a later update changes": {"f.txt": "", "g.txt": f"b\n{TOKEN}\n"}, } From dccabdfc9706475d9f3b52f1df128f4bf167eb5f Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:23:58 +0000 Subject: [PATCH 22/33] Escape unencodable characters in the stdout wrapper (SR-5) A cp1252 stdout raised UnicodeEncodeError on U+FFFD. Retry with backslashreplace in the stream encoding; representable text is unchanged. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/stdout-unencodable-escape.md | 4 ++++ src/chock/pipe.py | 4 ++++ tests/test_pipe_tolerance.py | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 changelog.d/stdout-unencodable-escape.md diff --git a/changelog.d/stdout-unencodable-escape.md b/changelog.d/stdout-unencodable-escape.md new file mode 100644 index 00000000..6193a92f --- /dev/null +++ b/changelog.d/stdout-unencodable-escape.md @@ -0,0 +1,4 @@ +- **Command output no longer crashes on a character the stream cannot encode.** `chock check` on a + Windows runner (stdout cp1252) raised `UnicodeEncodeError` on U+FFFD while printing eval results. The + stdout wrapper now retries with `backslashreplace` in the stream's encoding; text the encoding can + represent prints unchanged. diff --git a/src/chock/pipe.py b/src/chock/pipe.py index 69ba15a9..e8b8f24e 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -24,6 +24,10 @@ def write(self, text: str) -> int: return len(text) try: return self._stream.write(text) + except UnicodeEncodeError: + encoding = getattr(self._stream, "encoding", None) or "ascii" + safe = text.encode(encoding, "backslashreplace").decode(encoding) + return self._stream.write(safe) except OSError as exc: if not _is_broken_pipe(exc): raise diff --git a/tests/test_pipe_tolerance.py b/tests/test_pipe_tolerance.py index 0061133e..9eccf112 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -117,3 +117,25 @@ def test_recompile_finishes_its_bookkeeping_when_the_reader_closes_early(tmp_pat f"just installed now fail every commit:\n{output}" ) assert "index_freshness" not in output, f"INDEX.md was left stale by the truncated run:\n{output}" + + +def test_unencodable_character_is_escaped_not_raised() -> None: + """IR-F18: U+FFFD on a cp1252 stream (Windows console) must not crash the command.""" + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + writer = PipeTolerantWriter(stream) + + writer.write("ok � end") + writer.flush() + + assert raw.getvalue() == b"ok \\ufffd end" + + +def test_representable_text_is_written_unchanged() -> None: + raw = io.BytesIO() + writer = PipeTolerantWriter(io.TextIOWrapper(raw, encoding="cp1252", errors="strict")) + + writer.write("café €") + writer.flush() + + assert raw.getvalue() == "café €".encode("cp1252") From 975abccfdf401705e4c3298929d7b081ddc2c043 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:06 +0000 Subject: [PATCH 23/33] Tolerate a broken pipe on the escaped retry (SR-5) Both writes now go through the same OSError handling, so a reader that vanishes during the backslashreplace retry no longer raises past the wrapper. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/pipe.py | 10 +++++----- tests/test_pipe_tolerance.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/chock/pipe.py b/src/chock/pipe.py index e8b8f24e..b5b06606 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -23,11 +23,11 @@ def write(self, text: str) -> int: if self.closed_by_reader: return len(text) try: - return self._stream.write(text) - except UnicodeEncodeError: - encoding = getattr(self._stream, "encoding", None) or "ascii" - safe = text.encode(encoding, "backslashreplace").decode(encoding) - return self._stream.write(safe) + try: + return self._stream.write(text) + except UnicodeEncodeError: + encoding = getattr(self._stream, "encoding", None) or "ascii" + return self._stream.write(text.encode(encoding, "backslashreplace").decode(encoding)) except OSError as exc: if not _is_broken_pipe(exc): raise diff --git a/tests/test_pipe_tolerance.py b/tests/test_pipe_tolerance.py index 9eccf112..8aabe271 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -139,3 +139,19 @@ def test_representable_text_is_written_unchanged() -> None: writer.flush() assert raw.getvalue() == "café €".encode("cp1252") + + +def test_broken_pipe_on_the_escaped_retry_is_tolerated() -> None: + class _Cp1252ThenClosed(io.StringIO): + encoding = "cp1252" + + def write(self, text: str) -> int: + if "�" in text: + raise UnicodeEncodeError("charmap", text, 0, 1, "undefined") + raise BrokenPipeError(32, "Broken pipe") + + writer = PipeTolerantWriter(_Cp1252ThenClosed()) + + writer.write("ok � end") + + assert writer.closed_by_reader From 4ef158ae9a1f39ea680830f0bbc95bd0ac309130 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:16:30 +0000 Subject: [PATCH 24/33] test: keep interpreter start-up out of the early-exit pattern-judge budget (SR-6) judge_patterns spends interpreter start-up out of the caller's budget when it starts a child. The dead-child test gave those calls the 1 s budget meant for the catastrophic pattern, so on a loaded runner the fresh child after the timeout was not up in time and the "(" case reported "no verdict within 1s" instead of "exited early". The calls that start a child now get PATTERN_SECONDS; the catastrophic pattern keeps the 1 s budget on an already-running child. No assertion changed. budget.py and the vendored runtime are untouched. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_pattern_deadline.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_pattern_deadline.py b/tests/test_pattern_deadline.py index 03d4a0f2..134a1efe 100644 --- a/tests/test_pattern_deadline.py +++ b/tests/test_pattern_deadline.py @@ -142,10 +142,14 @@ def test_the_history_scan_needs_no_signal() -> None: def test_the_judge_matches_times_out_and_reports_a_dead_child() -> None: + # A call that starts a child spends interpreter start-up out of its budget, and start-up on a + # loaded runner can outlast SECONDS; only the catastrophic pattern needs the short budget, and + # by then the child is already up. Every other call returns as soon as the child answers or dies. + start_up = budget.PATTERN_SECONDS judge = budget.pattern_judge() assert budget.judge_patterns(judge, [("a", []), ("b", [])], SECONDS) == ([[], []], None) assert judge["proc"] is None, "nothing to judge starts no child" - assert budget.judge_patterns(judge, [("a+", ["xa", "b", "aaa"]), (r"\d", ["1", "x"])], SECONDS) == ( + assert budget.judge_patterns(judge, [("a+", ["xa", "b", "aaa"]), (r"\d", ["1", "x"])], start_up) == ( [[0, 2], [0]], None, ) @@ -154,9 +158,9 @@ def test_the_judge_matches_times_out_and_reports_a_dead_child() -> None: assert hits is None and why == f"the patterns gave no verdict within {SECONDS}s (a catastrophic pattern?)" assert SECONDS <= time.monotonic() - started < budget.PATTERN_SECONDS assert judge["proc"] is None, "a timed-out child is killed" - hits, why = budget.judge_patterns(judge, [("(", ["x"])], SECONDS) + hits, why = budget.judge_patterns(judge, [("(", ["x"])], start_up) assert hits is None and why.startswith("the pattern judge exited early (") - assert budget.judge_patterns(judge, [("x", ["x"])], SECONDS) == ([[0]], None), "a new child after a crash" + assert budget.judge_patterns(judge, [("x", ["x"])], start_up) == ([[0]], None), "a new child after a crash" assert budget.close_judge(judge) == "" and judge["proc"] is None From 57ad94b815ff0467e11af46b9f6dd996c124f4c8 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:48:36 -0400 Subject: [PATCH 25/33] chore: chock sync for the SR-3 hardening Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/_chock_runtime.py | 171 +++++++++++++++++++++++++---------- .chock/bin/claude_code.py | 2 +- .chock/bin/codex_cli.py | 2 +- .chock/bin/cursor.py | 2 +- .chock/bin/devin.py | 2 +- .chock/bin/gate.py | 23 +++-- .chock/bin/gemini_cli.py | 2 +- .chock/bin/grok.py | 2 +- .chock/bin/tabnine.py | 2 +- .chock/bin/vscode_copilot.py | 2 +- .chock/bin/windsurf.py | 2 +- 11 files changed, 146 insertions(+), 66 deletions(-) diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 336e74a1..6d50dfbf 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -1098,6 +1098,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1159,12 +1161,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1465,7 +1471,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1477,7 +1487,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1485,7 +1495,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2077,6 +2087,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2203,18 +2292,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2292,37 +2392,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -def repo_relative(path, root): - """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" - text = str(path) - if root is None: - return text - windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON - flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath - try: - return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() - except ValueError: - pass - if windows != (_chock_os.name == 'nt'): - return text - try: - return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return text - -def repo_paths(path, root): - """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" - lexical = repo_relative(path, root) - if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): - return (lexical,) - try: - resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return (lexical,) - return tuple(dict.fromkeys((lexical, resolved))) - def changed_paths(repo_root, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2346,8 +2417,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2363,10 +2434,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2405,7 +2480,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2413,7 +2488,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index ee95433b..7429bc33 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9f24d0d4..6a9bca35 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index ef075ec6..6d07feae 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 627c4116..419fce76 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 153e73dc..82a07505 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -190,14 +190,18 @@ def __init__( #: Path prefixes this gate never judges: its own policy's source and compiled folders. self.own = tuple(own) - def in_scope(self, path: str) -> bool: + def is_own(self, path: str) -> bool: + """Whether `path` is in this policy's own source or compiled folder.""" + return bool(self.own) and path.startswith(self.own) + + def in_scope(self, path: str, *, judge_own: bool = False) -> bool: """Whether this policy may judge this file at all. fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ - if path.startswith(self.own): + if not judge_own and self.is_own(path): return False if _is_outside(path): return True @@ -245,9 +249,9 @@ def rev_exists(self, ref: str) -> bool: """True when `ref` resolves to a commit. Used to fail CI closed on a missing base.""" return bool(self._git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").strip()) - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: paths = self._listing("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [path for path in paths if self.in_scope(path)] + return [path for path in paths if self.in_scope(path, judge_own=judge_own)] def _diff_text(self, path: str) -> str: """`path`'s `-U0` diff as git writes it, unconverted; raises DiffError when git fails.""" @@ -349,11 +353,11 @@ def __init__( self._added = dict(added or {}) self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: """Every write is a present file, so a filter asking only for deletions finds none.""" if not set(diff_filter) & set("ACMRT"): return [] - return [path for path in self._writes if self.in_scope(path)] + return [path for path in self._writes if self.in_scope(path, judge_own=judge_own)] def staged_blob(self, path: str) -> str: return self._writes.get(path, "") @@ -624,14 +628,15 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul pragma = _waiver_pattern(params, event) agent = event in HEAD_WAIVER_EVENTS whole = params.get("scan", "added_lines") == "staged_blob" - paths = ctx.staged_paths(params.get("diff_filter", "ACMRT")) + paths = ctx.staged_paths(params.get("diff_filter", "ACMRT"), judge_own=True) judge = pattern_judge() try: forbidden = sorted(_hits(judge, [(params.get("forbidden_path_regex"), paths)])[0]) heads = {path: ctx.committed_blob(path) if agent else "" for path in paths} blobs = [heads[paths[index]] if agent else ctx.staged_blob(paths[index]) for index in forbidden] spans, texts = _lines( - paths, lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path) + [path for path in paths if not ctx.is_own(path)], + lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path), ) jobs = [(pragma, blobs), (pragma, texts), (params["content_pattern"], texts)] blob_waivers, line_waivers, hits = _hits(judge, jobs) @@ -642,7 +647,7 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul if index in forbidden and forbidden.index(index) not in blob_waivers: matches.append(f"{path}: forbidden path") head = frozenset(heads[path].splitlines()) if agent else None - if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans[path]): + if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans.get(path, ())): matches.append(f"{path}: content pattern") return GateResult(allowed=not matches, matches=matches) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2990ff7e..1e4048e1 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 16b86489..1e2fcc0f 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index d513535a..c4847fa5 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index ab04974d..1f873646 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index ee68c3b5..0975fa33 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '1338b7f2c52bc6aa85599d6913bab34e13784a39897247e3d84c092684bbb443' +_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' def _chock_load_runtime(): From 51c08979b2277dd0078474dddd5170ea2da5179a Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:34:02 -0400 Subject: [PATCH 26/33] rc: chock sync and runtime goldens for rc.2 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/claude_code.py | 2 +- .chock/bin/codex_cli.py | 2 +- .chock/bin/cursor.py | 2 +- .chock/bin/devin.py | 2 +- .chock/bin/gemini_cli.py | 2 +- .chock/bin/grok.py | 2 +- .chock/bin/tabnine.py | 2 +- .chock/bin/vscode_copilot.py | 2 +- .chock/bin/windsurf.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 7429bc33..b905927f 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 6a9bca35..fa389a49 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 6d07feae..7a7421b0 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 419fce76..bb3f8f6e 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 1e4048e1..a863924b 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 1e2fcc0f..8c8549c5 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index c4847fa5..63202422 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 1f873646..5174394e 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 0975fa33..8f3e4611 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): From 6cbe32aa639a9b1f4291ff465abb29809a431945 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:45:27 -0400 Subject: [PATCH 27/33] rc: runtime goldens for rc.2 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/fixtures/runtime_goldens/antigravity.py | 2 +- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/fixtures/runtime_goldens/codex_cli.py | 2 +- tests/fixtures/runtime_goldens/cursor.py | 2 +- tests/fixtures/runtime_goldens/devin.py | 2 +- tests/fixtures/runtime_goldens/factory_droid.py | 2 +- tests/fixtures/runtime_goldens/gemini_cli.py | 2 +- tests/fixtures/runtime_goldens/goose.py | 2 +- tests/fixtures/runtime_goldens/grok.py | 2 +- tests/fixtures/runtime_goldens/mistral_vibe.py | 2 +- tests/fixtures/runtime_goldens/openhands.py | 2 +- tests/fixtures/runtime_goldens/tabnine.py | 2 +- tests/fixtures/runtime_goldens/vscode_copilot.py | 2 +- tests/fixtures/runtime_goldens/windsurf.py | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 21b68be6..a2f76965 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 7429bc33..b905927f 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 6a9bca35..fa389a49 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 6d07feae..7a7421b0 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 419fce76..bb3f8f6e 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3cf36a48..9f8dcff8 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 1e4048e1..a863924b 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index 5c22c5ed..baf2118b 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 1e2fcc0f..8c8549c5 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index b0468f34..460e3d65 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 3ba3e772..46f9a47c 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index c4847fa5..63202422 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 1f873646..5174394e 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 0975fa33..8f3e4611 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '3d33eb80d931c113e0be0564e75f60900323c3f850a14a714bb247294ce03b4c' +_CHOCK_RUNTIME_PIN = '46ede0890c4545d20dbad3cf8c7743c28aae27f46cea04d9532c4de1f5b7f1a4' def _chock_load_runtime(): From cc695c0eceba6fd827e9dd9f76b132cd713fd0c9 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:05:23 +0000 Subject: [PATCH 28/33] fix: make _in_scope's blind flag keyword-only (FBT001) after the v1-runtime merge Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/gateway/gates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chock/gateway/gates.py b/src/chock/gateway/gates.py index be585240..668554db 100644 --- a/src/chock/gateway/gates.py +++ b/src/chock/gateway/gates.py @@ -115,7 +115,7 @@ def _call_paths(spec: dict[str, Any], arguments: Any) -> list[str]: return list(dict.fromkeys(paths)) -def _in_scope(path: str, scope: list[str], blind: bool) -> bool: +def _in_scope(path: str, scope: list[str], *, blind: bool) -> bool: """A path is in a gate's bound when a glob matches it, or when it cannot be placed inside the repository.""" fold = str.casefold if blind else str return ".." in path.split("/") or any(fnmatch.fnmatchcase(fold(path), fold(g)) for g in scope) @@ -132,7 +132,7 @@ def _eval_content_regex(spec: dict[str, Any], arguments: Any) -> str | None: if scope: # A path-bounded gate judges only what it can place inside its bound. blind = _blind(spec) - paths = [p for p in paths if _in_scope(p, scope, blind)] + paths = [p for p in paths if _in_scope(p, scope, blind=blind)] if not paths: return None forbidden = params.get("forbidden_path_regex") From 33689b999d7077da85ea46914dc1e6c1f487f9aa Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:55:05 -0400 Subject: [PATCH 29/33] rc: sync the compiled runtime and goldens for rc.3 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/_chock_runtime.py | 190 +++++++++++++----- .chock/bin/claude_code.py | 2 +- .chock/bin/codex_cli.py | 2 +- .chock/bin/cursor.py | 2 +- .chock/bin/devin.py | 2 +- .chock/bin/gate.py | 26 ++- .chock/bin/gemini_cli.py | 2 +- .chock/bin/grok.py | 2 +- .chock/bin/tabnine.py | 2 +- .chock/bin/vscode_copilot.py | 2 +- .chock/bin/windsurf.py | 2 +- tests/fixtures/plugin_targets/droid.json | 2 + tests/fixtures/plugin_targets/goose.json | 2 + tests/fixtures/plugin_targets/grok.json | 1 + tests/fixtures/plugin_targets/kimi.json | 2 + tests/fixtures/plugin_targets/openhands.json | 2 + .../runtime_goldens/_chock_runtime.py | 190 +++++++++++++----- tests/fixtures/runtime_goldens/antigravity.py | 2 +- tests/fixtures/runtime_goldens/claude_code.py | 2 +- tests/fixtures/runtime_goldens/codex_cli.py | 2 +- tests/fixtures/runtime_goldens/cursor.py | 2 +- tests/fixtures/runtime_goldens/devin.py | 2 +- .../fixtures/runtime_goldens/factory_droid.py | 2 +- tests/fixtures/runtime_goldens/gemini_cli.py | 2 +- tests/fixtures/runtime_goldens/goose.py | 2 +- tests/fixtures/runtime_goldens/grok.py | 2 +- .../fixtures/runtime_goldens/mistral_vibe.py | 2 +- tests/fixtures/runtime_goldens/openhands.py | 2 +- tests/fixtures/runtime_goldens/tabnine.py | 2 +- .../runtime_goldens/vscode_copilot.py | 2 +- tests/fixtures/runtime_goldens/windsurf.py | 2 +- 31 files changed, 320 insertions(+), 141 deletions(-) diff --git a/.chock/bin/_chock_runtime.py b/.chock/bin/_chock_runtime.py index 59e2f682..dbde6c72 100644 --- a/.chock/bin/_chock_runtime.py +++ b/.chock/bin/_chock_runtime.py @@ -286,7 +286,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(''.join([format(path), ': unknown key(s) ', format(', '.join(map(str, unknown))), '; only level and max_bytes are read'])) + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -858,7 +859,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: @@ -949,7 +950,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(''.join([format(mcp['server']), '/', format(mcp['tool'])]))[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1075,8 +1077,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return ''.join(['guard-', format(GUARD_LAST['outcome'])]) - return source if source == 'fault' else ''.join([format(source), '-', format(_DLOG_SPOKEN.get(outcome, 'allow'))]) + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1149,6 +1155,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1210,12 +1218,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1386,7 +1398,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, ''.join(['update of ', format(repr(last['path'])), ' has no hunk'])) + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') @@ -1516,7 +1529,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1528,7 +1545,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1536,7 +1553,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2168,6 +2185,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2294,18 +2390,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2383,37 +2490,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -def repo_relative(path, root): - """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" - text = str(path) - if root is None: - return text - windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON - flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath - try: - return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() - except ValueError: - pass - if windows != (_chock_os.name == 'nt'): - return text - try: - return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return text - -def repo_paths(path, root): - """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" - lexical = repo_relative(path, root) - if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): - return (lexical,) - try: - resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return (lexical,) - return tuple(dict.fromkeys((lexical, resolved))) - def changed_paths(repo_root, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2437,8 +2515,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2454,10 +2532,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2496,7 +2578,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2504,7 +2586,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 34438330..f8363244 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 9b699d09..43dd8bda 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 3036ec85..3313be92 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index ba58c7be..4ced8037 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 3c7a864d..3dbb4565 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -220,14 +220,18 @@ def __init__( #: Path prefixes this gate never judges: its own policy's source and compiled folders. self.own = tuple(own) - def in_scope(self, path: str) -> bool: + def is_own(self, path: str) -> bool: + """Whether `path` is in this policy's own source or compiled folder.""" + return bool(self.own) and path.startswith(self.own) + + def in_scope(self, path: str, *, judge_own: bool = False) -> bool: """Whether this policy may judge this file at all. fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ - if path.startswith(self.own): + if not judge_own and self.is_own(path): return False if _is_outside(path): return True @@ -277,9 +281,9 @@ def rev_exists(self, ref: str) -> bool: """True when `ref` resolves to a commit. Used to fail CI closed on a missing base.""" return bool(self._git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").strip()) - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: paths = self._listing("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [path for path in paths if self.in_scope(path)] + return [path for path in paths if self.in_scope(path, judge_own=judge_own)] def _diff_text(self, path: str) -> str: """`path`'s `-U0` diff as git writes it, unconverted; raises DiffError when git fails.""" @@ -382,11 +386,11 @@ def __init__( self._added = dict(added or {}) self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + def staged_paths(self, diff_filter: str = "ACMRT", *, judge_own: bool = False) -> list[str]: """Every write is a present file, so a filter asking only for deletions finds none.""" if not set(diff_filter) & set("ACMRT"): return [] - return [path for path in self._writes if self.in_scope(path)] + return [path for path in self._writes if self.in_scope(path, judge_own=judge_own)] def staged_blob(self, path: str) -> str: return self._writes.get(path, "") @@ -678,14 +682,15 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul pragma = _waiver_pattern(params, event) agent = event in HEAD_WAIVER_EVENTS whole = params.get("scan", "added_lines") == "staged_blob" - paths = ctx.staged_paths(params.get("diff_filter", "ACMRT")) + paths = ctx.staged_paths(params.get("diff_filter", "ACMRT"), judge_own=True) judge = pattern_judge() try: forbidden = sorted(_hits(judge, [(_path_pattern(params, ctx), paths)])[0]) heads = {path: ctx.committed_blob(path) if agent else "" for path in paths} blobs = [heads[paths[index]] if agent else ctx.staged_blob(paths[index]) for index in forbidden] spans, texts = _lines( - paths, lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path) + [path for path in paths if not ctx.is_own(path)], + lambda path: ctx.staged_blob(path).splitlines() if whole else ctx.added_lines(path), ) jobs = [(pragma, blobs), (pragma, texts), (params["content_pattern"], texts)] blob_waivers, line_waivers, hits = _hits(judge, jobs) @@ -696,7 +701,7 @@ def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResul if index in forbidden and forbidden.index(index) not in blob_waivers: matches.append(f"{path}: forbidden path") head = frozenset(heads[path].splitlines()) if agent else None - if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans[path]): + if any(n in hits and not _waived(n, texts[n], line_waivers, head) for n in spans.get(path, ())): matches.append(f"{path}: content pattern") return GateResult(allowed=not matches, matches=matches) @@ -1826,7 +1831,8 @@ def log_settings_read(path): problems.append(f"{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576") unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(f"{path}: unknown key(s) {', '.join(map(str, unknown))}; only level and max_bytes are read") + names = ", ".join(map(str, unknown)) + problems.append(f"{path}: unknown key(s) {names}; only level and max_bytes are read") return found, problems diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 2482da42..53676caa 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index d22df270..74f8315b 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 46a2965f..9621ba33 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 1b7bdeec..9a0e6aa1 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index c2523375..c719df2e 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/plugin_targets/droid.json b/tests/fixtures/plugin_targets/droid.json index d66741a1..956e73d0 100644 --- a/tests/fixtures/plugin_targets/droid.json +++ b/tests/fixtures/plugin_targets/droid.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/goose.json b/tests/fixtures/plugin_targets/goose.json index 09c34420..a679be7c 100644 --- a/tests/fixtures/plugin_targets/goose.json +++ b/tests/fixtures/plugin_targets/goose.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/grok.json b/tests/fixtures/plugin_targets/grok.json index 58bd5b86..6d3a612f 100644 --- a/tests/fixtures/plugin_targets/grok.json +++ b/tests/fixtures/plugin_targets/grok.json @@ -5,6 +5,7 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/kimi.json b/tests/fixtures/plugin_targets/kimi.json index e4087b62..4005b924 100644 --- a/tests/fixtures/plugin_targets/kimi.json +++ b/tests/fixtures/plugin_targets/kimi.json @@ -5,11 +5,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/plugin_targets/openhands.json b/tests/fixtures/plugin_targets/openhands.json index 507425e8..c1f8658f 100644 --- a/tests/fixtures/plugin_targets/openhands.json +++ b/tests/fixtures/plugin_targets/openhands.json @@ -6,11 +6,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", diff --git a/tests/fixtures/runtime_goldens/_chock_runtime.py b/tests/fixtures/runtime_goldens/_chock_runtime.py index 59e2f682..dbde6c72 100644 --- a/tests/fixtures/runtime_goldens/_chock_runtime.py +++ b/tests/fixtures/runtime_goldens/_chock_runtime.py @@ -286,7 +286,8 @@ def log_settings_read(path): problems.append(f'{path}: max_bytes must be {_LOG_BYTES_RANGE}, not {size!r}; using the next scope or 1048576') unknown = sorted(set(doc) - set(LOG_SETTINGS_KEYS)) if unknown: - problems.append(''.join([format(path), ': unknown key(s) ', format(', '.join(map(str, unknown))), '; only level and max_bytes are read'])) + names = ', '.join(map(str, unknown)) + problems.append(f'{path}: unknown key(s) {names}; only level and max_bytes are read') return (found, problems) def log_settings_paths(repo_root): @@ -858,7 +859,7 @@ def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | Non verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) GUARD_LAST['outcome'] = verdict if verdict == GUARD_BLOCKED: - return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}: {message}') if verdict == GUARD_ASKED: return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: @@ -949,7 +950,8 @@ def dlog_target(event, repo): tool = str(getattr(event, 'tool', None) or '') mcp = _DLOG_MCP_TOOL.match(tool) if mcp: - return log_redact(''.join([format(mcp['server']), '/', format(mcp['tool'])]))[:_DLOG_MCP_CAP] + server, name = (mcp['server'], mcp['tool']) + return log_redact(f'{server}/{name}')[:_DLOG_MCP_CAP] command, path = (getattr(event, 'command', None), getattr(event, 'path', None)) if command: return dlog_command_print(command) @@ -1075,8 +1077,12 @@ def _dlog_reason(source, outcome): if source == HALT_REASON: return HALT_REASON if source == 'guard' and 'outcome' in GUARD_LAST: - return ''.join(['guard-', format(GUARD_LAST['outcome'])]) - return source if source == 'fault' else ''.join([format(source), '-', format(_DLOG_SPOKEN.get(outcome, 'allow'))]) + guarded = GUARD_LAST['outcome'] + return f'guard-{guarded}' + if source == 'fault': + return source + spoken = _DLOG_SPOKEN.get(outcome, 'allow') + return f'{source}-{spoken}' def _dlog_paths(route, runtime, agent, event): """(log file, repository whose settings apply) for this call.""" @@ -1149,6 +1155,8 @@ def log_heartbeat(event, manifests): _CREATE_TEXT = 'file_text' +_CREATE_KEYS = ('content', _CREATE_TEXT) + def _edit_call_input(event): raw = getattr(event, 'raw', None) tool_input = raw.get('tool_input') if isinstance(raw, dict) else None @@ -1210,12 +1218,16 @@ def edited_text(event, root=None): return text def created_text(event): - """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + """The whole file a write call carries: the adapter's content, else the call's own `content` or `file_text`. + + An adapter reads an empty `content` as absent, so the call's own input is read too: a write of + nothing is still a write to its path. + """ content = getattr(event, 'content', None) if isinstance(content, str): return content - text = _edit_call_input(event).get(_CREATE_TEXT) - return text if isinstance(text, str) else None + tool_input = _edit_call_input(event) + return next((text for text in map(tool_input.get, _CREATE_KEYS) if isinstance(text, str)), None) def added_from_event(event): """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" @@ -1386,7 +1398,8 @@ def _patch_not_empty(state, text): if last is None or last['op'] != 'update': return if not last['body'] and state['mode'] == 'update': - _patch_refuse(state['number'], text, ''.join(['update of ', format(repr(last['path'])), ' has no hunk'])) + target = last['path'] + _patch_refuse(state['number'], text, f'update of {target!r} has no hunk') if last['body'] and (not (last['body'][-1]['old'] or last['body'][-1]['new'])): _patch_refuse(state['number'], text, 'a hunk has no lines') @@ -1516,7 +1529,11 @@ def _read(root, path): return None def patched_files(event, root=None): - """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt. + + A file it deletes, or moves away from, is named with empty text: it has no content left to judge, + but its path is still a write that path-based gates must see. + """ patch = patch_text(event) if patch is None: return {} @@ -1528,7 +1545,7 @@ def patched_files(event, root=None): disk[key] = files[key] = ''.join((line + '\n' for line in body)) elif op == 'delete': disk[key] = None - files.pop(key, None) + files[key] = '' else: before = disk[key] if key in disk else _read(root, key) after = apply_hunks(before, body) if before is not None else None @@ -1536,7 +1553,7 @@ def patched_files(event, root=None): target = _patch_key(event, moved_to) if moved_to else key if target != key: disk[key] = None - files.pop(key, None) + files[key] = '' disk[target] = files[target] = after return files @@ -2168,6 +2185,85 @@ def workspace_root(event): path = getattr(event, 'path', None) return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) +_CASE_BLIND = sys.platform in ('darwin', 'win32') + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def on_disk_case(name, root): + """`name` (repo-relative, POSIX) with each existing component spelled as the filesystem stores it.""" + here, spelled = (_chock_Path(root), []) + for part in _chock_PurePosixPath(name).parts: + try: + stored = _chock_os.listdir(here) + except OSError: + stored = [] + found = part if part in stored else next((s for s in stored if s.casefold() == part.casefold()), part) + spelled.append(found) + here = here / found + return _chock_PurePosixPath(*spelled).as_posix() + +def repo_paths(path, root, *, case_blind=_CASE_BLIND): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + names = [lexical, resolved] + if case_blind: + names += [on_disk_case(name, root) for name in (lexical, resolved) if not is_outside(name)] + return tuple(dict.fromkeys(names)) + +_MODE_LINK = 40960 + +def _open_text(target, kind): + """(text, why) of a link's target or a regular file, opened without following a link or waiting on a pipe.""" + try: + if kind == _MODE_LINK: + return (_chock_os.readlink(target), None) + if kind != STOP_MODE_FILE: + return (None, f'{target} is neither a file nor a link') + fd = _chock_os.open(target, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, 'rb') as handle: + return (handle.read().decode('utf-8'), None) + except UnicodeDecodeError: + return (None, None) + except OSError as exc: + return (None, f'{target} could not be read ({exc})') + +def worktree_text(target): + """(text, why): `target` as the worktree holds it, never opening a FIFO, device or the file a link names. + + A file that is gone reads as "" (its path is still judged), a directory or undecodable file as no + text, a link as the target git stores for it. Anything else is refused with a reason: opening a + FIFO or device can block the hook until its client's timeout, which a client may read as an allow. + """ + try: + kind = _chock_os.lstat(target).st_mode & STOP_MODE_MASK + except FileNotFoundError: + return ('', None) + except OSError as exc: + return (None, f'{target} could not be examined ({exc})') + return (None, None) if kind == STOP_MODE_DIR else _open_text(target, kind) + _REPO_STOP_PROJECT_ENV = ('CLAUDE_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'OPENHANDS_PROJECT_DIR') _REPO_STOP_PROJECT_KEYS = ('workspacePaths',) @@ -2294,18 +2390,29 @@ def repo_stop_refusal(event): _GIT_DIR = '.git' -def _repository(folder): +def repository_of(folder): """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" here = _chock_Path(folder).absolute() + if not here.is_dir(): + return None return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) +def judged_root(named): + """The top level of `named`'s repository; `named` as given when relative (a drive-lettered root on POSIX).""" + path = _chock_Path(named) + return repository_of(path) or path if path.is_absolute() else path + def stop_roots(event, fallback): - """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + """The top level of each repository the event's `cwd` and workspace roots are in; else [fallback]. + + The top level, not the named folder: `git status` lists paths relative to it, so a subdirectory + would resolve every listed path against the wrong folder and judge nothing. + """ by_repository = {} for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): - repository = _repository(named) + repository = repository_of(named) if repository is not None: - by_repository.setdefault(repository, _chock_Path(named)) + by_repository.setdefault(repository, judged_root(named)) return list(by_repository.values()) or [fallback] def per_root(said): @@ -2383,37 +2490,8 @@ def writes_from_event(event, root=None): return {} return {str(path): content} -def repo_relative(path, root): - """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" - text = str(path) - if root is None: - return text - windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON - flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath - try: - return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() - except ValueError: - pass - if windows != (_chock_os.name == 'nt'): - return text - try: - return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return text - -def repo_paths(path, root): - """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" - lexical = repo_relative(path, root) - if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): - return (lexical,) - try: - resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() - except (OSError, ValueError, RuntimeError): - return (lexical,) - return tuple(dict.fromkeys((lexical, resolved))) - def changed_paths(repo_root, deadline=None, why=None): - """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + """Every uncommitted path, a deleted file and a rename's old name included; None (reason in `why`) when git gave no answer.""" deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] try: @@ -2437,8 +2515,8 @@ def changed_paths(repo_root, deadline=None, why=None): for field in fields: status, path = (field[:2], field[3:]) if status.startswith(_RENAMED): - next(fields, None) - if _DELETED not in status and path: + paths.append(next(fields, '')) + if path: paths.append(path) return paths @@ -2454,10 +2532,14 @@ def writes_from_worktree(repo_root, deadline=None, why=None): return None writes = {} for path in paths: - try: - writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - continue + text, unsafe = worktree_text(_chock_Path(repo_root, path)) + if unsafe: + if why is not None: + why.append(unsafe) + sys.stderr.write(f'chock: {unsafe}, worktree not checked\n') + return None + if text is not None: + writes[path] = text return writes def run_gate(gate, writes, event, run_in, deadline): @@ -2496,7 +2578,7 @@ def root_for(gate): return parents[_GATE_DEPTH_TO_CHOCK].parent def repo_root_for(event, gate): - """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + """The repository under judgement: the compiled layout's root, else the top level of the event's cwd or workspace root. Of several workspace roots, the one holding the written path; this process's cwd only when there are none. """ @@ -2504,7 +2586,7 @@ def repo_root_for(event, gate): if root is not None: return root cwd = getattr(event, 'cwd', None) or workspace_root(event) - return _chock_Path(cwd) if cwd else _chock_Path.cwd() + return judged_root(cwd) if cwd else _chock_Path.cwd() def writes_for(event, gate, deadline=None, why=None, root=None): """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index e87bed04..e0ecd1cb 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -698,7 +698,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 34438330..f8363244 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -809,7 +809,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 9b699d09..43dd8bda 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -777,7 +777,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 3036ec85..3313be92 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -670,7 +670,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index ba58c7be..4ced8037 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py index 3108a2a5..48e574cd 100644 --- a/tests/fixtures/runtime_goldens/factory_droid.py +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -763,7 +763,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 2482da42..53676caa 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py index ac3192ab..b8786bd3 100644 --- a/tests/fixtures/runtime_goldens/goose.py +++ b/tests/fixtures/runtime_goldens/goose.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index d22df270..74f8315b 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -776,7 +776,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py index c1baf62c..d1a66dfe 100644 --- a/tests/fixtures/runtime_goldens/mistral_vibe.py +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -759,7 +759,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py index 50673761..9720d7eb 100644 --- a/tests/fixtures/runtime_goldens/openhands.py +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 46a2965f..9621ba33 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -732,7 +732,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 1b7bdeec..9a0e6aa1 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -560,7 +560,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index c2523375..c719df2e 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -629,7 +629,7 @@ def degrade(decision, event): # Not self-contained: chock's guard logic lives in the sibling _chock_runtime.py, loaded from this # file's own folder and pinned to the sha256 of that file's bytes (CRLF read as LF), hashed before they are executed. # Missing, unreadable, another version, or failing to import: every call refuses (_CHOCK_RUNTIME_FAULT), never allows. -_CHOCK_RUNTIME_PIN = '7ad185c06c529b9d8174cc9faeece81f92a75732282baa0bccee772d06d92787' +_CHOCK_RUNTIME_PIN = 'a7302f5f8e1deb74d6f245a97474228ce9258ed2c04313a30af7799787a9d259' def _chock_load_runtime(): From acc5a1e507d5506e3cacfde599ca890680175ff5 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:42:05 -0400 Subject: [PATCH 30/33] rc: sync the compiled runtime and goldens for rc.3 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/fixtures/plugin_targets/antigravity.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/plugin_targets/antigravity.json b/tests/fixtures/plugin_targets/antigravity.json index 421768ad..6c84d482 100644 --- a/tests/fixtures/plugin_targets/antigravity.json +++ b/tests/fixtures/plugin_targets/antigravity.json @@ -7,11 +7,13 @@ "scripts/chock-guardrails-protect/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/chock_guardrails_protect/names.py", "scripts/chock-guardrails-protect/gate.json", "scripts/chock-guardrails-protect/gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect-gate.py", "scripts/chock-guardrails-protect/implementations/chock-guardrails-protect.py", "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/__init__.py", + "scripts/chock-guardrails-protect/implementations/chock_guardrails_protect/names.py", "scripts/chock_bundle.json", "scripts/chock_bundle.py", "scripts/chock_bundle_log.py", From a3e3efde9559276a32991ca4747be7a650df5373 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:53:25 +0000 Subject: [PATCH 31/33] test: pin the eval CLI's cp1252 console output (IR-33) `chock check --only evals` on a Windows cp1252 console printed U+FFFD and raised UnicodeEncodeError (IR-F31). SR-5 (#325) already escapes it in the stdout wrapper, so no code changes. This test drives the real command through a strict cp1252 stream and asserts no exception and backslash-escaped output. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_pipe_tolerance.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_pipe_tolerance.py b/tests/test_pipe_tolerance.py index 8aabe271..d8ea1cb2 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -155,3 +155,28 @@ def write(self, text: str) -> int: writer.write("ok � end") assert writer.closed_by_reader + + +def test_eval_output_with_unencodable_text_survives_a_cp1252_console( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """IR-F31: `chock check --only evals` printing U+FFFD to a Windows cp1252 console must not raise.""" + import shutil + + import chock.eval.cli as eval_cli + from chock.cli import main + + target = tmp_path / ".agents" / "policies" / "agent-discipline" + target.parent.mkdir(parents=True) + shutil.copytree(ROOT / ".agents" / "policies" / "agent-discipline", target) + + real_render = eval_cli.render_text + monkeypatch.setattr(eval_cli, "render_text", lambda *a, **k: real_render(*a, **k) + "\nguard said � bad byte") + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", stream) + + main(["check", "--only", "evals", "--repo", str(tmp_path)]) + sys.stdout.flush() + + assert b"guard said \\ufffd bad byte" in raw.getvalue() From b79a275c6d0824c67733193d9654fa19d8b94c2e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:11:51 +0000 Subject: [PATCH 32/33] fix(guardrails): report an uncommitted repo-scope off as staying on (IR-F42) The session-start notice now applies the guard's HEAD-vouched rule: an off that HEAD's copy of the toggle file does not hold is listed as staying on until that change is committed (the existing UNCOMMITTED wording), not as switched off. `chock bundle off` at repo scope ends with one line saying the change applies only once the file is committed, and from the next session. The docs and the customize-guardrails skill state both rules. User scope is unchanged. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/off-status-matches-guard.md | 4 ++ docs/custom-policies.md | 6 ++- src/chock/guardrails/cli.py | 1 + .../data/customize-guardrails/SKILL.md.tmpl | 5 ++- src/chock/guardrails/toggle.py | 22 ++++++++--- src/chock/guardrails/toggle_state.py | 36 +++++++++++++----- tests/test_guardrails_cli.py | 37 +++++++++++++++++++ tests/test_guardrails_toggle_session.py | 26 +++++++++++++ 8 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 changelog.d/off-status-matches-guard.md diff --git a/changelog.d/off-status-matches-guard.md b/changelog.d/off-status-matches-guard.md new file mode 100644 index 00000000..0abd932f --- /dev/null +++ b/changelog.d/off-status-matches-guard.md @@ -0,0 +1,4 @@ +- **A repo-scope `off` the guard does not honour is no longer reported as off.** The session-start notice now lists an + `off` that HEAD's copy of the toggle file does not hold as staying on until that change is committed, as + `chock bundle status` already did, and `chock bundle off` at repo scope ends with a line saying the change applies + only once the file is committed and from the next session. User scope is unchanged. diff --git a/docs/custom-policies.md b/docs/custom-policies.md index d5d63f35..35929029 100644 --- a/docs/custom-policies.md +++ b/docs/custom-policies.md @@ -90,7 +90,11 @@ To propose a policy to the catalog instead, follow the catalog's CONTRIBUTING st A plugin honours an `off` in the repository's or the user's guardrails toggle file only in a session that started after that file's record (the sha256 `chock bundle` keeps beside it) last changed. The plugin binds each session to the files it first saw; if either file changes during the session, every member stays on for the rest of it, and the change takes -effect in the next session. A session start lists every member that is off, by scope, and the Stop-time check refuses a +effect in the next session. At repository scope an `off` also needs a commit: the guard honours it only when HEAD's +copy of the toggle file holds it too, so `chock bundle off` takes effect once the file is committed and a +new session starts. Until then the member stays on, and `chock bundle status` and the session-start notice say so +("is off in the working file but not in HEAD's, so it stays on until that change is committed"). User scope has no +commit rule. A session start lists every member that is off, by scope, and the Stop-time check refuses a turn's end while a file changed during the session switches a member off. The built-in self-protection is never switchable. A client that sends no session id honours an `off` only after a session-start event. diff --git a/src/chock/guardrails/cli.py b/src/chock/guardrails/cli.py index be19740a..74e9d3f5 100644 --- a/src/chock/guardrails/cli.py +++ b/src/chock/guardrails/cli.py @@ -143,6 +143,7 @@ def switch(policy_id: str, state: str, bundle: str | None, scope: str | None) -> if scope == toggle.REPO and state == toggle.OFF: print("Commit it: a policy is off in a repository only once HEAD's copy says so, and") print("`chock check --only baseline` reports a policy switched off against the base as a loosening.") + print(f"This applies only once {toggle.FILENAME} is committed, and from the next session.") return 0 diff --git a/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl b/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl index 063fa813..6649a199 100644 --- a/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl +++ b/src/chock/guardrails/data/customize-guardrails/SKILL.md.tmpl @@ -11,7 +11,10 @@ pull request that turns one off is reviewed as a loosening) or `~/.chock/guardra A repository's own file governs it; the user file counts only where the repository has none. A switch applies from the next session: a session honours an `off` only if its file and record were -already in place when the session started, and the session start lists every member that is off. This is +already in place when the session started, and the session start lists every member that is off. +In a repository an `off` also needs a commit: it applies only once `.chock/guardrails.json` is committed +(HEAD's copy must hold it too), and from the next session. Until then the member stays on, and +`chock bundle status` and the session start say so. The user file has no commit rule. This is not a security boundary against an agent running as the person. ## Members diff --git a/src/chock/guardrails/toggle.py b/src/chock/guardrails/toggle.py index 0ac62ee4..1fb521d7 100644 --- a/src/chock/guardrails/toggle.py +++ b/src/chock/guardrails/toggle.py @@ -51,6 +51,7 @@ "them):\n{listed}\nA toggle is not a security boundary against an agent running as the person; a change made " "during a session applies from the next one." ) +_HELD = "chock: these guardrails stay on:\n{held}\nEach {uncommitted}." def user_path() -> Path: @@ -135,9 +136,18 @@ def moved(starts: list[Path], bound: dict | None) -> list[Path]: return ts.moved(_states(starts), bound) -def switched_off(starts: list[Path]) -> list[str]: - """One line per toggle file in scope that switches a member off: its scope, path and members.""" - return listed_off(list(dict.fromkeys(p for s in starts for p in scopes(s))), source(starts[0])[0], user_path()) +def switched_off(starts: list[Path]) -> tuple[list[str], list[str]]: + """(one line per toggle file in scope that switches a member off, one per repo file whose offs HEAD does not hold). + + A repo file's offs go through `vouched`, as the guard reads them: its scope, path and members. + """ + repo_files = {r / FILENAME for r in map(repo_root, starts) if r} + + def vouch(path: Path, toggles: dict[str, dict[str, str]]) -> tuple[dict, list[str]]: + return vouched(toggles, committed(path.parent.parent)) if path in repo_files else (toggles, []) + + paths = list(dict.fromkeys(p for s in starts for p in scopes(s))) + return listed_off(paths, source(starts[0])[0], user_path(), vouch) def handed_to_user() -> str | None: @@ -223,8 +233,10 @@ def handle(event): if decision is not None or getattr(event, "event", "") != "session_start": return decision bind(wheres, session, fresh=resumed) - listed = switched_off(wheres) - return adapter.Decision.allow(context=_NOTICE.format(listed="\n".join(listed))) if listed else None + listed, held = switched_off(wheres) + notice = [_NOTICE.format(listed="\n".join(listed))] if listed else [] + notice += [_HELD.format(held="\n".join(held), uncommitted=UNCOMMITTED)] if held else [] + return adapter.Decision.allow(context="\n".join(notice)) if notice else None adapter.handle = handle sys.argv = list(argv) diff --git a/src/chock/guardrails/toggle_state.py b/src/chock/guardrails/toggle_state.py index 16403ec0..ac446a01 100644 --- a/src/chock/guardrails/toggle_state.py +++ b/src/chock/guardrails/toggle_state.py @@ -8,6 +8,7 @@ import os import re import time +from collections.abc import Callable from pathlib import Path VERSION = 1 @@ -186,19 +187,34 @@ def has_off(path: Path) -> bool: return False -def listed_off(paths: list[Path], governing: Path | None, user: Path) -> list[str]: - """One line per recorded file in `paths` that switches a member off; `governing` is the file that applies here.""" - lines = [] +def listed_off( + paths: list[Path], + governing: Path | None, + user: Path, + vouch: Callable[[Path, dict], tuple[dict, list[str]]] | None = None, +) -> tuple[list[str], list[str]]: + """(one line per recorded file in `paths` that switches a member off, one per file whose offs `vouch` held back). + + `governing` is the file that applies here; `vouch(path, toggles)` is (the toggles still off, the offs it dropped). + """ + lines, held = [], [] for path in paths: if not os.path.lexists(path) or drift(path): continue try: - off = sorted( - f"{b}/{i}" for b, ms in read(path).items() for i, s in ms.items() if s == OFF and i != PROTECT_ID - ) + toggles = read(path) except ToggleError: continue - if off: - shadowed = "" if governing in (None, path) else " (not applied here: the repository's own file governs)" - lines.append(f"{'user' if path == user else 'repo'} ({path}){shadowed}: {', '.join(off)}") - return lines + dropped = [] + if vouch is not None: + toggles, dropped = vouch(path, toggles) + shadowed = "" if governing in (None, path) else " (not applied here: the repository's own file governs)" + kept = sorted(d for d in dropped if d.split("/", 1)[1] != PROTECT_ID) + for found, into in ((_offs(toggles), lines), (kept, held)): + if found: + into.append(f"{'user' if path == user else 'repo'} ({path}){shadowed}: {', '.join(found)}") + return lines, held + + +def _offs(toggles: dict[str, dict[str, str]]) -> list[str]: + return sorted(f"{b}/{i}" for b, ms in toggles.items() for i, s in ms.items() if s == OFF and i != PROTECT_ID) diff --git a/tests/test_guardrails_cli.py b/tests/test_guardrails_cli.py index a44d67e1..c2d76211 100644 --- a/tests/test_guardrails_cli.py +++ b/tests/test_guardrails_cli.py @@ -64,6 +64,43 @@ def test_off_then_on_round_trips_at_repo_scope(repo: Path, capsys: pytest.Captur toggle.parse((repo / toggle.FILENAME).read_text(encoding="utf-8")) +def test_off_ends_with_the_commit_and_next_session_line_at_repo_scope_only( + repo: Path, home: Path, capsys: pytest.CaptureFixture +) -> None: + line = f"This applies only once {toggle.FILENAME} is committed, and from the next session." + assert cli.main(["off", P]) == 0 + assert capsys.readouterr().out.rstrip().endswith(line) + assert cli.main(["on", P]) == 0 + assert line not in capsys.readouterr().out + assert cli.main(["off", Q, "--scope", "user"]) == 0 + assert line not in capsys.readouterr().out + + +def test_status_shows_an_uncommitted_off_as_on_until_committed(repo: Path, capsys: pytest.CaptureFixture) -> None: + assert cli.main(["off", P]) == 0 + capsys.readouterr() + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +on ", out) and toggle.UNCOMMITTED in out + commit_toggles(repo) + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +off ", out) and toggle.UNCOMMITTED not in out + + +def test_status_at_user_scope_needs_no_commit( + repo: Path, home: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + outside = repo.parent / "plain" + outside.mkdir() + monkeypatch.chdir(outside) + assert cli.main(["off", P]) == 0 + capsys.readouterr() + assert cli.main(["status"]) == 0 + out = capsys.readouterr().out + assert re.search(rf"{P} +off ", out) and toggle.UNCOMMITTED not in out + + def test_user_scope_writes_the_home_file_and_says_the_repo_governs( repo: Path, home: Path, capsys: pytest.CaptureFixture ) -> None: diff --git a/tests/test_guardrails_toggle_session.py b/tests/test_guardrails_toggle_session.py index d9557d85..c76f6a60 100644 --- a/tests/test_guardrails_toggle_session.py +++ b/tests/test_guardrails_toggle_session.py @@ -82,6 +82,32 @@ def test_a_session_start_lists_every_member_that_is_off_by_scope(tmp_path: Path, assert "not a security boundary" in out +def _notice(commands, plugin, repo, home, session) -> str: + out = json.loads(_start(commands, plugin, repo, home, session).stdout) + return out["hookSpecificOutput"]["additionalContext"] + + +def test_a_session_start_does_not_list_an_uncommitted_repo_off_as_off(tmp_path: Path, home: Path) -> None: + plugin, repo, commands = build(tmp_path, "claude") + set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}, committed=False) + out = _notice(commands, plugin, repo, home, "one") + assert "switched off for this session" not in out + assert f"repo ({repo / toggle.FILENAME}): {BUNDLE_ID}/{GUARD_ID}" in out + assert "stay on" in out and toggle.UNCOMMITTED in out + + +def test_a_session_start_lists_a_committed_repo_off_and_an_uncommitted_user_off(tmp_path: Path, home: Path) -> None: + plugin, repo, commands = build(tmp_path, "claude") + outside = tmp_path / "scratch" + outside.mkdir() + set_toggles(repo, {BUNDLE_ID: {GUARD_ID: "off"}}) + set_toggles(home, {BUNDLE_ID: {GATE_ID: "off"}}, committed=False) + assert toggle.UNCOMMITTED not in _notice(commands, plugin, repo, home, "one") + out = _notice(commands, plugin, outside, home, "two") + assert "switched off for this session" in out and toggle.UNCOMMITTED not in out + assert f"user ({home / toggle.FILENAME}): {BUNDLE_ID}/{GATE_ID}" in out + + def test_a_session_start_binds_the_session_and_a_compaction_does_not_rebind_it(tmp_path: Path, home: Path) -> None: plugin, repo, commands = build(tmp_path, "claude") mine = client_payloads.in_session(shell("claude", repo, RM), "one") From c4e9a4a126fda7f0df5542b80d5d40a527a2dd40 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:46:53 -0400 Subject: [PATCH 33/33] rc: sync the compiled runtime, goldens and lockfile (v2) for rc.4 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- chock.lock | 92 +++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/chock.lock b/chock.lock index 8aebbc80..22c0800b 100644 --- a/chock.lock +++ b/chock.lock @@ -1,189 +1,189 @@ { - "lockfile_version": "1", + "lockfile_version": "2", "engine": ">=0.1,<0.2", "packs": [ { "id": "agent-discipline", "version": "0.0.1", "managed": false, - "sha256": "ec930ee2abd4de6717bc0ff71ad22e9d54610970346a9e4ca6d617c1456da956", + "sha256": "e41a849bc2b80f5c3c10fa4ea9883795a3e2ef9a626322c97ce7db41bab71bda", "source": "local", - "artifacts_sha256": "df7dcb3f795b87fde8b0995eb1f84ed36620ccea56b5581be9deb40d7cb60e68" + "artifacts_sha256": "aa8906f9de0aa4a597077410094b8f3cb5cef5e0e50e438f45a1c689160cf3db" }, { "id": "block-destructive-commands", "version": "0.0.1", "managed": false, - "sha256": "ba1108ffdfce230bf66ee9d83a71c46cddb7d7c995759d4daee987439107afc6", + "sha256": "a5c10785be81bb4a391f4e629359454b2e6212772b8475a2ce2f08b03214bab6", "source": "local", - "artifacts_sha256": "b661782f8965ff30f0546b7c5ae442ff0bff89ff1e8ce89b8dc617b0067ed829" + "artifacts_sha256": "606e3ef7e43d9050b317103ae0a0652e2b8548ee8fc2d18aa390539077f8210a" }, { "id": "block-invisible-unicode", "version": "0.0.1", "managed": false, - "sha256": "bc91b8805d38ab92649b5eb69cf7a40c311ad86263ba343935ba4a5a4c3a8d3e", + "sha256": "e34c93313ca3a6352c1d13715c60e8191ad6cdf04542d9f9d6a78cb210e34594", "source": "local", - "artifacts_sha256": "e5853dddd2016f10743c4e28a38efd194ee09e52024bcd08d24d416f27d62bd7" + "artifacts_sha256": "33845353b14a3413b48dce1deeabecc9451858702adc4c12f3ad68dfe560be46" }, { "id": "block-no-verify", "version": "0.0.1", "managed": false, - "sha256": "67c7a06a6676eb0ecc8f929dbdf8fc181ead721c8aa7b6e0f29e1757fdfbb3ff", + "sha256": "4e4fe3bd5a2b1947851bb390f56701c8129619b5a74a745806684f95d2e2105a", "source": "local", - "artifacts_sha256": "b674a89ed872672837d8eac9225086c47530a3163f18e66c729d18df41545b8d" + "artifacts_sha256": "8d9fb089051fba248a366e851b77448117bdf54026fb3fdab1abe645bb479861" }, { "id": "block-wildcard-agent-permissions", "version": "0.0.1", "managed": false, - "sha256": "123c2f3a5c286c2677f00e0ac93cef75c4604e84a1af2464fb89c91b0575aed4", + "sha256": "5a6018554c19e90993f20e5d8a02fd1288c3ebb8431ddcee2967f009f6fab129", "source": "local", - "artifacts_sha256": "0609b0051635e601c2b7cb295849002048b10b3b5d9ae3aa6eee883147a345fc" + "artifacts_sha256": "395d89ad34af4bdb0ed4f0e7a0dce55ebf8ee3d9eada8e90196ffcb190bf1767" }, { "id": "code-safety", "version": "0.0.1", "managed": false, - "sha256": "e2f96ed16c4e714fe7c062418953335f357025eda12ffc651c74554ed27dbc8c", + "sha256": "07a34b6e60452d78f8df0f853d17097e931f5e2e5e4b3fc492277bb99fd651f7", "source": "local", - "artifacts_sha256": "e94db722b253b96a68a6629fb822abc19ec7d8b465fc4bc43967353921bf6ceb" + "artifacts_sha256": "2a3465baf6bc0da2f3ae44e01cf1ddcdd400135438c14836368c6bdc7a29a405" }, { "id": "context-hygiene", "version": "0.0.1", "managed": false, - "sha256": "b428fd0fa0927e343a28334268cfdd214602a7eba0ee27176ec3205e93ffb7ad", + "sha256": "7d77f634a5b13e199a4e6744d3fc2dadffce3891c7bc6a6a538639e37ae1505d", "source": "local", - "artifacts_sha256": "f85d4575557544366e98ca4d2a468511ae370064048a8fecb1a89d44b7ba4117" + "artifacts_sha256": "1cbaddfc2bd8e401ac218252c755d5ef9be607f0b88359f2af59c5323992b285" }, { "id": "git-safety", "version": "0.0.1", "managed": false, - "sha256": "d5c9f5052cb7cda87d42c03b4cfa13e7635be542745e4b183ef87220da9e6749", + "sha256": "d57e89caab2d9c0608beb4c6acad049c1dc76150fa2caae17f43daa2d77264c4", "source": "local", - "artifacts_sha256": "4560a72cb117582027f70a2b01130e89088e6fb4596c7230d5779940a5864deb" + "artifacts_sha256": "b9db0e16e1009cb85d051c939ff47093497f8540d648adc33b90b6d1a39f08d8" }, { "id": "injection-defense", "version": "0.0.1", "managed": false, - "sha256": "ff181c5171d6360b9add0ec8106189afc97675661899960512e708ea8496bc76", + "sha256": "722c6235b5366a0916625a2acb4f7383b07b04f2691a99b3d5cb8203d922fa45", "source": "local", - "artifacts_sha256": "c76a9bd12792ccecc411093054f16aac210953f3324b054586f3c8af3f7a0034" + "artifacts_sha256": "a6f82e74b77c04a051dbc1058b1e1124eb8b78d12427af3b947b6d42686331be" }, { "id": "memory-discipline", "version": "0.0.1", "managed": false, - "sha256": "8e410a79774cc33335242580a7acfae5164b043e596c560ed0ca4ec0abe88062", + "sha256": "e795ef722ee5eb0fdc9af872666d24b90a7a020dff50e56bd8f418a9a868a11a", "source": "local", - "artifacts_sha256": "1aafc65b5f94357a12c022f51ba34035444e28f85f5d5176cf3a78552565867b" + "artifacts_sha256": "906287d93337b6080f68621350512cbf8a73c381657c08faeeddab8a2ec45a6b" }, { "id": "minimal-content", "version": "0.0.1", "managed": false, - "sha256": "0be9365f661d0a63e3b565a434ca9bd865818baecef425fbfe320cc77a6a2ce7", + "sha256": "00bb15c96c603ff92fdb7c7fd9fcc610c36f05fdcd5147763931acbd96818853", "source": "local", - "artifacts_sha256": "318e534a8fbca46a300a7dc0d6ee0c3f2c5d9bbd5952999a188bf19ee092c29f" + "artifacts_sha256": "a6d131a5036887ab769dbdcbe01c761f5825b656a6cae30607f900639cb29ec7" }, { "id": "pin-github-actions", "version": "0.0.1", "managed": false, - "sha256": "53677765d84916220ac717424bbdf9dce4089257bfc873a3ed354cdb38f7865c", + "sha256": "a3afaef1791bf3b84cfd48b91c8dd5553a6a2fbd99d0e9df6423cb100a4e68d7", "source": "local", - "artifacts_sha256": "4c927b55c5e37e555dbec88628f0c47ea08a42bd08bd6f2cc911d97a24e8d57f" + "artifacts_sha256": "99d58c2e84918370cac3b75a6fa2cc8d530c63dfa8189c5a5e4835b06859dfdd" }, { "id": "pre-generated-scripts", "version": "0.0.1", "managed": false, - "sha256": "3605fe3806364cc16407d81c6522783ab855c950c630dd464b196067b7fce136", + "sha256": "17227f0f6e007ddd445f0ace6457b5d3ffc66ed23f2d52b75af9f9eb669191c5", "source": "local", - "artifacts_sha256": "36bb984d05a83a67c124d6c85c97de3fa35b362d3e3514dccb3a59f6f3436224" + "artifacts_sha256": "ef6d558f78d7f86bc66b7c755b3eac5324a44db9e240ef29dc24a7d6e27f5380" }, { "id": "protect-agent-config", "version": "0.0.1", "managed": false, - "sha256": "29c1a18fa1bd3ee2ab34a0ea1d1c07617a5841b8afbd65ad8704d31093c4fcc9", + "sha256": "dc20b58f82ae4c5c77759d32d15a51a431c76039c3cc34b1d4a892cb2f264aef", "source": "local", - "artifacts_sha256": "8d0aab010a51d39faba904aa4244b2acdff2280345e79e441e6011ee0dae4843" + "artifacts_sha256": "aa0f584a3da1309770c9fbda7d082970e7d279d298f06ca625520a80d85e278f" }, { "id": "protect-commit-privacy", "version": "0.0.1", "managed": false, - "sha256": "22045da796ba466d8043b7d4322b6dbfdcc46e3fd9709221c505acdf24d08119", + "sha256": "07d12989cb3a4a20a2d48579f2b51c5c5d6e14e4367412250d2d3fe682759fdd", "source": "local", - "artifacts_sha256": "db35858d04820994dc661f2bc437a980842be296a8fdd5c2a3ade921e293ce88" + "artifacts_sha256": "0aab76e19d6744ed014507bd15d6ed15e2db88d1777cd5bfe2295d557c7bf39b" }, { "id": "protect-main-branch", "version": "0.0.1", "managed": false, - "sha256": "a0c154b0e2fc31a90f518419d29177a487702f881e7f0158e566271d5a8a61a8", + "sha256": "d1709293d7ee4ef502457e2e65eb65516daebd6ba5f7172db744bfdd8db63002", "source": "local", - "artifacts_sha256": "18bb4772936ece0e374fb48e1a09fdbf946fb82381ed30687c0bcc8a10d4643c" + "artifacts_sha256": "dd31407d52e400f6f74e515cdacb8b3e188b97133db255f88bf73791bbee2eb4" }, { "id": "require-review-evidence", "version": "0.0.1", "managed": false, - "sha256": "6746768db59903cf017c66799b68ca4290870655ab6ede665ef2fde035c87875", + "sha256": "67b76ca30e7e9add2d284b62da210344a828e6de3d28e5f410ff81c5842beaa4", "source": "local", - "artifacts_sha256": "6bab271e1984302fd4c659739aac6d0029f5f2215af90182d3d7cbb59984f9c8" + "artifacts_sha256": "b16de533c1019379d5f5c7420bd0934f1430e6716f09faa8677763e20bab5a02" }, { "id": "scan-secrets", "version": "0.0.1", "managed": false, - "sha256": "ac98dc12772df80b1133d262996d7e52b279281c1f1df2095693aceba58952ec", + "sha256": "d04914f3b1ff3d4a2983ec8bc9fd3465556d5744ec41d03faca61ddd8c4917f0", "source": "local", - "artifacts_sha256": "e01cc23847398082eeb2bb7f9039c2b7f994f89a9404863afc3f67b3fa7a6c8f" + "artifacts_sha256": "5c9540a8b0ca8ca8bb88dde396adb71fe725eace2e932dc90c18d8c69d6b67f5" }, { "id": "test-integrity", "version": "0.0.1", "managed": false, - "sha256": "c140c5ca23c3e9dde10712b889840ee9a4a1976f7cb328d088f8f781e03c274e", + "sha256": "965b849688591738ddb1b6a7f5d20deaa0483e33c6daa117d37a592126512fca", "source": "local", - "artifacts_sha256": "c5d2e1f614b609c98c3da3c2e33beb9196beeacec4e3f7c7541f3bd1b08c1db4" + "artifacts_sha256": "b013983cfca823013ac954817d053c7ce8b7accdaa6b0484a2f7105a655550e8" }, { "id": "token-efficiency", "version": "0.0.1", "managed": false, - "sha256": "cea845541e17c74ce0ffe8fd269c1b00d649d96c61a0b504a185b7ed6e247b4e", + "sha256": "771cbb776e8b86602d330b878073046bd7becbc44827e47f745bb5970973ceb4", "source": "local", - "artifacts_sha256": "dbc26807e0d6ab796e909fd55a874546e6777bbdfb288edc7c3e7e92bd7cad5b" + "artifacts_sha256": "c14412edb8abd9e633198f2fc08bc09af74e682939e1a076da1fea32c3fab97a" }, { "id": "verify-dependency-exists", "version": "0.0.1", "managed": false, - "sha256": "005680c3df7ab7272edf4e45d3ea1f56dec2fedd8a8f78b75b1555c8ccbe762b", + "sha256": "495a8175b1dbef29dc7b741157f96d7da6c5e8cc207a2f1a1fa86ca06f2961f8", "source": "local" }, { "id": "verify-framework-conformance", "version": "0.0.1", "managed": false, - "sha256": "d8d03b846cef3bcfc66ed036e3d69b3abd6762c1ff2f60b8ecc7c3d7da3e9609", + "sha256": "8cf8ec272df2d224b264ca662e7ca6798130369a4d70a14d454566654ada9917", "source": "local", - "artifacts_sha256": "ea12f0f8a86bcab67484585b30d72226a6be554c4dc51a0e2f66d6c29d38d897" + "artifacts_sha256": "3c4f69d24ba8acbe41c9c7a1a334dc3fb9d722d39c85f601e6f214a92917210c" }, { "id": "yagni", "version": "0.0.1", "managed": false, - "sha256": "2546154d5eb24bbfa8510b69c809b19bb5546ae112bae29be2de26e492a42437", + "sha256": "58954cf1e9d3634f67b759d65fc16bb2f97782b1815a71ea558b4dfd47b66628", "source": "local", - "artifacts_sha256": "80297724a9306b6a8a3278780a02e7bbdcb8dafd7b197e66fed8ba301ef090ab" + "artifacts_sha256": "12038a1b15c829fc3326b2a9667e0ea7276278be51620a58b2efdb4b60a019b5" } ] }