@bongos/core 1.19.845 → 1.19.847
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +30 -20
- package/docs/adr/0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +31 -0
- package/src/module-api.js +1 -1
- package/tests/box_ship_permissions.mjs +130 -1
- package/tests/claude_materialize.mjs +1 -0
- package/tests/git_guard.mjs +23 -1
- package/tests/hook_shell_tools.mjs +115 -0
- package/tests/lifecycle-card.mjs +45 -0
- package/tests/secret_gate.mjs +45 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.847",
|
|
6
|
+
"core_contract": "1.19.847",
|
|
7
|
+
"source_commit": "2f39aee80129563c843fb3b14b31a00db502f851",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-17T19:
|
|
9
|
+
"built_at": "2026-09-17T19:42:18.427Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 496,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2273,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2795,
|
|
20
|
+
"tree_sha256": "6e729d424a5377af457b9b765d44e5af905a4228a5f2df22ee259583224231a0",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2009,10 +2009,15 @@
|
|
|
2009
2009
|
"mode": "0000644",
|
|
2010
2010
|
"sha256": "62ffb9c1aeeaaafd5c51767f1ffff1c67adb3563e8d55ceb81ef77511184faa4"
|
|
2011
2011
|
},
|
|
2012
|
+
{
|
|
2013
|
+
"path": "docs/adr/0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md",
|
|
2014
|
+
"mode": "0000644",
|
|
2015
|
+
"sha256": "104b5675fe52c1725dbbc78ab6fde9fd676b4020bf0e3f05411733c95d4342e0"
|
|
2016
|
+
},
|
|
2012
2017
|
{
|
|
2013
2018
|
"path": "docs/adr/README.md",
|
|
2014
2019
|
"mode": "0000644",
|
|
2015
|
-
"sha256": "
|
|
2020
|
+
"sha256": "61dd72c5f0d4157062fb93585252448e5493afd25cb014fb747bf0e20037a2ab"
|
|
2016
2021
|
},
|
|
2017
2022
|
{
|
|
2018
2023
|
"path": "docs/api-reference.md",
|
|
@@ -2567,7 +2572,7 @@
|
|
|
2567
2572
|
{
|
|
2568
2573
|
"path": "docs/file-map.md",
|
|
2569
2574
|
"mode": "0000644",
|
|
2570
|
-
"sha256": "
|
|
2575
|
+
"sha256": "eaa7dca723187a394a3b6ee252bbb81b13b76cc384a4651b6ee74a859af9d611"
|
|
2571
2576
|
},
|
|
2572
2577
|
{
|
|
2573
2578
|
"path": "docs/handoff-template.md",
|
|
@@ -2577,7 +2582,7 @@
|
|
|
2577
2582
|
{
|
|
2578
2583
|
"path": "docs/module-api-changelog.md",
|
|
2579
2584
|
"mode": "0000644",
|
|
2580
|
-
"sha256": "
|
|
2585
|
+
"sha256": "6ba668dea0868f1ab54467d18278e494c3fc4eb121d19313c6d85d2c80d89e63"
|
|
2581
2586
|
},
|
|
2582
2587
|
{
|
|
2583
2588
|
"path": "docs/modules-contract.md",
|
|
@@ -7902,12 +7907,12 @@
|
|
|
7902
7907
|
{
|
|
7903
7908
|
"path": "package-lock.json",
|
|
7904
7909
|
"mode": "0000644",
|
|
7905
|
-
"sha256": "
|
|
7910
|
+
"sha256": "6adf112f9a6622910cb13b7dd741da5a7d1c84976c286e13068974bb8717c629"
|
|
7906
7911
|
},
|
|
7907
7912
|
{
|
|
7908
7913
|
"path": "package.json",
|
|
7909
7914
|
"mode": "0000644",
|
|
7910
|
-
"sha256": "
|
|
7915
|
+
"sha256": "f7dc6b6b5522f7b4e1bddf7f62cd371fda49d1304497cb6b5de979c5f7a2ac93"
|
|
7911
7916
|
},
|
|
7912
7917
|
{
|
|
7913
7918
|
"path": "public-docs/index.html",
|
|
@@ -8217,7 +8222,7 @@
|
|
|
8217
8222
|
{
|
|
8218
8223
|
"path": "scripts/gds/claude-materialize.js",
|
|
8219
8224
|
"mode": "0000644",
|
|
8220
|
-
"sha256": "
|
|
8225
|
+
"sha256": "de8dcb51a250334621b104e99f7bc5f857f4422718c3c3ed8519cd5d0ebc8739"
|
|
8221
8226
|
},
|
|
8222
8227
|
{
|
|
8223
8228
|
"path": "scripts/gds/claude-md-template.js",
|
|
@@ -9842,7 +9847,7 @@
|
|
|
9842
9847
|
{
|
|
9843
9848
|
"path": "src/module-api.js",
|
|
9844
9849
|
"mode": "0000644",
|
|
9845
|
-
"sha256": "
|
|
9850
|
+
"sha256": "c2347ac91ce5aa5e5f46bcb3e0734bd151e2abbe1f2347290d75891229223a17"
|
|
9846
9851
|
},
|
|
9847
9852
|
{
|
|
9848
9853
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10407,7 +10412,7 @@
|
|
|
10407
10412
|
{
|
|
10408
10413
|
"path": "tests/box_ship_permissions.mjs",
|
|
10409
10414
|
"mode": "0000644",
|
|
10410
|
-
"sha256": "
|
|
10415
|
+
"sha256": "063c94d759fa8a79b8c402145801ae2a4d9863bef591fb5adf57697e15effc39"
|
|
10411
10416
|
},
|
|
10412
10417
|
{
|
|
10413
10418
|
"path": "tests/box_sync.mjs",
|
|
@@ -10562,7 +10567,7 @@
|
|
|
10562
10567
|
{
|
|
10563
10568
|
"path": "tests/claude_materialize.mjs",
|
|
10564
10569
|
"mode": "0000644",
|
|
10565
|
-
"sha256": "
|
|
10570
|
+
"sha256": "42df08d261aaeb7cf94db3eef0e95b06af138d8d317b57f12dc4cb2c23d9f81a"
|
|
10566
10571
|
},
|
|
10567
10572
|
{
|
|
10568
10573
|
"path": "tests/clawback_confirmed_backlog.mjs",
|
|
@@ -11127,7 +11132,7 @@
|
|
|
11127
11132
|
{
|
|
11128
11133
|
"path": "tests/git_guard.mjs",
|
|
11129
11134
|
"mode": "0000644",
|
|
11130
|
-
"sha256": "
|
|
11135
|
+
"sha256": "95ec763418aceec86f239edc1e78154c0fb9f67ffcb566c3062c75eceeb09b24"
|
|
11131
11136
|
},
|
|
11132
11137
|
{
|
|
11133
11138
|
"path": "tests/git_merge_regen.mjs",
|
|
@@ -11744,6 +11749,11 @@
|
|
|
11744
11749
|
"mode": "0000644",
|
|
11745
11750
|
"sha256": "7035673d4b9e55166acc19d5c40a2128c0aceb233bca6856eace34bebc1139e4"
|
|
11746
11751
|
},
|
|
11752
|
+
{
|
|
11753
|
+
"path": "tests/hook_shell_tools.mjs",
|
|
11754
|
+
"mode": "0000644",
|
|
11755
|
+
"sha256": "88db0fedd800ad8a8f6fd9d912844238bfb7bb944d5bf648eb8818eca6854466"
|
|
11756
|
+
},
|
|
11747
11757
|
{
|
|
11748
11758
|
"path": "tests/hook_user_agents.mjs",
|
|
11749
11759
|
"mode": "0000644",
|
|
@@ -12052,7 +12062,7 @@
|
|
|
12052
12062
|
{
|
|
12053
12063
|
"path": "tests/lifecycle-card.mjs",
|
|
12054
12064
|
"mode": "0000644",
|
|
12055
|
-
"sha256": "
|
|
12065
|
+
"sha256": "a308fb62ff11b442c20e3eeb3eb9617a1b3ad1abcba4ec4b2cc6b349b3e6aa5e"
|
|
12056
12066
|
},
|
|
12057
12067
|
{
|
|
12058
12068
|
"path": "tests/lifecycle_breakdown_view.mjs",
|
|
@@ -13152,7 +13162,7 @@
|
|
|
13152
13162
|
{
|
|
13153
13163
|
"path": "tests/secret_gate.mjs",
|
|
13154
13164
|
"mode": "0000644",
|
|
13155
|
-
"sha256": "
|
|
13165
|
+
"sha256": "46ea51955dbcc4874684fdb06b298e744e7e019d2f3971ae7953d37ede96958a"
|
|
13156
13166
|
},
|
|
13157
13167
|
{
|
|
13158
13168
|
"path": "tests/security_docs.mjs",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# ADR 0304 — Routine reads run without a prompt; anything a flag can turn into a write is exact-form
|
|
2
|
+
|
|
3
|
+
**Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003992](https://cloudbongos.com/builders#/task/1003992) (goal 1000095 — BONGOS-V2)
|
|
4
|
+
|
|
5
|
+
**Related:** [ADR 0104](0104-trust-gds-api-channel-in-auto-mode.md) (the allow-list this extends — it named read wrappers as the next step) · [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (an allow-rule grants no authority) · [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md) (git-guard is the cooperative layer) · [ADR 0302](0302-the-third-identical-failure-stops-the-session-and-asks.md) (same investigation)
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The shipped `.claude/settings.json` pre-allowed six commands. Much of what a session runs to look
|
|
10
|
+
around — `gh pr view`, `git fetch origin`, `node scripts/gds/status.js` — stopped and asked the
|
|
11
|
+
builder for permission. The 2026-09-17 investigation of a non-technical builder's stalls found
|
|
12
|
+
this is a prompt they cannot evaluate: they are asked to approve a command they do not read, many
|
|
13
|
+
times an hour, and the only safe answer they have is "yes". A prompt that is always answered yes
|
|
14
|
+
protects nothing and teaches the builder to stop reading prompts.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
Pre-allow the **read-only subset**, in the core's `.claude/settings.json` and — rule for rule, in
|
|
19
|
+
verb form — in the starter an instance receives (`STARTER_ALLOW`, task 1003962):
|
|
20
|
+
|
|
21
|
+
- `git status` as a prefix rule.
|
|
22
|
+
- `git branch` and `git fetch` as **exact forms only** (`git branch --list`, `--show-current`,
|
|
23
|
+
`-a`, `-vv`; `git fetch`, `git fetch origin`, `git fetch origin main`).
|
|
24
|
+
- `gh pr view | checks | list` and `gh run view | list` as prefix rules.
|
|
25
|
+
- The gds CLIs audited read-only under every flag they accept: `start`, `status`, `recall`, `me`,
|
|
26
|
+
`doctor`, `preflight`, `skill-preflight`, `ship-check`, `fitness`, `strand-watch`,
|
|
27
|
+
`stale-worktrees`, `read-session-export`. (`recall` POSTs, but to the search endpoint — a query.
|
|
28
|
+
`ship-check` and `fitness` run the repo's own checks, the same trust as the already-allowed
|
|
29
|
+
`ship.js`: a rule naming a script in the tree trusts the tree.) Audited and left out:
|
|
30
|
+
`backlog-review.js`, whose default walk POSTs promote / abandon, and `key-status.js`, whose
|
|
31
|
+
`--recheck` rewrites the local session file.
|
|
32
|
+
|
|
33
|
+
**The rule behind the split.** `Bash(x:*)` is a prefix rule: it allows every flag that can follow
|
|
34
|
+
`x`. So a command gets a prefix rule only when no flag turns it into a write or an exec.
|
|
35
|
+
`git fetch --upload-pack=<cmd>` runs a command, `git fetch origin x:main` moves a local branch, and
|
|
36
|
+
`git branch -D` deletes one — so those two subcommands are listed in the exact forms a session
|
|
37
|
+
actually uses, and anything else under them still asks.
|
|
38
|
+
|
|
39
|
+
**`git diff`, `git log` and `git show` get no rule at all.** The task asked for them, the first
|
|
40
|
+
cut had them as prefix rules, and review caught what that did — both reproduced on this repo:
|
|
41
|
+
`git log -1 --format=<text> --output=<path>` writes chosen content to any path (a
|
|
42
|
+
`.git/hooks/pre-push` is code execution), and `git diff --no-index -- /dev/null <path>` prints any
|
|
43
|
+
file, including the ones the `Read(...)` deny-list protects. Claude Code already runs the
|
|
44
|
+
read-only forms of these commands without a prompt in every permission mode, and checks their
|
|
45
|
+
flags when it does; an allow-rule is matched first and switches that check off. So for these three
|
|
46
|
+
the rule bought nothing and cost the flag check. By this ADR's own test they fail it.
|
|
47
|
+
|
|
48
|
+
**git-guard stays the backstop, and the lists are pinned.** A PreToolUse hook runs whether or not
|
|
49
|
+
a command is pre-allowed, so git-guard still blocks its four shapes. `tests/box_ship_permissions.mjs`
|
|
50
|
+
goes further: no allow-rule may *cover* a command git-guard blocks (blocked samples per guard rule;
|
|
51
|
+
a new guard rule fails the suite until it has one); no allow-rule may cover a write dressed as a
|
|
52
|
+
read (`--output`, `--no-index`, `--upload-pack`, a refspec); and the whole allow-list is pinned —
|
|
53
|
+
every rule is a named gds script or one of the thirteen git/gh strings in the test — so
|
|
54
|
+
widening it is a deliberate, reviewed edit.
|
|
55
|
+
|
|
56
|
+
## Consequences
|
|
57
|
+
|
|
58
|
+
- A session on a laptop or a box in auto mode reads PR and CI state, fetches, and runs the
|
|
59
|
+
read-only gds CLIs without interrupting the builder. `bypassPermissions` boxes are unaffected
|
|
60
|
+
(the rules are a no-op there).
|
|
61
|
+
- An allow-rule matches the command text, not the program: it is a convenience, never a security
|
|
62
|
+
boundary. That is why the dangerous cases are handled by *not writing the rule* rather than by an
|
|
63
|
+
`ask` or `deny` pattern for `--output` — Claude Code's own docs note such argument patterns are
|
|
64
|
+
evaded by quoting (`--out''put`).
|
|
65
|
+
- Rules are `Bash` only — the evidence is a hosted Linux box. `PowerShell(...)` twins are a
|
|
66
|
+
follow-up ([task 1004014](https://cloudbongos.com/builders#/task/1004014)), possible now that
|
|
67
|
+
[task 1003999](https://cloudbongos.com/builders#/task/1003999) has put that tool behind the same
|
|
68
|
+
guards; allowing first and guarding second was the wrong order.
|
|
69
|
+
- An instance that already has a `.claude/settings.json` keeps its own; only a fresh starter carries
|
|
70
|
+
the new rules (the never-clobber rule of task 1003962).
|
|
71
|
+
|
|
72
|
+
## Rejected
|
|
73
|
+
|
|
74
|
+
- **`Bash(git:*)` or `Bash(gh:*)`.** One rule instead of thirteen, and it pre-allows `git push`,
|
|
75
|
+
`git reset --hard`, `gh pr merge` and `gh api -X DELETE`.
|
|
76
|
+
- **Prefix rules for `git diff | log | show`, with `ask` rules for `--output` and `--no-index`.**
|
|
77
|
+
Evaluated in the order deny → ask → allow, so it works on the text a session normally writes — and
|
|
78
|
+
is evaded by quoting. The harness's built-in handling already covers the safe forms, so the pair
|
|
79
|
+
of rules would add a leaky guard to remove a prompt that does not exist.
|
|
80
|
+
- **Prefix rules for `git fetch` / `git branch --list`.** `git branch --list -D x` is covered by the
|
|
81
|
+
prefix and blocked by git-guard — harmless in practice (git refuses the combination) but it makes
|
|
82
|
+
"nothing the guard blocks is allowed" false, and that sentence is worth keeping true.
|
|
83
|
+
- **`bypassPermissions` everywhere** (task 1251's box default). It is a mode, not a list: a builder
|
|
84
|
+
who cycles modes or works from a laptop is back to prompts, and it removes the prompt for writes too.
|
package/docs/adr/README.md
CHANGED
|
@@ -395,3 +395,4 @@ This keeps the decision history honest and traceable.
|
|
|
395
395
|
| 0301 | [**A red PR names its failing tests in a commit status, and says so when it cannot see** ([task 1003988](https://cloudbongos.com/builders#/task/1003988), goal 1000095). One pull request took nine red CI runs in four hours because the builder box could not learn which test failed: without Checks:read GitHub nulls every check-run node, the server dropped the holes silently, and the red PR was reported as a review-or-pending wait. Now the merge state carries checks_unreadable and a red, unreadable rollup reads failing_check_unreadable; the unit workflow posts the failing test names as a unit-report commit STATUS (readable without the permission) from a separate job that holds the only write token and checks out no PR code; the server returns it as failing_tests, reduced to test-list vocabulary because the PR controls the workflow that wrote it.](0301-a-red-pr-names-its-failing-tests-in-a-commit-status.md) | GDS / shipping pipeline |
|
|
396
396
|
| 0302 | [**The third identical failure stops the session and asks** ([task 1003990](https://cloudbongos.com/builders#/task/1003990), goal 1000095). Sessions looped without noticing: one PR re-shipped into the same red check nine times, and 45 of 169 claim attempts were the same refusal retried minutes apart. A PostToolUse hook now counts walls per session — the signature is the `[otb-plain]` card's "What happened" line + the task id + the RED line, so a red check naming different failing tests each run reads as progress — and on the third identical one injects a single instruction: stop retrying, diagnose (path named), or /ask-for-help. Waiting cards ("Nothing to do…") are never counted; git-guard and worktree-guard count their own blocks through the same helper; it never blocks a tool call and echoes nothing a tool printed. Rejected: counting any repeated failing command (red→green is that), blocking the fourth attempt (a new gate, ADR 0162), server-side detection (cannot see guard blocks or speak into the session).](0302-the-third-identical-failure-stops-the-session-and-asks.md) | GDS / sessions / hooks |
|
|
397
397
|
| 0303 | [**Provisioning verifies the base Caddyfile, and never edits it** ([task 1002860](https://cloudbongos.com/builders#/task/1002860), goal 1000106, from idea 1000524). The runner assumed the box's `/etc/caddy/Caddyfile` carried the `on_demand_tls` tls-check ask and `import /etc/caddy/sites/*.caddy`, and reloaded with `allowFail` — a box missing either provisioned "successfully" and the instance was unreachable. It now reads Caddy's RESOLVED config (`caddy adapt`: unprivileged, no key access, indifferent to how the directives are written) before touching the box (the ask) and after writing the snippet (the domain is served ⇒ the import reaches it), refusing with the exact line to add, and the reload is loud. Rejected: editing the base file (`on_demand_tls` must sit inside the global block an append cannot place, and a bad write takes down the control plane's own apex); `caddy validate` (loads certificate keys, needs root).](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) | provisioning / infra |
|
|
398
|
+
| 0304 | [**Routine reads run without a prompt; anything a flag can turn into a write is exact-form** ([task 1003992](https://cloudbongos.com/builders#/task/1003992), goal 1000095). The shipped `.claude/settings.json` pre-allowed six commands, so every `gh pr view` or `git fetch` asked a non-technical builder to approve a command they cannot evaluate. The read-only subset is now pre-allowed — `git status`, `gh pr view/checks/list`, `gh run view/list` and the gds CLIs audited read-only under every flag as prefix rules; `git branch` and `git fetch` as exact forms only, because a flag turns either into a write or an exec — and the instance starter mirrors it rule for rule. `git diff/log/show` get NO rule: the harness already runs their read-only forms unprompted with a flag check, and a prefix rule switched that check off (`--output=<path>` writes anywhere, `diff --no-index` reads deny-listed files — both reproduced in review). A test pins the whole allow-list, that no rule covers a command git-guard blocks, and that no rule covers a write dressed as a read. Rejected: `Bash(git:*)`, prefix-plus-`ask` for diff/log/show (argument patterns are evaded by quoting), prefix rules for fetch/branch, `bypassPermissions` everywhere.](0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md) | GDS / sessions / permissions |
|
package/docs/file-map.md
CHANGED
|
@@ -475,10 +475,10 @@ To install on a new machine, symlink each routine's directory under `~/.claude/s
|
|
|
475
475
|
|
|
476
476
|
```
|
|
477
477
|
.claude/hooks/
|
|
478
|
-
├── secret-gate.js ← PreToolUse (Edit|Write|MultiEdit|NotebookEdit|Bash): cooperative, fail-OPEN block of secret-shaped content before it reaches the tree or a shell (task 1003125, ADR 0022)
|
|
478
|
+
├── secret-gate.js ← PreToolUse (Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell): cooperative, fail-OPEN block of secret-shaped content before it reaches the tree or a shell (task 1003125, ADR 0022)
|
|
479
479
|
├── run-node.sh ← the node resolver every hook command runs through (task 1003548): $OTB_NODE_BIN → PATH → known install dirs → nvm; one actionable line + exit 1 when none is found. 1,055 hook runs had died silently with `node: command not found`
|
|
480
480
|
├── edit-hints.js ← PostToolUse (Edit|Write|MultiEdit): lints a saved SKILL.md's frontmatter (scripts/gds/skill-lint.js) and names the generator an edited path makes stale (copy-registry, repo-map blocks, file-map, openapi, §13 snippet) with its command (task 1003548)
|
|
481
|
-
├── git-guard.js ← PreToolUse (Bash): same contract as secret-gate — blocks `git stash` (repo-global stack, learning 1000215), raw `git worktree remove` (follows the node_modules junction, task 1002809), `git push origin main` (PR-only) and `git branch -D`, each with its remedy; kill-switch BONGOS_GIT_GUARD_OFF=1 (task 1003365)
|
|
481
|
+
├── git-guard.js ← PreToolUse (Bash|PowerShell): same contract as secret-gate — blocks `git stash` (repo-global stack, learning 1000215), raw `git worktree remove` (follows the node_modules junction, task 1002809), `git push origin main` (PR-only) and `git branch -D`, each with its remedy; kill-switch BONGOS_GIT_GUARD_OFF=1 (task 1003365)
|
|
482
482
|
├── _core-resolve.js ← shared helper, not a hook: locates a core module/script across the monolith vs `node_modules/@cloudbongos/core` layouts; required by most hooks here and by four routine scripts under `.claude/scheduled-tasks/`
|
|
483
483
|
├── cascade-route.js ← PreToolUse (Task|Agent): cheapest-sufficient start-model advisory for a subagent spawn (ADR 0024); inert unless OTB_AUTONOMY_ENABLED=1
|
|
484
484
|
├── land-watch-card.js ← UserPromptSubmit: delivers the SHIPPED banner once an async CI land completes (reads `scripts/gds/land-watch.js`)
|
|
@@ -489,8 +489,9 @@ To install on a new machine, symlink each routine's directory under `~/.claude/s
|
|
|
489
489
|
├── recall-hints.js ← SessionStart: when the session opens holding a claim, surface the top project-knowledge hits for that task via POST /api/gds/search (#963/V4.R25, ADR 0060)
|
|
490
490
|
├── conductor.js ← UserPromptSubmit: MAS Conductor advisory + dispatch directive (#427/#439); self-gates on OTB_CONDUCTOR_OFF / OTB_SUBAGENT
|
|
491
491
|
├── session-cards.js ← UserPromptSubmit: deterministic delivery of READ-ONLY in-session cards (builder-start, status) — pre-renders the card + injects a one-step render directive (task 1288)
|
|
492
|
-
├── lifecycle-card.js ← PostToolUse: deterministic delivery of MUTATING lifecycle cards (claim/ship/release) — the write-side mirror of session-cards.js (task 1288)
|
|
492
|
+
├── lifecycle-card.js ← PostToolUse (Bash|PowerShell): deterministic delivery of MUTATING lifecycle cards (claim/ship/release) — the write-side mirror of session-cards.js (task 1288)
|
|
493
493
|
├── stuck-detector.js ← PostToolUse + PostToolUseFailure (Bash|PowerShell): the third time one session hits the same wall — the same `[otb-plain]` card for the same task id, or the same red check — it injects ONE instruction: stop retrying, diagnose (path named), or /ask-for-help. Never blocks; silent with no card; kill-switch BONGOS_STUCK_DETECTOR_OFF=1 (task 1003990)
|
|
494
|
+
├── shell-tools.js ← shared helper, not a hook: the ONE list of tool names that carry a shell command (`Bash`, `PowerShell`) + the `matcher` string the settings entries must use. git-guard, secret-gate, lifecycle-card and stuck-detector all test through it; four private copies of `Bash` had left every Windows session unguarded, silently (task 1003999, pinned by tests/hook_shell_tools.mjs)
|
|
494
495
|
├── stuck-strikes.js ← shared helper, not a hook: the per-session strike counter (hashed signatures in os.tmpdir()) + the static instruction text; stuck-detector.js, git-guard.js and worktree-guard.js all count through it (task 1003990)
|
|
495
496
|
├── pre-compact.js ← PreCompact: thin reader around eviction-policy.js — emits an eviction directive (blocks to drop + one-line recovery stubs) at the compaction seam (task 1349)
|
|
496
497
|
├── eviction-policy.js ← pure deterministic in-session compaction classifier (transcript turns → eviction set + replacement stubs); no I/O, unit-tested in tests/eviction_policy.mjs; consumed by pre-compact.js (task 1348)
|
|
@@ -2164,5 +2164,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2164
2164
|
landed since 1.19.843 with no explicit bump. run 35262101669. (task 1002620)
|
|
2165
2165
|
1.19.845 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2166
2166
|
landed since 1.19.844 with no explicit bump. run 35263096514. (task 1002620)
|
|
2167
|
+
1.19.846 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2168
|
+
landed since 1.19.845 with no explicit bump. run 35264882014. (task 1002620)
|
|
2169
|
+
1.19.847 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2170
|
+
landed since 1.19.846 with no explicit bump. run 35266409194. (task 1002620)
|
|
2167
2171
|
---------------------------------------------------------------------------
|
|
2168
2172
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.847",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.847",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.847",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -347,6 +347,37 @@ const STARTER_ALLOW = [
|
|
|
347
347
|
'Bash(bongos reauth:*)',
|
|
348
348
|
'Bash(bongos paste-token:*)',
|
|
349
349
|
'Bash(bongos exec scripts/gds/api.js:*)',
|
|
350
|
+
// The read-only subset (task 1003992, ADR 0304). `git branch` and `git fetch`
|
|
351
|
+
// are exact forms on purpose: a flag turns either into a write or an exec. No
|
|
352
|
+
// `git diff|log|show` rule, also on purpose: the harness already runs their
|
|
353
|
+
// read-only forms unprompted, and a prefix rule would switch off its flag check
|
|
354
|
+
// (`--output=<file>` writes anywhere, `diff --no-index` reads any file).
|
|
355
|
+
// tests/box_ship_permissions.mjs pins this list to the core's, rule for rule.
|
|
356
|
+
'Bash(bongos start:*)',
|
|
357
|
+
'Bash(bongos status:*)',
|
|
358
|
+
'Bash(bongos recall:*)',
|
|
359
|
+
'Bash(bongos exec scripts/gds/me.js:*)',
|
|
360
|
+
'Bash(bongos doctor:*)',
|
|
361
|
+
'Bash(bongos exec scripts/gds/preflight.js:*)',
|
|
362
|
+
'Bash(bongos exec scripts/gds/skill-preflight.js:*)',
|
|
363
|
+
'Bash(bongos exec scripts/gds/ship-check.js:*)',
|
|
364
|
+
'Bash(bongos exec scripts/gds/fitness.js:*)',
|
|
365
|
+
'Bash(bongos exec scripts/gds/strand-watch.js:*)',
|
|
366
|
+
'Bash(bongos exec scripts/gds/stale-worktrees.js:*)',
|
|
367
|
+
'Bash(bongos exec scripts/gds/read-session-export.js:*)',
|
|
368
|
+
'Bash(git status:*)',
|
|
369
|
+
'Bash(git branch --list)',
|
|
370
|
+
'Bash(git branch --show-current)',
|
|
371
|
+
'Bash(git branch -a)',
|
|
372
|
+
'Bash(git branch -vv)',
|
|
373
|
+
'Bash(git fetch)',
|
|
374
|
+
'Bash(git fetch origin)',
|
|
375
|
+
'Bash(git fetch origin main)',
|
|
376
|
+
'Bash(gh pr view:*)',
|
|
377
|
+
'Bash(gh pr checks:*)',
|
|
378
|
+
'Bash(gh pr list:*)',
|
|
379
|
+
'Bash(gh run view:*)',
|
|
380
|
+
'Bash(gh run list:*)',
|
|
350
381
|
];
|
|
351
382
|
|
|
352
383
|
// The starter an instance gets when the core root carries no settings.json — a
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.847'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -12,11 +12,15 @@ import { strict as assert } from 'node:assert';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import {
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { skipUnlessPresent, requireHostModule } from './helpers.mjs';
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const settingsPath = path.join(__dirname, '..', '.claude', 'settings.json');
|
|
19
20
|
skipUnlessPresent('.claude/settings.json');
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const guard = requireHostModule(require, '../.claude/hooks/git-guard.js');
|
|
23
|
+
const { STARTER_ALLOW, bongosify } = require('../scripts/gds/claude-materialize.js');
|
|
20
24
|
|
|
21
25
|
let passed = 0, failed = 0;
|
|
22
26
|
function test(name, fn) {
|
|
@@ -68,6 +72,131 @@ test('every allow-rule points at a script that actually exists (no inert rules)'
|
|
|
68
72
|
}
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// task 1003992 — the read-only subset (ADR 0304). A non-technical builder was
|
|
77
|
+
// interrupted for every `gh pr view` and `git fetch`; those are now pre-allowed.
|
|
78
|
+
// An allow-rule skips the prompt, so the list must never reach a command
|
|
79
|
+
// git-guard blocks, and must stay read-only.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
// How Claude Code reads a Bash rule: `Bash(x:*)` covers x and anything starting
|
|
83
|
+
// "x "; `Bash(x)` covers exactly x. A chained command is covered only when every
|
|
84
|
+
// part is, so `git status && git stash` is not covered by a `git status` rule.
|
|
85
|
+
function ruleCovers(rule, part) {
|
|
86
|
+
const m = rule.match(/^Bash\((.*?)(:\*)?\)$/);
|
|
87
|
+
if (!m) return false;
|
|
88
|
+
return part === m[1] || (Boolean(m[2]) && part.startsWith(`${m[1]} `));
|
|
89
|
+
}
|
|
90
|
+
function allowCovers(allow, command) {
|
|
91
|
+
const parts = command.split(/&&|\|\||[;|&\n]/).map((s) => s.trim()).filter(Boolean);
|
|
92
|
+
return parts.every((part) => allow.some((rule) => ruleCovers(rule, part)));
|
|
93
|
+
}
|
|
94
|
+
const isBlocked = (command) => guard.decide({ tool_name: 'Bash', tool_input: { command } }, {}).block;
|
|
95
|
+
|
|
96
|
+
// At least one sample per git-guard rule, keyed by the rule's own name.
|
|
97
|
+
const BLOCKED_SAMPLES = {
|
|
98
|
+
'git stash': ['git stash', 'git stash pop', 'git stash push -m wip', 'git status && git stash'],
|
|
99
|
+
'git worktree remove': ['git worktree remove ../wt', 'git worktree remove --force ../wt'],
|
|
100
|
+
'git push origin main': ['git push origin main', 'git push -u origin HEAD:main', 'git push --force origin task-1:main'],
|
|
101
|
+
'git branch -D': ['git branch -D task-1', 'git branch --list -D task-1', 'git branch -a -D task-1'],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
test('every git-guard rule has blocked samples here (a new rule forces a new sample)', () => {
|
|
105
|
+
for (const [name] of guard.RULES) {
|
|
106
|
+
assert.ok((BLOCKED_SAMPLES[name] || []).length, `no blocked sample for git-guard rule "${name}"`);
|
|
107
|
+
}
|
|
108
|
+
for (const samples of Object.values(BLOCKED_SAMPLES)) {
|
|
109
|
+
for (const cmd of samples) assert.ok(isBlocked(cmd), `sample is not actually blocked by git-guard: ${cmd}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('no allow-rule covers a command git-guard blocks', () => {
|
|
114
|
+
const allow = settings.permissions.allow;
|
|
115
|
+
for (const rule of allow) {
|
|
116
|
+
const text = rule.replace(/^Bash\(/, '').replace(/(:\*)?\)$/, '');
|
|
117
|
+
assert.ok(!isBlocked(text), `allow-rule is itself a git-guard-blocked command: ${rule}`);
|
|
118
|
+
}
|
|
119
|
+
for (const cmd of Object.values(BLOCKED_SAMPLES).flat()) {
|
|
120
|
+
assert.ok(!allowCovers(allow, cmd), `an allow-rule covers a git-guard-blocked command: ${cmd}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the check has teeth: a too-broad git rule IS caught', () => {
|
|
125
|
+
const samples = Object.values(BLOCKED_SAMPLES).flat();
|
|
126
|
+
for (const broad of ['Bash(git:*)', 'Bash(git stash:*)', 'Bash(git worktree:*)', 'Bash(git push:*)', 'Bash(git branch:*)', 'Bash(git branch --list:*)']) {
|
|
127
|
+
assert.ok(samples.some((cmd) => allowCovers([broad], cmd)), `${broad} should cover a blocked sample`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The whole git/gh allow-list, pinned. A flag can turn a read into a write or an
|
|
132
|
+
// exec (`git fetch --upload-pack=<cmd>`, `git fetch origin x:main`, `git branch -D`),
|
|
133
|
+
// so `branch` and `fetch` appear only as the exact commands a session runs.
|
|
134
|
+
//
|
|
135
|
+
// NO `git diff|log|show` rule, on purpose. Claude Code already runs their read-only
|
|
136
|
+
// forms without a prompt in every mode and checks their flags; a prefix rule would
|
|
137
|
+
// switch that check off (see WRITES_DRESSED_AS_READS below — both were reproduced).
|
|
138
|
+
const GIT_GH_RULES = [
|
|
139
|
+
'Bash(git status:*)',
|
|
140
|
+
'Bash(git branch --list)', 'Bash(git branch --show-current)', 'Bash(git branch -a)', 'Bash(git branch -vv)',
|
|
141
|
+
'Bash(git fetch)', 'Bash(git fetch origin)', 'Bash(git fetch origin main)',
|
|
142
|
+
'Bash(gh pr view:*)', 'Bash(gh pr checks:*)', 'Bash(gh pr list:*)', 'Bash(gh run view:*)', 'Bash(gh run list:*)',
|
|
143
|
+
];
|
|
144
|
+
// gds CLIs audited read-only under EVERY flag they take — a prefix rule allows them all.
|
|
145
|
+
// Audited and left OUT: backlog-review.js (POSTs from its default walk) and
|
|
146
|
+
// key-status.js (`--recheck` rewrites the local session file).
|
|
147
|
+
const READ_ONLY_CLIS = ['start', 'status', 'recall', 'me', 'doctor', 'preflight', 'skill-preflight',
|
|
148
|
+
'ship-check', 'fitness', 'strand-watch', 'stale-worktrees', 'read-session-export'];
|
|
149
|
+
const BLESSED_WRITERS = ['ship', 'claim', 'reauth', 'release', 'paste-token', 'api']; // tasks 1051 / 1716
|
|
150
|
+
|
|
151
|
+
const SCRIPT_RULE = /^Bash\(node scripts\/gds\/([a-z-]+)\.js:\*\)$/;
|
|
152
|
+
|
|
153
|
+
test('every allow-rule that is not a gds script is in the pinned git/gh set (adding one is a deliberate edit here)', () => {
|
|
154
|
+
// Catches `Bash(gh:*)`, `Bash(gh *)`, `Bash(git -C x status:*)`, a bare `Bash` — anything unlisted.
|
|
155
|
+
const rest = settings.permissions.allow.filter((rule) => !SCRIPT_RULE.test(rule));
|
|
156
|
+
assert.deepEqual([...rest].sort(), [...GIT_GH_RULES].sort());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// "Read" commands whose flags write a file, read one the Read deny-list protects,
|
|
160
|
+
// run a command, or move a branch. None may ever run without a prompt.
|
|
161
|
+
const WRITES_DRESSED_AS_READS = [
|
|
162
|
+
"git log -1 --format='#!/bin/sh' --output=.git/hooks/pre-push",
|
|
163
|
+
'git show --output=/tmp/x HEAD',
|
|
164
|
+
'git diff --output=/tmp/x',
|
|
165
|
+
'git diff --no-index -- /dev/null .env',
|
|
166
|
+
'git fetch --upload-pack=./x origin',
|
|
167
|
+
'git fetch origin task-1:main',
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
test('no allow-rule lets a write dressed as a read through (--output, --no-index, --upload-pack, a refspec)', () => {
|
|
171
|
+
for (const cmd of WRITES_DRESSED_AS_READS) {
|
|
172
|
+
assert.ok(!allowCovers(settings.permissions.allow, cmd), `runs without a prompt: ${cmd}`);
|
|
173
|
+
}
|
|
174
|
+
// Teeth: the prefix rules this list deliberately does NOT carry would let them through.
|
|
175
|
+
const tempting = ['Bash(git log:*)', 'Bash(git show:*)', 'Bash(git diff:*)', 'Bash(git fetch:*)'];
|
|
176
|
+
for (const cmd of WRITES_DRESSED_AS_READS) assert.ok(allowCovers(tempting, cmd), `teeth: ${cmd}`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('every gds-script allow-rule is a blessed writer or an audited read-only CLI', () => {
|
|
180
|
+
const known = new Set([...BLESSED_WRITERS, ...READ_ONLY_CLIS]);
|
|
181
|
+
for (const rule of settings.permissions.allow) {
|
|
182
|
+
const m = rule.match(SCRIPT_RULE);
|
|
183
|
+
if (m) assert.ok(known.has(m[1]), `${m[1]}.js is pre-allowed but not audited here — a prefix rule allows every flag it takes`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the read-only subset is pre-allowed', () => {
|
|
188
|
+
const allow = settings.permissions.allow;
|
|
189
|
+
for (const cmd of ['git status', 'git status --short', 'git branch --list', 'git fetch origin',
|
|
190
|
+
'gh pr view 922', 'gh pr checks 922', 'gh pr list', 'gh run view 1 --log-failed', 'gh run list --limit 5',
|
|
191
|
+
...READ_ONLY_CLIS.map((s) => `node scripts/gds/${s}.js`)]) {
|
|
192
|
+
assert.ok(allowCovers(allow, cmd), `should run without a prompt: ${cmd}`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the instance starter mirrors this list in verb form (claude-materialize.js STARTER_ALLOW)', () => {
|
|
197
|
+
assert.deepEqual([...STARTER_ALLOW].sort(), settings.permissions.allow.map((rule) => bongosify(rule)).sort());
|
|
198
|
+
});
|
|
199
|
+
|
|
71
200
|
console.log('');
|
|
72
201
|
console.log(`box_ship_permissions: ${passed} passed, ${failed} failed`);
|
|
73
202
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -729,6 +729,7 @@ t('every allow-rule names a verb the CLI actually registers', () => {
|
|
|
729
729
|
// is silently inert — it looks like a permission and grants nothing, so the
|
|
730
730
|
// builder still meets the classifier and nobody can see why.
|
|
731
731
|
for (const rule of m.STARTER_ALLOW) {
|
|
732
|
+
if (!rule.startsWith('Bash(bongos ')) continue; // the git/gh reads (task 1003992) name no verb
|
|
732
733
|
const inner = rule.replace(/^Bash\(/, '').replace(/:\*\)$/, '');
|
|
733
734
|
const verb = inner.split(' ')[1];
|
|
734
735
|
assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
|
package/tests/git_guard.mjs
CHANGED
|
@@ -31,6 +31,14 @@ function bash(command, env) {
|
|
|
31
31
|
return runHook(JSON.stringify({ tool_name: 'Bash', tool_input: { command } }), env);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The same command as a Windows box sends it. Claude Code's shell tool is named
|
|
35
|
+
// `PowerShell` there, carrying the identical `tool_input.command` — and this hook
|
|
36
|
+
// matched `Bash` alone, so every guarded shape was unguarded on Windows (task
|
|
37
|
+
// 1003999).
|
|
38
|
+
function pwsh(command, env) {
|
|
39
|
+
return runHook(JSON.stringify({ tool_name: 'PowerShell', tool_input: { command } }), env);
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
test('blocks every git stash form except the read-only list', () => {
|
|
35
43
|
for (const cmd of [
|
|
36
44
|
'git stash',
|
|
@@ -96,7 +104,21 @@ test('allows the read-only, the supported, and the ordinary', () => {
|
|
|
96
104
|
}
|
|
97
105
|
});
|
|
98
106
|
|
|
99
|
-
test('
|
|
107
|
+
test('a Windows session is guarded: the PowerShell tool blocks the same shapes', () => {
|
|
108
|
+
for (const cmd of ['git stash', 'git push origin main', 'git worktree remove wt-x', 'git branch -D feature']) {
|
|
109
|
+
const r = pwsh(cmd);
|
|
110
|
+
assert.equal(r.status, 2, `PowerShell went unguarded for: ${cmd}`);
|
|
111
|
+
assert.match(r.stderr, /git-guard: blocked/);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the PowerShell tool keeps the same false-positive floor as Bash', () => {
|
|
116
|
+
for (const cmd of ['git stash list', 'git push origin my-branch', 'npm test']) {
|
|
117
|
+
assert.equal(pwsh(cmd).status, 0, `false positive on PowerShell: ${cmd}`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a non-shell tool and unknown payload shapes still fail open', () => {
|
|
100
122
|
assert.equal(
|
|
101
123
|
runHook(JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'git stash' } })).status,
|
|
102
124
|
0,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/hook_shell_tools.mjs — the settings matcher and the in-hook tool check are
|
|
2
|
+
// two halves of one gate, and this pins that they agree (task 1003999).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS CLOSES. A hook runs only if its `matcher` in .claude/settings.json
|
|
5
|
+
// names the tool, and acts only if its own `tool_name` check admits it. Widening
|
|
6
|
+
// one without the other changes nothing. Three hooks hard-coded `'Bash'` on both
|
|
7
|
+
// halves, so on a Windows box — where Claude Code's shell tool is named
|
|
8
|
+
// `PowerShell` and carries the identical `tool_input.command` — git-guard did not
|
|
9
|
+
// see a `git push origin main`, secret-gate did not scan a command for
|
|
10
|
+
// credentials, and lifecycle-card printed no claim or ship card.
|
|
11
|
+
//
|
|
12
|
+
// It failed silently in the worst way: the hooks ran, matched nothing and exited
|
|
13
|
+
// 0, which looks exactly like a clean command. So the drift is what gets asserted
|
|
14
|
+
// here, not the behaviour — tests/git_guard.mjs owns the behaviour, with a real
|
|
15
|
+
// PowerShell payload against the live hook.
|
|
16
|
+
//
|
|
17
|
+
// Adding a future shell tool is one edit in .claude/hooks/shell-tools.js; this
|
|
18
|
+
// test then names every settings entry that must follow.
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { SHELL_TOOLS, MATCHER, isShellTool } = require(
|
|
30
|
+
path.join(ROOT, '.claude', 'hooks', 'shell-tools.js'),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const settings = JSON.parse(
|
|
34
|
+
fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Every hook whose job is to guard or read a shell command. The bare basename is
|
|
38
|
+
// how the settings `command` string names it.
|
|
39
|
+
const SHELL_HOOKS = [
|
|
40
|
+
'git-guard.js',
|
|
41
|
+
'secret-gate.js',
|
|
42
|
+
'lifecycle-card.js',
|
|
43
|
+
'stuck-detector.js',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Flatten settings.hooks into [{event, matcher, command}] so an entry can be found
|
|
47
|
+
// by the hook file it runs, wherever it sits.
|
|
48
|
+
function entries() {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
51
|
+
for (const group of groups || []) {
|
|
52
|
+
for (const h of group.hooks || []) {
|
|
53
|
+
out.push({ event, matcher: group.matcher || '', command: h.command || '' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('every shell-reading hook is registered, and its matcher names every shell tool', () => {
|
|
61
|
+
const all = entries();
|
|
62
|
+
for (const hook of SHELL_HOOKS) {
|
|
63
|
+
const found = all.filter((e) => e.command.includes(hook));
|
|
64
|
+
assert.ok(found.length > 0, `${hook} is not registered in settings.json at all`);
|
|
65
|
+
for (const e of found) {
|
|
66
|
+
const named = e.matcher.split('|').map((s) => s.trim());
|
|
67
|
+
for (const tool of SHELL_TOOLS) {
|
|
68
|
+
assert.ok(
|
|
69
|
+
named.includes(tool),
|
|
70
|
+
`${hook} (${e.event}) matcher "${e.matcher}" omits ${tool} — ` +
|
|
71
|
+
'that shell tool reaches the hook not at all, silently',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('no shell-reading hook still hard-codes a single tool name', () => {
|
|
79
|
+
for (const hook of SHELL_HOOKS) {
|
|
80
|
+
const src = fs.readFileSync(path.join(ROOT, '.claude', 'hooks', hook), 'utf8');
|
|
81
|
+
// The precise shape that caused this: an equality test against one tool name.
|
|
82
|
+
// A hook may still MENTION 'Bash' (comments, a candidateTexts case for a
|
|
83
|
+
// non-shell tool), so only the comparison is refused.
|
|
84
|
+
for (const tool of SHELL_TOOLS) {
|
|
85
|
+
const re = new RegExp(`(!==|===)\\s*['"]${tool}['"]|['"]${tool}['"]\\s*(!==|===)`);
|
|
86
|
+
assert.doesNotMatch(
|
|
87
|
+
src, re,
|
|
88
|
+
`${hook} compares tool_name against '${tool}' directly — use isShellTool()`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
assert.match(
|
|
92
|
+
src, /require\(['"]\.\/shell-tools['"]\)/,
|
|
93
|
+
`${hook} does not share the shell-tool list`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('MATCHER is built from the list, so it cannot drift from it', () => {
|
|
99
|
+
assert.equal(MATCHER, SHELL_TOOLS.join('|'));
|
|
100
|
+
// And it is what the settings entries actually carry, for the bare-shell hooks
|
|
101
|
+
// (secret-gate also matches the file-writing tools, so it is a superset).
|
|
102
|
+
const gitGuard = entries().find((e) => e.command.includes('git-guard.js'));
|
|
103
|
+
assert.equal(gitGuard.matcher, MATCHER);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('isShellTool admits every listed tool and nothing else, and never throws', () => {
|
|
107
|
+
for (const tool of SHELL_TOOLS) assert.equal(isShellTool(tool), true, tool);
|
|
108
|
+
for (const other of ['Write', 'Edit', 'Read', 'bash', 'powershell', '']) {
|
|
109
|
+
assert.equal(isShellTool(other), false, other);
|
|
110
|
+
}
|
|
111
|
+
// A hook must not throw on an odd payload — that is a fail-open on a guard.
|
|
112
|
+
for (const junk of [undefined, null, 0, {}, []]) {
|
|
113
|
+
assert.equal(isShellTool(junk), false);
|
|
114
|
+
}
|
|
115
|
+
});
|
package/tests/lifecycle-card.mjs
CHANGED
|
@@ -18,9 +18,17 @@
|
|
|
18
18
|
import { strict as assert } from 'node:assert';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { requireHostModule } from './helpers.mjs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
21
26
|
|
|
22
27
|
const require = createRequire(import.meta.url);
|
|
23
28
|
const hook = requireHostModule(require, '../.claude/hooks/lifecycle-card.js');
|
|
29
|
+
const HOOK = path.join(
|
|
30
|
+
path.dirname(fileURLToPath(import.meta.url)), '..', '.claude', 'hooks', 'lifecycle-card.js',
|
|
31
|
+
);
|
|
24
32
|
|
|
25
33
|
let passed = 0;
|
|
26
34
|
let failed = 0;
|
|
@@ -30,6 +38,43 @@ function test(name, fn) {
|
|
|
30
38
|
}
|
|
31
39
|
const count = (hay, needle) => hay.split(needle).length - 1;
|
|
32
40
|
|
|
41
|
+
// main() reads stdin and is not exported, so the tool-name gate is only reachable by
|
|
42
|
+
// spawning the hook the way Claude Code does. That gate matched `Bash` alone, so a
|
|
43
|
+
// claim or ship run from a Windows session — where the shell tool is named
|
|
44
|
+
// `PowerShell` — produced no card at all (task 1003999).
|
|
45
|
+
function runHook(payload) {
|
|
46
|
+
const r = spawnSync(process.execPath, [HOOK], {
|
|
47
|
+
input: JSON.stringify(payload), encoding: 'utf8',
|
|
48
|
+
env: { ...process.env, OTB_SESSION_CARDS_OFF: '', OTB_SUBAGENT: '' },
|
|
49
|
+
});
|
|
50
|
+
return r.stdout || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A ship run's output as the hook sees it: the widgets-ON marker naming a card file.
|
|
54
|
+
function shipPayload(toolName, cardPath) {
|
|
55
|
+
return {
|
|
56
|
+
tool_name: toolName,
|
|
57
|
+
tool_input: { command: 'node scripts/gds/ship.js 1003999 --summary x' },
|
|
58
|
+
tool_response: `shipped\n[otb-card-html] ${cardPath}\n`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('a lifecycle card is emitted for every shell tool, not Bash alone', () => {
|
|
63
|
+
const cardPath = path.join(os.tmpdir(), `otb-test-card-${process.pid}.html`);
|
|
64
|
+
fs.writeFileSync(cardPath, '<div id="card">shipped</div>');
|
|
65
|
+
try {
|
|
66
|
+
for (const tool of ['Bash', 'PowerShell']) {
|
|
67
|
+
const out = runHook(shipPayload(tool, cardPath));
|
|
68
|
+
assert.ok(out.includes('additionalContext'), `${tool} got no card directive`);
|
|
69
|
+
assert.ok(out.includes('show_widget'), `${tool} card directive is malformed`);
|
|
70
|
+
}
|
|
71
|
+
// A tool that carries no shell command still gets nothing.
|
|
72
|
+
assert.equal(runHook(shipPayload('Write', cardPath)).trim(), '');
|
|
73
|
+
} finally {
|
|
74
|
+
try { fs.unlinkSync(cardPath); } catch (_) { /* best effort */ }
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
33
78
|
// ---------- matchesLifecycle ----------
|
|
34
79
|
|
|
35
80
|
test('matches claim / ship / release script invocations', () => {
|
package/tests/secret_gate.mjs
CHANGED
|
@@ -65,6 +65,51 @@ test('blocks a Bash command smuggling a literal token; allows ordinary commands'
|
|
|
65
65
|
}
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// The identical command as a Windows session sends it: Claude Code names the shell
|
|
69
|
+
// tool `PowerShell` there (task 1003999).
|
|
70
|
+
//
|
|
71
|
+
// WHERE THE REAL DEFECT WAS, precisely. For THIS gate the load-bearing half was the
|
|
72
|
+
// settings `matcher`, which listed `Bash` and so never ran the hook on a Windows box
|
|
73
|
+
// at all; tests/hook_shell_tools.mjs owns that half. candidateTexts() was survivable
|
|
74
|
+
// on its own, because an unrecognised tool falls through to `JSON.stringify(ti)` —
|
|
75
|
+
// which still contains the command, so decide() blocked anyway. Verified by probe:
|
|
76
|
+
// narrowing candidateTexts() back to `=== 'Bash'` left the decide() assertions below
|
|
77
|
+
// GREEN. They are kept because they pin the end-state contract, but they are not what
|
|
78
|
+
// catches a narrowing, so the last assertion goes at candidateTexts() directly: a
|
|
79
|
+
// shell tool must yield the command FIELD, not a stringified blob that happens to
|
|
80
|
+
// contain it. That distinction is the difference between scanning a command and
|
|
81
|
+
// scanning it by accident.
|
|
82
|
+
test('the PowerShell tool is scanned exactly as Bash is, command field and all', () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
gate.decide({ tool_name: 'PowerShell', tool_input: { command: `echo "${FAKE.anthropic}" >> notes.txt` } }, ENV_OFF).block,
|
|
85
|
+
true,
|
|
86
|
+
'a secret in a PowerShell command went unscanned',
|
|
87
|
+
);
|
|
88
|
+
for (const cmd of ['npm test', 'git commit -m "feat: rotate the key handling (ADR 0022)"']) {
|
|
89
|
+
assert.equal(
|
|
90
|
+
gate.decide({ tool_name: 'PowerShell', tool_input: { command: cmd } }, ENV_OFF).block,
|
|
91
|
+
false, `false positive on PowerShell: ${cmd}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// And it inherits Bash's rule that a command never gets the path allowlist.
|
|
95
|
+
assert.equal(
|
|
96
|
+
gate.decide(
|
|
97
|
+
{ tool_name: 'PowerShell', tool_input: { file_path: 'tests/bfg.mjs', command: `echo ${FAKE.github}` } },
|
|
98
|
+
ENV_OFF,
|
|
99
|
+
).block,
|
|
100
|
+
true,
|
|
101
|
+
'the allowlist leaked to a PowerShell command',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// The assertion that actually reds when the shell-tool branch narrows: the command
|
|
105
|
+
// field is chosen for it, not reached through the unknown-tool stringify fallback.
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
gate.candidateTexts('PowerShell', { command: 'echo hi', file_path: 'x.js' }),
|
|
108
|
+
['echo hi'],
|
|
109
|
+
'a shell tool must yield its command field, not a stringified payload',
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
68
113
|
test('clean writes pass; short/placeholder shapes below the length floors pass', () => {
|
|
69
114
|
for (const content of [
|
|
70
115
|
'const label = "use your ghp_ token here";', // prefix alone, no 30-char body
|