@bookedsolid/rea 0.30.1 → 0.32.0
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/.husky/prepare-commit-msg +80 -6
- package/MIGRATING.md +24 -15
- package/dist/cli/audit-specialists.d.ts +106 -24
- package/dist/cli/audit-specialists.js +239 -64
- package/dist/cli/delegation-advisory.d.ts +161 -0
- package/dist/cli/delegation-advisory.js +433 -0
- package/dist/cli/doctor.d.ts +110 -39
- package/dist/cli/doctor.js +302 -90
- package/dist/cli/hook.d.ts +6 -0
- package/dist/cli/hook.js +45 -22
- package/dist/cli/index.js +1 -1
- package/dist/cli/install/settings-merge.js +25 -0
- package/dist/cli/roster.d.ts +119 -0
- package/dist/cli/roster.js +141 -0
- package/dist/hooks/_lib/halt-check.d.ts +78 -0
- package/dist/hooks/_lib/halt-check.js +106 -0
- package/dist/hooks/_lib/payload.d.ts +86 -0
- package/dist/hooks/_lib/payload.js +166 -0
- package/dist/hooks/_lib/segments.d.ts +100 -0
- package/dist/hooks/_lib/segments.js +444 -0
- package/dist/hooks/attribution-advisory/index.d.ts +72 -0
- package/dist/hooks/attribution-advisory/index.js +233 -0
- package/dist/hooks/bash-scanner/protected-scan.js +14 -2
- package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
- package/dist/hooks/pr-issue-link-gate/index.js +127 -0
- package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
- package/dist/hooks/security-disclosure-gate/index.js +502 -0
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +46 -0
- package/dist/policy/profiles.d.ts +23 -0
- package/dist/policy/profiles.js +16 -0
- package/dist/policy/types.d.ts +61 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/delegation-advisory.sh +162 -0
- package/hooks/pr-issue-link-gate.sh +114 -45
- package/hooks/security-disclosure-gate.sh +148 -316
- package/hooks/settings-protection.sh +13 -9
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +12 -0
- package/profiles/bst-internal.yaml +13 -0
- package/profiles/client-engagement.yaml +11 -0
- package/profiles/lit-wc.yaml +10 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +11 -0
- package/profiles/open-source.yaml +11 -0
- package/templates/attribution-advisory.dogfood-staged.sh +170 -0
- package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
- package/templates/prepare-commit-msg.husky.sh +80 -6
- package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
- package/templates/settings-protection.dogfood.patch +58 -0
package/dist/cli/index.js
CHANGED
|
@@ -148,7 +148,7 @@ async function main() {
|
|
|
148
148
|
.description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
|
|
149
149
|
.option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
|
|
150
150
|
.option('--drift', 'report drift vs. the install manifest (read-only; does not mutate)')
|
|
151
|
-
.option('--smoke', 'also run the
|
|
151
|
+
.option('--smoke', 'also run the delegation-signal round-trip: drives the real `.claude/hooks/delegation-capture.sh` shell hook end-to-end (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
|
|
152
152
|
.option('--strict', '0.30.0 Class M — promote settings.json schema warnings (zod parse failures, path traversal, missing rea hooks) to hard fail. Use in CI gates.')
|
|
153
153
|
.action(async (opts) => {
|
|
154
154
|
await runDoctor({
|
|
@@ -380,5 +380,30 @@ export function defaultDesiredHooks() {
|
|
|
380
380
|
},
|
|
381
381
|
],
|
|
382
382
|
},
|
|
383
|
+
{
|
|
384
|
+
// 0.31.0 delegation-telemetry completion — the *nudge*. The
|
|
385
|
+
// matcher is `Bash|Edit|Write|MultiEdit|NotebookEdit`: every
|
|
386
|
+
// write-class tool call (note this group INCLUDES Bash, unlike
|
|
387
|
+
// the architecture-review group above). The hook maintains a
|
|
388
|
+
// per-session counter and emits a one-time stderr advisory when
|
|
389
|
+
// a session crosses `policy.delegation_advisory.threshold`
|
|
390
|
+
// without dispatching a curated specialist. Advisory only — it
|
|
391
|
+
// never blocks a tool call, and `policy.delegation_advisory`
|
|
392
|
+
// defaults to disabled (only `bst-internal*` profiles pin
|
|
393
|
+
// `enabled: true`), so a vanilla install sees the hook as a
|
|
394
|
+
// silent no-op. Kept as its own matcher group rather than
|
|
395
|
+
// chained onto the architecture-review group because the
|
|
396
|
+
// matcher string differs (Bash is in this one, not that one).
|
|
397
|
+
event: 'PostToolUse',
|
|
398
|
+
matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit',
|
|
399
|
+
hooks: [
|
|
400
|
+
{
|
|
401
|
+
type: 'command',
|
|
402
|
+
command: `${base}/delegation-advisory.sh`,
|
|
403
|
+
timeout: 10000,
|
|
404
|
+
statusMessage: 'Checking delegation cadence...',
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
},
|
|
383
408
|
];
|
|
384
409
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live `.claude/agents/` roster discovery (0.31.0+).
|
|
3
|
+
*
|
|
4
|
+
* 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
|
|
5
|
+
* closes the loop with the *nudge* — and the nudge needs to know what
|
|
6
|
+
* counts as a "real" specialist delegation versus a built-in helper.
|
|
7
|
+
*
|
|
8
|
+
* # Why discovery, not a hardcoded list
|
|
9
|
+
*
|
|
10
|
+
* `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
|
|
11
|
+
* but it is a deliberately-frozen 10-entry subset — the minimum roster
|
|
12
|
+
* `rea init` guarantees, pinned so a regression that drops a curated
|
|
13
|
+
* agent from the install manifest trips a doctor failure. It is NOT
|
|
14
|
+
* the live roster: this repo ships 23 agents, consumers add their own,
|
|
15
|
+
* and the curated set grows release over release. Keying the
|
|
16
|
+
* delegation nudge off `EXPECTED_AGENTS` would mean a session that
|
|
17
|
+
* delegated exclusively to `principal-engineer` and `data-architect`
|
|
18
|
+
* (both real, curated, neither in the frozen subset) still got nudged
|
|
19
|
+
* — exactly the false positive that erodes trust in an advisory.
|
|
20
|
+
*
|
|
21
|
+
* So the roster is discovered at read time: every `*.md` file under
|
|
22
|
+
* `<baseDir>/.claude/agents/` is a curated specialist. The basename
|
|
23
|
+
* (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
|
|
24
|
+
* reports, so the mapping is direct.
|
|
25
|
+
*
|
|
26
|
+
* # The exempt set
|
|
27
|
+
*
|
|
28
|
+
* Claude Code ships built-in helpers — `general-purpose`, `Explore`,
|
|
29
|
+
* `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
|
|
30
|
+
* through the same `Agent` tool but are NOT curated specialists. A
|
|
31
|
+
* session that only ever delegated to those has not actually routed
|
|
32
|
+
* work to the engineering team. The exempt set is policy-configurable
|
|
33
|
+
* (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
|
|
34
|
+
* built-in default; this module takes the resolved list as an argument
|
|
35
|
+
* rather than reading policy itself, so it stays a pure filesystem
|
|
36
|
+
* helper with no policy-loader dependency.
|
|
37
|
+
*
|
|
38
|
+
* # Skills
|
|
39
|
+
*
|
|
40
|
+
* The `Skill` delegation tool is intentionally NOT roster-gated. A
|
|
41
|
+
* skill invocation (`deep-dive`, `due-diligence`, …) is always a real
|
|
42
|
+
* delegation signal — there is no "built-in skill" equivalent of
|
|
43
|
+
* `general-purpose`. The advisory's "did this session delegate"
|
|
44
|
+
* predicate counts every `Skill` signal and every non-exempt `Agent`
|
|
45
|
+
* signal whose `subagent_type` is in the discovered roster.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Default exempt subagent names — Claude Code's built-in helper agents
|
|
49
|
+
* that are dispatched through the `Agent` tool but are not curated
|
|
50
|
+
* specialists. Mirrors the schema-layer default in
|
|
51
|
+
* `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
|
|
52
|
+
* so callers that have no policy in scope (the bash-hook fallback
|
|
53
|
+
* path) can still apply the same filter.
|
|
54
|
+
*/
|
|
55
|
+
export declare const DEFAULT_EXEMPT_SUBAGENTS: readonly string[];
|
|
56
|
+
export interface RosterDiscoveryResult {
|
|
57
|
+
/**
|
|
58
|
+
* Sorted list of discovered curated-specialist names — the basename
|
|
59
|
+
* (sans `.md`) of every file under `.claude/agents/`. Empty when the
|
|
60
|
+
* directory is absent or unreadable.
|
|
61
|
+
*/
|
|
62
|
+
roster: string[];
|
|
63
|
+
/**
|
|
64
|
+
* Absolute path actually scanned. Returned for diagnostics — doctor
|
|
65
|
+
* surfaces it, the `rea hook` subcommand echoes it in `--json` mode.
|
|
66
|
+
*/
|
|
67
|
+
agentsDir: string;
|
|
68
|
+
/**
|
|
69
|
+
* `true` when `.claude/agents/` exists and was read. `false` when it
|
|
70
|
+
* is absent or a read error occurred — callers treat that as "no
|
|
71
|
+
* roster, every Agent delegation is non-exempt-but-also-unverifiable"
|
|
72
|
+
* and fall back to the exempt-list-only filter.
|
|
73
|
+
*/
|
|
74
|
+
discovered: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Discover the curated-specialist roster by listing `*.md` files under
|
|
78
|
+
* `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
|
|
79
|
+
* absent or unreadable directory yields `discovered: false` with an
|
|
80
|
+
* empty roster.
|
|
81
|
+
*
|
|
82
|
+
* The `.md` extension match is case-insensitive (`.MD` on a
|
|
83
|
+
* case-preserving-but-insensitive filesystem still counts) and the
|
|
84
|
+
* basename is taken verbatim — `rea-orchestrator.md` →
|
|
85
|
+
* `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
|
|
86
|
+
* `.DS_Store`, editor swap files) are skipped.
|
|
87
|
+
*/
|
|
88
|
+
export declare function discoverRoster(baseDir: string): RosterDiscoveryResult;
|
|
89
|
+
/**
|
|
90
|
+
* The delegation-nudge predicate, factored out so the CLI subcommand,
|
|
91
|
+
* the `audit specialists` reader, and the doctor smoke check all apply
|
|
92
|
+
* identical logic.
|
|
93
|
+
*
|
|
94
|
+
* Returns `true` when the given `(delegation_tool, subagent_type)` pair
|
|
95
|
+
* counts as a REAL delegation — i.e. the kind that should suppress the
|
|
96
|
+
* advisory nudge:
|
|
97
|
+
*
|
|
98
|
+
* - `Skill` → always counts. There is no "built-in skill" exemption.
|
|
99
|
+
* - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
|
|
100
|
+
* (the roster was discovered AND contains the name, OR the roster
|
|
101
|
+
* was NOT discovered — in which case we cannot verify and fall
|
|
102
|
+
* back to "non-exempt name counts"). The non-discovered fallback
|
|
103
|
+
* is deliberately permissive: a consumer who deleted
|
|
104
|
+
* `.claude/agents/` has bigger problems than a missed nudge, and a
|
|
105
|
+
* false negative (no nudge when one was due) is far less corrosive
|
|
106
|
+
* than a false positive.
|
|
107
|
+
*
|
|
108
|
+
* `exempt` comparison is case-sensitive — the built-in helper names
|
|
109
|
+
* (`Explore`, `Plan`) are capitalized and the curated specialists are
|
|
110
|
+
* kebab-case, so there is no realistic collision, and a case-folded
|
|
111
|
+
* compare would risk a curated `Plan-something` agent being wrongly
|
|
112
|
+
* exempted.
|
|
113
|
+
*/
|
|
114
|
+
export declare function countsAsRealDelegation(args: {
|
|
115
|
+
delegationTool: 'Agent' | 'Skill';
|
|
116
|
+
subagentType: string;
|
|
117
|
+
roster: RosterDiscoveryResult;
|
|
118
|
+
exempt: readonly string[];
|
|
119
|
+
}): boolean;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live `.claude/agents/` roster discovery (0.31.0+).
|
|
3
|
+
*
|
|
4
|
+
* 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
|
|
5
|
+
* closes the loop with the *nudge* — and the nudge needs to know what
|
|
6
|
+
* counts as a "real" specialist delegation versus a built-in helper.
|
|
7
|
+
*
|
|
8
|
+
* # Why discovery, not a hardcoded list
|
|
9
|
+
*
|
|
10
|
+
* `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
|
|
11
|
+
* but it is a deliberately-frozen 10-entry subset — the minimum roster
|
|
12
|
+
* `rea init` guarantees, pinned so a regression that drops a curated
|
|
13
|
+
* agent from the install manifest trips a doctor failure. It is NOT
|
|
14
|
+
* the live roster: this repo ships 23 agents, consumers add their own,
|
|
15
|
+
* and the curated set grows release over release. Keying the
|
|
16
|
+
* delegation nudge off `EXPECTED_AGENTS` would mean a session that
|
|
17
|
+
* delegated exclusively to `principal-engineer` and `data-architect`
|
|
18
|
+
* (both real, curated, neither in the frozen subset) still got nudged
|
|
19
|
+
* — exactly the false positive that erodes trust in an advisory.
|
|
20
|
+
*
|
|
21
|
+
* So the roster is discovered at read time: every `*.md` file under
|
|
22
|
+
* `<baseDir>/.claude/agents/` is a curated specialist. The basename
|
|
23
|
+
* (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
|
|
24
|
+
* reports, so the mapping is direct.
|
|
25
|
+
*
|
|
26
|
+
* # The exempt set
|
|
27
|
+
*
|
|
28
|
+
* Claude Code ships built-in helpers — `general-purpose`, `Explore`,
|
|
29
|
+
* `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
|
|
30
|
+
* through the same `Agent` tool but are NOT curated specialists. A
|
|
31
|
+
* session that only ever delegated to those has not actually routed
|
|
32
|
+
* work to the engineering team. The exempt set is policy-configurable
|
|
33
|
+
* (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
|
|
34
|
+
* built-in default; this module takes the resolved list as an argument
|
|
35
|
+
* rather than reading policy itself, so it stays a pure filesystem
|
|
36
|
+
* helper with no policy-loader dependency.
|
|
37
|
+
*
|
|
38
|
+
* # Skills
|
|
39
|
+
*
|
|
40
|
+
* The `Skill` delegation tool is intentionally NOT roster-gated. A
|
|
41
|
+
* skill invocation (`deep-dive`, `due-diligence`, …) is always a real
|
|
42
|
+
* delegation signal — there is no "built-in skill" equivalent of
|
|
43
|
+
* `general-purpose`. The advisory's "did this session delegate"
|
|
44
|
+
* predicate counts every `Skill` signal and every non-exempt `Agent`
|
|
45
|
+
* signal whose `subagent_type` is in the discovered roster.
|
|
46
|
+
*/
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
/**
|
|
50
|
+
* Default exempt subagent names — Claude Code's built-in helper agents
|
|
51
|
+
* that are dispatched through the `Agent` tool but are not curated
|
|
52
|
+
* specialists. Mirrors the schema-layer default in
|
|
53
|
+
* `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
|
|
54
|
+
* so callers that have no policy in scope (the bash-hook fallback
|
|
55
|
+
* path) can still apply the same filter.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
58
|
+
'general-purpose',
|
|
59
|
+
'Explore',
|
|
60
|
+
'Plan',
|
|
61
|
+
'output-style-setup',
|
|
62
|
+
'statusline-setup',
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Discover the curated-specialist roster by listing `*.md` files under
|
|
66
|
+
* `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
|
|
67
|
+
* absent or unreadable directory yields `discovered: false` with an
|
|
68
|
+
* empty roster.
|
|
69
|
+
*
|
|
70
|
+
* The `.md` extension match is case-insensitive (`.MD` on a
|
|
71
|
+
* case-preserving-but-insensitive filesystem still counts) and the
|
|
72
|
+
* basename is taken verbatim — `rea-orchestrator.md` →
|
|
73
|
+
* `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
|
|
74
|
+
* `.DS_Store`, editor swap files) are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function discoverRoster(baseDir) {
|
|
77
|
+
const agentsDir = path.join(baseDir, '.claude', 'agents');
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ENOENT (no .claude/agents/), ENOTDIR, EACCES — all collapse to
|
|
84
|
+
// "no roster discovered". The caller falls back to the exempt-list
|
|
85
|
+
// filter alone.
|
|
86
|
+
return { roster: [], agentsDir, discovered: false };
|
|
87
|
+
}
|
|
88
|
+
const roster = [];
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
// Only regular files. A directory named `foo.md` is not an agent.
|
|
91
|
+
if (!entry.isFile())
|
|
92
|
+
continue;
|
|
93
|
+
const name = entry.name;
|
|
94
|
+
if (!name.toLowerCase().endsWith('.md'))
|
|
95
|
+
continue;
|
|
96
|
+
const base = name.slice(0, name.length - 3);
|
|
97
|
+
if (base.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
roster.push(base);
|
|
100
|
+
}
|
|
101
|
+
roster.sort();
|
|
102
|
+
return { roster, agentsDir, discovered: true };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The delegation-nudge predicate, factored out so the CLI subcommand,
|
|
106
|
+
* the `audit specialists` reader, and the doctor smoke check all apply
|
|
107
|
+
* identical logic.
|
|
108
|
+
*
|
|
109
|
+
* Returns `true` when the given `(delegation_tool, subagent_type)` pair
|
|
110
|
+
* counts as a REAL delegation — i.e. the kind that should suppress the
|
|
111
|
+
* advisory nudge:
|
|
112
|
+
*
|
|
113
|
+
* - `Skill` → always counts. There is no "built-in skill" exemption.
|
|
114
|
+
* - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
|
|
115
|
+
* (the roster was discovered AND contains the name, OR the roster
|
|
116
|
+
* was NOT discovered — in which case we cannot verify and fall
|
|
117
|
+
* back to "non-exempt name counts"). The non-discovered fallback
|
|
118
|
+
* is deliberately permissive: a consumer who deleted
|
|
119
|
+
* `.claude/agents/` has bigger problems than a missed nudge, and a
|
|
120
|
+
* false negative (no nudge when one was due) is far less corrosive
|
|
121
|
+
* than a false positive.
|
|
122
|
+
*
|
|
123
|
+
* `exempt` comparison is case-sensitive — the built-in helper names
|
|
124
|
+
* (`Explore`, `Plan`) are capitalized and the curated specialists are
|
|
125
|
+
* kebab-case, so there is no realistic collision, and a case-folded
|
|
126
|
+
* compare would risk a curated `Plan-something` agent being wrongly
|
|
127
|
+
* exempted.
|
|
128
|
+
*/
|
|
129
|
+
export function countsAsRealDelegation(args) {
|
|
130
|
+
if (args.delegationTool === 'Skill')
|
|
131
|
+
return true;
|
|
132
|
+
// Agent path.
|
|
133
|
+
if (args.exempt.includes(args.subagentType))
|
|
134
|
+
return false;
|
|
135
|
+
if (!args.roster.discovered) {
|
|
136
|
+
// Roster unverifiable — a non-exempt Agent name is the best signal
|
|
137
|
+
// we have. Count it.
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return args.roster.roster.includes(args.subagentType);
|
|
141
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HALT kill-switch reader for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
|
|
5
|
+
* `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
|
|
6
|
+
* `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
|
|
7
|
+
* carried a third copy with slightly different error semantics (the
|
|
8
|
+
* push-gate variant returns `{ halted: true, reason: 'unknown (HALT
|
|
9
|
+
* file unreadable)' }` on filesystem errors instead of falling through
|
|
10
|
+
* to allow). The Node-binary hook ports landing in 0.32.0 need the
|
|
11
|
+
* same primitive, so consolidate here before more copies accumulate.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
*
|
|
15
|
+
* - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
|
|
16
|
+
* - Returns `{ halted: true, reason }` when the file exists. `reason`
|
|
17
|
+
* is the first non-empty line trimmed and capped at 1024 bytes;
|
|
18
|
+
* missing/blank content collapses to `"Reason unknown"`.
|
|
19
|
+
* - Filesystem errors during the read collapse to a halted sentinel
|
|
20
|
+
* `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
|
|
21
|
+
* `runHookScanBash` inline copy fell through to allow on read
|
|
22
|
+
* failure; that is the wrong posture for a kill switch (an
|
|
23
|
+
* attacker who can prevent the read should not get a free allow).
|
|
24
|
+
* The push-gate's halt.ts already takes this stance; we converge.
|
|
25
|
+
* - NEVER throws.
|
|
26
|
+
*
|
|
27
|
+
* Used by:
|
|
28
|
+
* - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
|
|
29
|
+
* this primitive in 0.32.0)
|
|
30
|
+
* - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
|
|
31
|
+
* `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
|
|
32
|
+
*
|
|
33
|
+
* Distinct from `src/hooks/push-gate/halt.ts`:
|
|
34
|
+
* - The push-gate's `readHalt` is part of the dependency-injected
|
|
35
|
+
* test seam (`PushGateDeps.readHalt`) and cannot be replaced
|
|
36
|
+
* wholesale without breaking the gate's existing contract.
|
|
37
|
+
* - Future-work item: thread `checkHalt` THROUGH the push-gate's
|
|
38
|
+
* `readHalt` default so a single primitive backs every consumer.
|
|
39
|
+
* Out of scope for 0.32.0 — the push-gate ships green and rotating
|
|
40
|
+
* it now would expand the diff without carrying its own bug fix.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Result of a HALT probe.
|
|
44
|
+
*
|
|
45
|
+
* Discriminated union so callers cannot accidentally read `reason` from
|
|
46
|
+
* the not-halted case. The `halted: true` arm always carries a non-
|
|
47
|
+
* empty `reason` — the reader manufactures a placeholder rather than
|
|
48
|
+
* leaving the field undefined (the operator-facing stderr message
|
|
49
|
+
* `REA HALT: <reason>` would render `undefined` otherwise).
|
|
50
|
+
*/
|
|
51
|
+
export type HaltState = {
|
|
52
|
+
halted: true;
|
|
53
|
+
reason: string;
|
|
54
|
+
} | {
|
|
55
|
+
halted: false;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
|
|
59
|
+
* mutate process state. Caller is responsible for the operator-facing
|
|
60
|
+
* stderr emission and the exit code.
|
|
61
|
+
*
|
|
62
|
+
* @param reaRoot Absolute path to the project root that owns `.rea/`.
|
|
63
|
+
* Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
|
|
64
|
+
* `process.cwd()` — callers should pre-resolve before
|
|
65
|
+
* invoking this primitive.
|
|
66
|
+
* @returns `{ halted: false }` when the kill switch is clear, or
|
|
67
|
+
* `{ halted: true, reason }` with a non-empty reason string.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkHalt(reaRoot: string): HaltState;
|
|
70
|
+
/**
|
|
71
|
+
* Render the canonical operator-facing HALT banner. Pulled into a
|
|
72
|
+
* helper so the 5 hook callers (`runHookScanBash`,
|
|
73
|
+
* `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
|
|
74
|
+
* stderr text byte-for-byte. Matches the historical inline string
|
|
75
|
+
* exactly so existing consumer-side log parsers (if any) continue to
|
|
76
|
+
* work.
|
|
77
|
+
*/
|
|
78
|
+
export declare function formatHaltBanner(reason: string): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HALT kill-switch reader for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
|
|
5
|
+
* `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
|
|
6
|
+
* `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
|
|
7
|
+
* carried a third copy with slightly different error semantics (the
|
|
8
|
+
* push-gate variant returns `{ halted: true, reason: 'unknown (HALT
|
|
9
|
+
* file unreadable)' }` on filesystem errors instead of falling through
|
|
10
|
+
* to allow). The Node-binary hook ports landing in 0.32.0 need the
|
|
11
|
+
* same primitive, so consolidate here before more copies accumulate.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
*
|
|
15
|
+
* - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
|
|
16
|
+
* - Returns `{ halted: true, reason }` when the file exists. `reason`
|
|
17
|
+
* is the first non-empty line trimmed and capped at 1024 bytes;
|
|
18
|
+
* missing/blank content collapses to `"Reason unknown"`.
|
|
19
|
+
* - Filesystem errors during the read collapse to a halted sentinel
|
|
20
|
+
* `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
|
|
21
|
+
* `runHookScanBash` inline copy fell through to allow on read
|
|
22
|
+
* failure; that is the wrong posture for a kill switch (an
|
|
23
|
+
* attacker who can prevent the read should not get a free allow).
|
|
24
|
+
* The push-gate's halt.ts already takes this stance; we converge.
|
|
25
|
+
* - NEVER throws.
|
|
26
|
+
*
|
|
27
|
+
* Used by:
|
|
28
|
+
* - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
|
|
29
|
+
* this primitive in 0.32.0)
|
|
30
|
+
* - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
|
|
31
|
+
* `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
|
|
32
|
+
*
|
|
33
|
+
* Distinct from `src/hooks/push-gate/halt.ts`:
|
|
34
|
+
* - The push-gate's `readHalt` is part of the dependency-injected
|
|
35
|
+
* test seam (`PushGateDeps.readHalt`) and cannot be replaced
|
|
36
|
+
* wholesale without breaking the gate's existing contract.
|
|
37
|
+
* - Future-work item: thread `checkHalt` THROUGH the push-gate's
|
|
38
|
+
* `readHalt` default so a single primitive backs every consumer.
|
|
39
|
+
* Out of scope for 0.32.0 — the push-gate ships green and rotating
|
|
40
|
+
* it now would expand the diff without carrying its own bug fix.
|
|
41
|
+
*/
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
/**
|
|
45
|
+
* Maximum bytes of the HALT file we consider when assembling the
|
|
46
|
+
* `reason` line. Defends against a runaway-write scenario where
|
|
47
|
+
* `.rea/HALT` is megabytes large — we always emit the reason on
|
|
48
|
+
* stderr, and a multi-MB stderr blob can overwhelm a TTY before the
|
|
49
|
+
* user sees the actual exit. 1 KiB is more than enough for a human-
|
|
50
|
+
* authored kill-switch reason.
|
|
51
|
+
*/
|
|
52
|
+
const HALT_REASON_MAX_BYTES = 1024;
|
|
53
|
+
/**
|
|
54
|
+
* Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
|
|
55
|
+
* mutate process state. Caller is responsible for the operator-facing
|
|
56
|
+
* stderr emission and the exit code.
|
|
57
|
+
*
|
|
58
|
+
* @param reaRoot Absolute path to the project root that owns `.rea/`.
|
|
59
|
+
* Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
|
|
60
|
+
* `process.cwd()` — callers should pre-resolve before
|
|
61
|
+
* invoking this primitive.
|
|
62
|
+
* @returns `{ halted: false }` when the kill switch is clear, or
|
|
63
|
+
* `{ halted: true, reason }` with a non-empty reason string.
|
|
64
|
+
*/
|
|
65
|
+
export function checkHalt(reaRoot) {
|
|
66
|
+
const haltPath = path.join(reaRoot, '.rea', 'HALT');
|
|
67
|
+
if (!fs.existsSync(haltPath)) {
|
|
68
|
+
return { halted: false };
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(haltPath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Fail-closed: the file exists (existsSync passed) but we cannot
|
|
76
|
+
// read it. The operator intended to halt; a permissions glitch or
|
|
77
|
+
// race that prevents the read should NOT translate into a free
|
|
78
|
+
// allow. Surface a generic reason so the operator knows the file
|
|
79
|
+
// was present even when its content was unreadable.
|
|
80
|
+
return { halted: true, reason: 'unknown (HALT file unreadable)' };
|
|
81
|
+
}
|
|
82
|
+
// Cap at HALT_REASON_MAX_BYTES BEFORE splitting to bound the work.
|
|
83
|
+
// The pre-0.32.0 inline copies sliced the entire file content first
|
|
84
|
+
// and then trimmed; that is identical behavior for any reasonable
|
|
85
|
+
// file size but differs unboundedly for pathological inputs.
|
|
86
|
+
const slice = raw.length > HALT_REASON_MAX_BYTES ? raw.slice(0, HALT_REASON_MAX_BYTES) : raw;
|
|
87
|
+
const firstNonEmpty = slice
|
|
88
|
+
.split(/\r?\n/)
|
|
89
|
+
.map((l) => l.trim())
|
|
90
|
+
.find((l) => l.length > 0);
|
|
91
|
+
return {
|
|
92
|
+
halted: true,
|
|
93
|
+
reason: firstNonEmpty !== undefined && firstNonEmpty.length > 0 ? firstNonEmpty : 'Reason unknown',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Render the canonical operator-facing HALT banner. Pulled into a
|
|
98
|
+
* helper so the 5 hook callers (`runHookScanBash`,
|
|
99
|
+
* `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
|
|
100
|
+
* stderr text byte-for-byte. Matches the historical inline string
|
|
101
|
+
* exactly so existing consumer-side log parsers (if any) continue to
|
|
102
|
+
* work.
|
|
103
|
+
*/
|
|
104
|
+
export function formatHaltBanner(reason) {
|
|
105
|
+
return `REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared stdin payload primitive for the Node-binary hook tier.
|
|
3
|
+
*
|
|
4
|
+
* 0.32.0 — extracts the `INPUT=$(cat) ; jq -r '.tool_input.command'`
|
|
5
|
+
* pattern that every bash hook in `hooks/` repeats. The Node-binary
|
|
6
|
+
* scan-bash already does this work in `runHookScanBash` (lines 225-258
|
|
7
|
+
* of `src/cli/hook.ts`); the Phase 1 pilots landing in 0.32.0 need
|
|
8
|
+
* the same primitive without copy-pasting the parsing + type-guard +
|
|
9
|
+
* fail-closed-on-malformed-JSON dance into each new hook.
|
|
10
|
+
*
|
|
11
|
+
* The shape mirrors the bash hooks' contract verbatim:
|
|
12
|
+
*
|
|
13
|
+
* - `tool_input.command` is the only field we read; bash hooks only
|
|
14
|
+
* ever ran `jq -r '.tool_input.command // ""'` against this payload.
|
|
15
|
+
* - `tool_name` is also surfaced because two bash hooks
|
|
16
|
+
* (`pr-issue-link-gate.sh` and `security-disclosure-gate.sh`)
|
|
17
|
+
* short-circuit when the tool isn't `Bash`.
|
|
18
|
+
*
|
|
19
|
+
* Failure modes:
|
|
20
|
+
*
|
|
21
|
+
* - Empty stdin → `{ command: '', toolName: '' }`. The bash hooks
|
|
22
|
+
* allow on empty command (`[[ -z "$CMD" ]] && exit 0`); the Node
|
|
23
|
+
* port preserves this by returning empty strings rather than
|
|
24
|
+
* throwing.
|
|
25
|
+
* - Malformed JSON → throws `MalformedPayloadError`. The caller
|
|
26
|
+
* decides whether to fail-closed (block) or fail-open (allow);
|
|
27
|
+
* `runHookScanBash` chose fail-closed (block) and the Phase 1
|
|
28
|
+
* pilots match that posture for consistency.
|
|
29
|
+
* - `tool_input.command` is non-string → throws `TypePayloadError`.
|
|
30
|
+
* A crafted payload like `{"tool_input":{"command":["rm","-rf"]}}`
|
|
31
|
+
* would silently coerce to `''` if we used `String(c)`; that
|
|
32
|
+
* would translate into a free allow. Refuse instead.
|
|
33
|
+
*/
|
|
34
|
+
import { Buffer } from 'node:buffer';
|
|
35
|
+
/**
|
|
36
|
+
* Result of parsing a Claude Code hook PreToolUse stdin payload.
|
|
37
|
+
*/
|
|
38
|
+
export interface HookPayload {
|
|
39
|
+
/** `tool_name` from the payload, or `''` when absent. */
|
|
40
|
+
toolName: string;
|
|
41
|
+
/** `tool_input.command` from the payload, or `''` when absent. */
|
|
42
|
+
command: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when stdin contains content that is not valid JSON.
|
|
46
|
+
*
|
|
47
|
+
* Distinct error class so callers can `instanceof` discriminate without
|
|
48
|
+
* leaning on string matching of the message.
|
|
49
|
+
*/
|
|
50
|
+
export declare class MalformedPayloadError extends Error {
|
|
51
|
+
constructor(message: string);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown when the JSON parses but `tool_input.command` is present and
|
|
55
|
+
* has the wrong type (anything other than `string` / `undefined`).
|
|
56
|
+
*/
|
|
57
|
+
export declare class TypePayloadError extends Error {
|
|
58
|
+
constructor(message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse a Claude Code PreToolUse stdin payload. Pure function — no I/O.
|
|
62
|
+
*
|
|
63
|
+
* @param raw Bytes / string read from the hook's stdin (the `INPUT=$(cat)`
|
|
64
|
+
* equivalent).
|
|
65
|
+
* @returns A normalized `HookPayload` with both fields always defined.
|
|
66
|
+
* @throws MalformedPayloadError if the input is not parseable JSON.
|
|
67
|
+
* @throws TypePayloadError if `tool_input.command` is present with a
|
|
68
|
+
* non-string type.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseHookPayload(raw: string | Buffer): HookPayload;
|
|
71
|
+
/**
|
|
72
|
+
* Read all of stdin into a string with a soft byte cap and a hard
|
|
73
|
+
* timeout. Mirrors the `readStdinWithTimeout` helper in
|
|
74
|
+
* `src/cli/hook.ts` (which scans a fixed timeout but no byte cap).
|
|
75
|
+
*
|
|
76
|
+
* The cap (default 1 MiB) defends against a misbehaving caller piping
|
|
77
|
+
* an unbounded payload — we'd otherwise sit in the read loop forever
|
|
78
|
+
* even if the caller eventually closed stdin.
|
|
79
|
+
*
|
|
80
|
+
* @param timeoutMs How long to wait for stdin to close before resolving
|
|
81
|
+
* with whatever we have. Default 5_000 ms.
|
|
82
|
+
* @param maxBytes Soft cap on total bytes accepted. Default 1 MiB.
|
|
83
|
+
* Once reached, additional chunks are dropped silently
|
|
84
|
+
* (the caller still gets a parseable string back).
|
|
85
|
+
*/
|
|
86
|
+
export declare function readStdinWithTimeout(timeoutMs?: number, maxBytes?: number): Promise<string>;
|