@bongos/core 1.19.844 → 1.19.846
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 +21 -16
- 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/src/module-api.js +1 -1
- 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/tests/skill_friction_kind.mjs +34 -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.846",
|
|
6
|
+
"core_contract": "1.19.846",
|
|
7
|
+
"source_commit": "7875bef1fd719fda2b08d1c47132af0bf09470c0",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T19:26:58.935Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 495,
|
|
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": 2794,
|
|
20
|
+
"tree_sha256": "0ce103dd9544b2dc4d8ad8534a617c7e83884733163303d5eb381d4d44f49cb3",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2567,7 +2567,7 @@
|
|
|
2567
2567
|
{
|
|
2568
2568
|
"path": "docs/file-map.md",
|
|
2569
2569
|
"mode": "0000644",
|
|
2570
|
-
"sha256": "
|
|
2570
|
+
"sha256": "eaa7dca723187a394a3b6ee252bbb81b13b76cc384a4651b6ee74a859af9d611"
|
|
2571
2571
|
},
|
|
2572
2572
|
{
|
|
2573
2573
|
"path": "docs/handoff-template.md",
|
|
@@ -2577,7 +2577,7 @@
|
|
|
2577
2577
|
{
|
|
2578
2578
|
"path": "docs/module-api-changelog.md",
|
|
2579
2579
|
"mode": "0000644",
|
|
2580
|
-
"sha256": "
|
|
2580
|
+
"sha256": "d764a731750a2e1028c95a1fb94148e35ed3450b7394323d66c774959888105b"
|
|
2581
2581
|
},
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "docs/modules-contract.md",
|
|
@@ -7902,12 +7902,12 @@
|
|
|
7902
7902
|
{
|
|
7903
7903
|
"path": "package-lock.json",
|
|
7904
7904
|
"mode": "0000644",
|
|
7905
|
-
"sha256": "
|
|
7905
|
+
"sha256": "4c1cac911818d09a26f42a791f1a2ce4a7add73cb38a8f7186ee0fd3d6425a3b"
|
|
7906
7906
|
},
|
|
7907
7907
|
{
|
|
7908
7908
|
"path": "package.json",
|
|
7909
7909
|
"mode": "0000644",
|
|
7910
|
-
"sha256": "
|
|
7910
|
+
"sha256": "d9c4e3866e590697c2a303536f799c9a7a99925295201df566d80e8c1b91f59f"
|
|
7911
7911
|
},
|
|
7912
7912
|
{
|
|
7913
7913
|
"path": "public-docs/index.html",
|
|
@@ -9842,7 +9842,7 @@
|
|
|
9842
9842
|
{
|
|
9843
9843
|
"path": "src/module-api.js",
|
|
9844
9844
|
"mode": "0000644",
|
|
9845
|
-
"sha256": "
|
|
9845
|
+
"sha256": "7ed9c1945c4d366375bd4009c00e90dbcfb847de0fc086bcd73e4fbf33589b36"
|
|
9846
9846
|
},
|
|
9847
9847
|
{
|
|
9848
9848
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11127,7 +11127,7 @@
|
|
|
11127
11127
|
{
|
|
11128
11128
|
"path": "tests/git_guard.mjs",
|
|
11129
11129
|
"mode": "0000644",
|
|
11130
|
-
"sha256": "
|
|
11130
|
+
"sha256": "95ec763418aceec86f239edc1e78154c0fb9f67ffcb566c3062c75eceeb09b24"
|
|
11131
11131
|
},
|
|
11132
11132
|
{
|
|
11133
11133
|
"path": "tests/git_merge_regen.mjs",
|
|
@@ -11744,6 +11744,11 @@
|
|
|
11744
11744
|
"mode": "0000644",
|
|
11745
11745
|
"sha256": "7035673d4b9e55166acc19d5c40a2128c0aceb233bca6856eace34bebc1139e4"
|
|
11746
11746
|
},
|
|
11747
|
+
{
|
|
11748
|
+
"path": "tests/hook_shell_tools.mjs",
|
|
11749
|
+
"mode": "0000644",
|
|
11750
|
+
"sha256": "88db0fedd800ad8a8f6fd9d912844238bfb7bb944d5bf648eb8818eca6854466"
|
|
11751
|
+
},
|
|
11747
11752
|
{
|
|
11748
11753
|
"path": "tests/hook_user_agents.mjs",
|
|
11749
11754
|
"mode": "0000644",
|
|
@@ -12052,7 +12057,7 @@
|
|
|
12052
12057
|
{
|
|
12053
12058
|
"path": "tests/lifecycle-card.mjs",
|
|
12054
12059
|
"mode": "0000644",
|
|
12055
|
-
"sha256": "
|
|
12060
|
+
"sha256": "a308fb62ff11b442c20e3eeb3eb9617a1b3ad1abcba4ec4b2cc6b349b3e6aa5e"
|
|
12056
12061
|
},
|
|
12057
12062
|
{
|
|
12058
12063
|
"path": "tests/lifecycle_breakdown_view.mjs",
|
|
@@ -13152,7 +13157,7 @@
|
|
|
13152
13157
|
{
|
|
13153
13158
|
"path": "tests/secret_gate.mjs",
|
|
13154
13159
|
"mode": "0000644",
|
|
13155
|
-
"sha256": "
|
|
13160
|
+
"sha256": "46ea51955dbcc4874684fdb06b298e744e7e019d2f3971ae7953d37ede96958a"
|
|
13156
13161
|
},
|
|
13157
13162
|
{
|
|
13158
13163
|
"path": "tests/security_docs.mjs",
|
|
@@ -13437,7 +13442,7 @@
|
|
|
13437
13442
|
{
|
|
13438
13443
|
"path": "tests/skill_friction_kind.mjs",
|
|
13439
13444
|
"mode": "0000644",
|
|
13440
|
-
"sha256": "
|
|
13445
|
+
"sha256": "28d88f97e66c1ca8728d311a3cc8c58bb6df058dd9ee91aa632028faa5b5bdcf"
|
|
13441
13446
|
},
|
|
13442
13447
|
{
|
|
13443
13448
|
"path": "tests/skill_grade_audit.mjs",
|
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)
|
|
@@ -2162,5 +2162,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2162
2162
|
landed since 1.19.842 with no explicit bump. run 35259608793. (task 1002620)
|
|
2163
2163
|
1.19.844 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2164
2164
|
landed since 1.19.843 with no explicit bump. run 35262101669. (task 1002620)
|
|
2165
|
+
1.19.845 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
2165
2169
|
---------------------------------------------------------------------------
|
|
2166
2170
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.846",
|
|
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.846",
|
|
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.846",
|
|
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",
|
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.846'; // 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');
|
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
|
|
@@ -158,6 +158,40 @@ test('the legacy title prefix is still exported and still the nightly routine\'s
|
|
|
158
158
|
);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
// ---- the flip itself (task 1003963) ----------------------------------------
|
|
162
|
+
//
|
|
163
|
+
// The routine filed `--kind cleanup` for as long as the deployed core pin ran
|
|
164
|
+
// behind the migration that made `skill-friction` a real kind. That window is
|
|
165
|
+
// closed and the flip has landed — but the flip lives in prose inside a SKILL.md
|
|
166
|
+
// that an agent reads and follows, so nothing but this assertion stops a later
|
|
167
|
+
// edit from reverting it by accident. Both halves matter: that the new kind IS
|
|
168
|
+
// the one the worked example passes, and that the two pre-`core_244` stand-ins
|
|
169
|
+
// are NOT offered as alternatives beside it (a `--kind cleanup` left in the
|
|
170
|
+
// example would split these rows across kinds again, which is the bug 1003105
|
|
171
|
+
// and 1003963 together exist to close).
|
|
172
|
+
|
|
173
|
+
test('the nightly routine files --kind skill-friction, not the pre-core_244 stand-ins', () => {
|
|
174
|
+
const routine = fs.readFileSync(
|
|
175
|
+
path.join(ROOT, '.claude', 'scheduled-tasks', 'skill-usage-review', 'SKILL.md'), 'utf8'
|
|
176
|
+
);
|
|
177
|
+
// Scope BOTH assertions to the runnable example, not to the whole file: the
|
|
178
|
+
// note above it explains the old stand-ins on purpose and must stay free to
|
|
179
|
+
// name them — and, the other way round, a `--kind skill-friction` sitting in
|
|
180
|
+
// that prose would satisfy a file-wide positive match while the example the
|
|
181
|
+
// agent actually copies still said `cleanup`. (Verified: reverting only the
|
|
182
|
+
// example left a file-wide match green.)
|
|
183
|
+
const example = routine.slice(routine.indexOf('node scripts/gds/capture.js'));
|
|
184
|
+
const block = example.slice(0, example.indexOf('```'));
|
|
185
|
+
assert.match(
|
|
186
|
+
block, /--kind skill-friction/,
|
|
187
|
+
'the capture.js example must pass the real kind'
|
|
188
|
+
);
|
|
189
|
+
assert.doesNotMatch(
|
|
190
|
+
block, /--kind (cleanup|bug)\b/,
|
|
191
|
+
'the example must not still offer cleanup/bug — that is the split this closed'
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
161
195
|
console.log('');
|
|
162
196
|
console.log(`skill-friction kind + visibility gate (task 1003105): ${passed} passed, ${failed} failed`);
|
|
163
197
|
if (failed > 0) process.exit(1);
|