@event4u/agent-config 9.24.0 → 9.26.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +136 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +33 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +111 -0
- package/dist/agent-src/rules/session-canary.md +15 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +23 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +146 -28
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +16 -11
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +12 -7
- package/dist/hooks/dispatch.js +1501 -511
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +131 -5
- package/src/config/gate-violation-baselines.json +11 -0
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +146 -5
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +422 -0
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +328 -0
- package/src/scripts/hook_manifest.yaml +69 -9
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +368 -0
- package/src/scripts/language_mirror_hook.ts +456 -0
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/lint_rule_interactions.ts +90 -0
- package/src/scripts/lint_rule_migration_ledger.ts +480 -0
- package/src/scripts/lint_skill_descriptions.ts +86 -2
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsConsent.ts +136 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Unauthorized-git gate — `pre_tool_use` concern.
|
|
4
|
+
*
|
|
5
|
+
* Checks a git / GitHub / npm operation against the authorization ledger that
|
|
6
|
+
* `git_authorization_hook.ts` writes on `user_prompt_submit`, so the question
|
|
7
|
+
* "did the user authorize this THIS turn?" is answered from a recorded fact
|
|
8
|
+
* rather than from the model's recollection.
|
|
9
|
+
*
|
|
10
|
+
* SEVERITY SPLIT — by reversibility, not by confidence.
|
|
11
|
+
*
|
|
12
|
+
* The AI council (2026-08-06) split on block-vs-warn: one member argued block
|
|
13
|
+
* from day one because a published npm release had already happened; the other
|
|
14
|
+
* argued warn-first to gather false-positive data. The repo's locked
|
|
15
|
+
* `ratchet severity, never reach` decision settles it without overriding
|
|
16
|
+
* either:
|
|
17
|
+
*
|
|
18
|
+
* BLOCK — only operations that `non-destructive-by-default` ALREADY declares
|
|
19
|
+
* never-autonomous and that cannot be undone: `npm publish`, a tag push,
|
|
20
|
+
* `gh release create`, `gh pr merge`. Blocking these adds no new prohibition;
|
|
21
|
+
* it makes an existing one mechanical. That is severity, not reach.
|
|
22
|
+
*
|
|
23
|
+
* WARN — `git commit`, `git push`, `gh pr create`, branch creation,
|
|
24
|
+
* force-push. Real violations were measured here too (two PRs opened on turns
|
|
25
|
+
* with zero authorization), but these are recoverable, and a false positive on
|
|
26
|
+
* `git commit` would be a workflow cliff.
|
|
27
|
+
*
|
|
28
|
+
* KNOWN CONSERVATIVE OVER-BLOCK: `gh pr merge` does not name its base in the
|
|
29
|
+
* command, so a merge into a non-production base is blocked as if it were a
|
|
30
|
+
* production-trunk merge. Stated rather than silently narrowed — the escape is
|
|
31
|
+
* one authorizing word in the prompt, and in this repo virtually every PR
|
|
32
|
+
* targets `main`.
|
|
33
|
+
*
|
|
34
|
+
* NO LEDGER = NOT AUTHORIZED for the block subset, warn for the rest. A hook
|
|
35
|
+
* that fails open on its own state file would be decorative on exactly the
|
|
36
|
+
* turns where the state write failed.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes (dispatcher contract): 0 allow · 2 block (stderr carries the
|
|
39
|
+
* reason on block-capable events) — the dispatcher's `host_semantics`
|
|
40
|
+
* translation owns the per-platform mapping.
|
|
41
|
+
*/
|
|
42
|
+
import fs from "node:fs";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
45
|
+
|
|
46
|
+
import { readHookStdin } from "./hook_stdin.js";
|
|
47
|
+
import { STATE_FILE, type GitOp } from "../git_authorization_hook.js";
|
|
48
|
+
|
|
49
|
+
const EXIT_ALLOW = 0;
|
|
50
|
+
// MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
|
|
51
|
+
// 0 allow / 1 block / 2 warn — NOT the 2-means-block shape a PreToolUse guard
|
|
52
|
+
// reads naturally from Claude's own native contract. This constant was 2 when
|
|
53
|
+
// this gate first shipped, so the dispatcher reduced every refusal to a WARN
|
|
54
|
+
// and the gate emitted advisory context while the operation went through.
|
|
55
|
+
// Pinned against the dispatcher's export by
|
|
56
|
+
// tests/hooks/concern_block_exit_parity.test.ts.
|
|
57
|
+
const EXIT_BLOCK = 1;
|
|
58
|
+
|
|
59
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
60
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
61
|
+
|
|
62
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
63
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Irreversible operations — blocked without a this-turn authorization. */
|
|
67
|
+
export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
|
|
68
|
+
"publish",
|
|
69
|
+
"tag",
|
|
70
|
+
"release",
|
|
71
|
+
"pr-merge",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Recoverable operations — warned without a this-turn authorization. */
|
|
75
|
+
export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
|
|
76
|
+
"commit",
|
|
77
|
+
"push",
|
|
78
|
+
"pr-create",
|
|
79
|
+
"branch",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Command → operation. Order matters: the most specific pattern wins, so
|
|
84
|
+
* `git push --tags` is a tag push and not a plain push.
|
|
85
|
+
*/
|
|
86
|
+
// `git`/`gh` accept global options before the subcommand (`git -C dir push`,
|
|
87
|
+
// `git --git-dir=x push`), and each of those was a silent bypass of the whole
|
|
88
|
+
// gate. `G` allows any run of them.
|
|
89
|
+
const G = "(?:-[A-Za-z-]+(?:=\\S+)?(?:\\s+\\S+)?\\s+)*";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Command → operation. Order matters: the most specific pattern wins, so
|
|
93
|
+
* `git push --tags` is a tag push and not a plain push.
|
|
94
|
+
*/
|
|
95
|
+
const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
96
|
+
{ op: "publish", re: new RegExp(`\\b(npm|pnpm|yarn)\\s+${G}publish\\b`, "i") },
|
|
97
|
+
{ op: "release", re: /\bgh\s+release\s+create\b|\bgh\s+api\b[^\n]*\/releases\b/i },
|
|
98
|
+
{
|
|
99
|
+
op: "tag",
|
|
100
|
+
re: new RegExp(
|
|
101
|
+
`\\bgit\\s+${G}push\\b[^\\n;|&]*(--tags|--follow-tags|refs\\/tags\\/)`,
|
|
102
|
+
"i",
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
{ op: "pr-merge", re: /\bgh\s+pr\s+merge\b|\bgh\s+api\b[^\n]*\/pulls\/\d+\/merge\b/i },
|
|
106
|
+
{ op: "pr-create", re: /\bgh\s+pr\s+create\b|\bgh\s+api\b[^\n]*-X\s+POST[^\n]*\/pulls\b/i },
|
|
107
|
+
{ op: "push", re: new RegExp(`\\bgit\\s+${G}push\\b`, "i") },
|
|
108
|
+
{ op: "commit", re: new RegExp(`\\bgit\\s+${G}commit\\b`, "i") },
|
|
109
|
+
{ op: "branch", re: new RegExp(`\\bgit\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const COMMAND_TOOLS: ReadonlySet<string> = new Set([
|
|
113
|
+
"launch-process",
|
|
114
|
+
"launch_process",
|
|
115
|
+
"Bash",
|
|
116
|
+
"BashTool",
|
|
117
|
+
"run-process",
|
|
118
|
+
"runProcess",
|
|
119
|
+
"shell",
|
|
120
|
+
"execute_shell",
|
|
121
|
+
"RunShellCommand",
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/** Extract the shell command from a platform-native pre-tool envelope. */
|
|
125
|
+
export function extractCommand(envelope: JsonObject): string | null {
|
|
126
|
+
const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
|
|
127
|
+
const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
|
|
128
|
+
const tool = typeof toolRaw === "string" ? toolRaw : null;
|
|
129
|
+
if (tool !== null && !COMMAND_TOOLS.has(tool)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const input = _isObject(payload["tool_input"])
|
|
133
|
+
? (payload["tool_input"] as JsonObject)
|
|
134
|
+
: _isObject(payload["toolInput"])
|
|
135
|
+
? (payload["toolInput"] as JsonObject)
|
|
136
|
+
: payload;
|
|
137
|
+
for (const key of ["command", "cmd", "script"]) {
|
|
138
|
+
const v = input[key];
|
|
139
|
+
if (typeof v === "string" && v.trim()) {
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Split a shell command into the segments that are actually INVOKED.
|
|
148
|
+
*
|
|
149
|
+
* A substring match over the whole command string is wrong, and measurably so:
|
|
150
|
+
* the round-2 self-scan found this gate firing on two commands of the auditor's
|
|
151
|
+
* own session whose only sin was carrying `npm publish` inside a `printf`
|
|
152
|
+
* argument as test data. Blocking a command because it mentions an operation is
|
|
153
|
+
* the worst false-positive shape a refusing gate can have.
|
|
154
|
+
*
|
|
155
|
+
* So: split on shell separators, drop leading env assignments, and keep the
|
|
156
|
+
* segment only from its command word. `printf '{"cmd":"npm publish"}'` yields a
|
|
157
|
+
* segment starting at `printf` and never matches.
|
|
158
|
+
*
|
|
159
|
+
* Quoted payloads are NOT simply discarded — `sh -c "npm publish"` really does
|
|
160
|
+
* publish. Those are unwrapped and re-split, so closing the false positive does
|
|
161
|
+
* not open a bypass.
|
|
162
|
+
*/
|
|
163
|
+
export function invokedSegments(command: string, depth = 0): string[] {
|
|
164
|
+
if (depth > 3) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
// Strip heredoc bodies before splitting: a line inside `<<EOF … EOF` is data
|
|
169
|
+
// being written, not a command being run, but after a newline split it looks
|
|
170
|
+
// exactly like an invocation.
|
|
171
|
+
const withoutHeredocs = command.replace(
|
|
172
|
+
/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
|
|
173
|
+
"<<HEREDOC",
|
|
174
|
+
);
|
|
175
|
+
for (const raw of withoutHeredocs.split(/\n|;|&&|\|\||\||&/)) {
|
|
176
|
+
let seg = raw.trim();
|
|
177
|
+
if (!seg) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Drop leading env assignments: `FOO=bar BAZ=1 npm publish`.
|
|
181
|
+
seg = seg.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "");
|
|
182
|
+
// Unwrap `sh -c '<payload>'` / `bash -c "<payload>"` and recurse.
|
|
183
|
+
const wrapped = /^(?:\S*\/)?(?:ba|z|k)?sh\s+-[a-z]*c\s+(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(seg);
|
|
184
|
+
if (wrapped) {
|
|
185
|
+
out.push(...invokedSegments(wrapped[1] ?? wrapped[2] ?? wrapped[3] ?? "", depth + 1));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
out.push(seg);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Classify a shell command into the operation it performs, if any.
|
|
195
|
+
*
|
|
196
|
+
* Matches per invoked segment and only when the segment BEGINS with the tool —
|
|
197
|
+
* a mention inside an argument is not an invocation.
|
|
198
|
+
*/
|
|
199
|
+
export function commandOp(command: string): GitOp | null {
|
|
200
|
+
// Every segment is classified and the MOST SEVERE result wins. Returning the
|
|
201
|
+
// first match let `npm publish && git push --tags` pass its tag push
|
|
202
|
+
// unchecked once `publish` was authorized.
|
|
203
|
+
let found: GitOp | null = null;
|
|
204
|
+
for (const seg of invokedSegments(command)) {
|
|
205
|
+
if (!/^(?:\S*\/)?(git|gh|npm|pnpm|yarn)\b/.test(seg)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const { op, re } of COMMAND_OPS) {
|
|
209
|
+
if (re.test(seg)) {
|
|
210
|
+
if (BLOCK_OPS.has(op)) {
|
|
211
|
+
return op;
|
|
212
|
+
}
|
|
213
|
+
found ??= op;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A ledger older than this is not "this turn" under any reading. */
|
|
222
|
+
export const LEDGER_MAX_AGE_MS = 30 * 60 * 1000;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Load the ledger, and refuse one that does not belong to THIS session and
|
|
226
|
+
* THIS turn.
|
|
227
|
+
*
|
|
228
|
+
* The header claims the gate answers "did the user authorize this THIS turn?".
|
|
229
|
+
* Before this check it answered "did anyone, in any session, ever authorize
|
|
230
|
+
* it?" — the file records `session_id` and `detected_at` and neither was read.
|
|
231
|
+
* Two concurrent worktrees share one state root, so session A typing
|
|
232
|
+
* `mach den release` authorized session B.
|
|
233
|
+
*/
|
|
234
|
+
function _loadLedger(
|
|
235
|
+
consumer_root: string,
|
|
236
|
+
session_id: string,
|
|
237
|
+
now: number,
|
|
238
|
+
): { authorized: Set<GitOp>; present: boolean } {
|
|
239
|
+
try {
|
|
240
|
+
const raw = fs.readFileSync(path.join(consumer_root, STATE_FILE), "utf8");
|
|
241
|
+
const decoded = JSON.parse(raw) as unknown;
|
|
242
|
+
if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
|
|
243
|
+
const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
244
|
+
// A ledger from a different session is another conversation's consent.
|
|
245
|
+
if (session_id && ledgerSession && ledgerSession !== session_id) {
|
|
246
|
+
return { authorized: new Set<GitOp>(), present: false };
|
|
247
|
+
}
|
|
248
|
+
const at = Date.parse(String(decoded["detected_at"] ?? ""));
|
|
249
|
+
if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
|
|
250
|
+
return { authorized: new Set<GitOp>(), present: false };
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
|
|
254
|
+
present: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* fall through */
|
|
259
|
+
}
|
|
260
|
+
return { authorized: new Set<GitOp>(), present: false };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface Decision {
|
|
264
|
+
exit: number;
|
|
265
|
+
stdout: string;
|
|
266
|
+
stderr: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function decide(
|
|
270
|
+
command: string | null,
|
|
271
|
+
ledger: { authorized: Set<GitOp>; present: boolean },
|
|
272
|
+
): Decision {
|
|
273
|
+
if (command === null) {
|
|
274
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
275
|
+
}
|
|
276
|
+
const op = commandOp(command);
|
|
277
|
+
if (op === null || ledger.authorized.has(op)) {
|
|
278
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
|
|
282
|
+
|
|
283
|
+
if (BLOCK_OPS.has(op)) {
|
|
284
|
+
const reason =
|
|
285
|
+
`Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
|
|
286
|
+
`This operation is irreversible and non-destructive-by-default already declares it ` +
|
|
287
|
+
`never-autonomous. Command: ${shown}. ` +
|
|
288
|
+
`Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
289
|
+
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (WARN_OPS.has(op)) {
|
|
293
|
+
const reason =
|
|
294
|
+
`No \`${op}\` authorization found in this turn's prompt (commit-policy: a one-shot ` +
|
|
295
|
+
`authorization is spent on the operation it named). If the user did authorize it this ` +
|
|
296
|
+
`turn, proceed; otherwise stop and ask. Command: ${shown}`;
|
|
297
|
+
return {
|
|
298
|
+
exit: EXIT_ALLOW,
|
|
299
|
+
stdout: `${JSON.stringify({ decision: "warn", reason })}\n`,
|
|
300
|
+
stderr: "",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
308
|
+
let envelope: JsonObject = {};
|
|
309
|
+
if (stdin_text.trim()) {
|
|
310
|
+
try {
|
|
311
|
+
const decoded = JSON.parse(stdin_text) as unknown;
|
|
312
|
+
if (_isObject(decoded)) {
|
|
313
|
+
envelope = decoded;
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
return EXIT_ALLOW;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
320
|
+
const decision = decide(
|
|
321
|
+
extractCommand(envelope),
|
|
322
|
+
_loadLedger(options.consumer_root, session_id, Date.now()),
|
|
323
|
+
);
|
|
324
|
+
if (decision.stdout) {
|
|
325
|
+
process.stdout.write(decision.stdout);
|
|
326
|
+
}
|
|
327
|
+
if (decision.stderr) {
|
|
328
|
+
process.stderr.write(decision.stderr);
|
|
329
|
+
}
|
|
330
|
+
return decision.exit;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function main(argv?: string[]): number {
|
|
334
|
+
const args = argv ?? process.argv.slice(2);
|
|
335
|
+
let consumer_root = process.cwd();
|
|
336
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
337
|
+
const a = args[i];
|
|
338
|
+
if (a === "--project-dir" && args[i + 1] !== undefined) {
|
|
339
|
+
consumer_root = args[i + 1] as string;
|
|
340
|
+
i += 1;
|
|
341
|
+
} else if (a !== undefined && a.startsWith("--project-dir=")) {
|
|
342
|
+
consumer_root = a.slice("--project-dir=".length);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return run(readHookStdin(), { consumer_root });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
349
|
+
function _isCliEntry(): boolean {
|
|
350
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (process.argv[1] === undefined) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
357
|
+
if (import.meta.url === argvUrl) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
362
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
363
|
+
return here === argv;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (_isCliEntry()) {
|
|
370
|
+
process.exit(main());
|
|
371
|
+
}
|
|
@@ -41,6 +41,10 @@ import { main as surfaceProbeMain } from '../surface_probe_hook.js';
|
|
|
41
41
|
import { main as teamReviewGateMain } from '../team_review_gate_hook.js';
|
|
42
42
|
import { main as prUrlReminderMain } from '../pr_url_reminder_hook.js';
|
|
43
43
|
import { main as sessionCanaryMain } from '../session_canary_hook.js';
|
|
44
|
+
import { main as languageMirrorMain } from '../language_mirror_hook.js';
|
|
45
|
+
import { main as gitAuthorizationMain } from '../git_authorization_hook.js';
|
|
46
|
+
import { main as blockUnauthorizedGitMain } from './block_unauthorized_git.js';
|
|
47
|
+
import { main as evidenceIndependenceMain } from './evidence_independence.js';
|
|
44
48
|
import { main as blockNoVerifyMain } from './block_no_verify.js';
|
|
45
49
|
import { main as blockKernelRuleWritesMain } from './block_kernel_rule_writes.js';
|
|
46
50
|
import { main as blockConfigWeakeningMain } from './block_config_weakening.js';
|
|
@@ -70,6 +74,10 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
|
|
|
70
74
|
'src/scripts/team_review_gate_hook.ts': teamReviewGateMain as ConcernMain,
|
|
71
75
|
'src/scripts/pr_url_reminder_hook.ts': prUrlReminderMain as ConcernMain,
|
|
72
76
|
'src/scripts/session_canary_hook.ts': sessionCanaryMain as ConcernMain,
|
|
77
|
+
'src/scripts/language_mirror_hook.ts': languageMirrorMain as ConcernMain,
|
|
78
|
+
'src/scripts/git_authorization_hook.ts': gitAuthorizationMain as ConcernMain,
|
|
79
|
+
'src/scripts/hooks/block_unauthorized_git.ts': blockUnauthorizedGitMain as ConcernMain,
|
|
80
|
+
'src/scripts/hooks/evidence_independence.ts': evidenceIndependenceMain as ConcernMain,
|
|
73
81
|
'src/scripts/hooks/block_no_verify.ts': blockNoVerifyMain as ConcernMain,
|
|
74
82
|
'src/scripts/hooks/block_kernel_rule_writes.ts': blockKernelRuleWritesMain as ConcernMain,
|
|
75
83
|
'src/scripts/hooks/block_config_weakening.ts': blockConfigWeakeningMain as ConcernMain,
|
|
@@ -71,8 +71,20 @@ function enabled(root: string): boolean {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The dispatcher nests the host-shaped tool fields under `payload`; a bare host
|
|
76
|
+
* invocation puts them at the top level. Read both — this hook shipped reading
|
|
77
|
+
* only the top level, so under the dispatcher `extract` always returned null and
|
|
78
|
+
* the concern was silent on every write it was supposed to flag.
|
|
79
|
+
*/
|
|
80
|
+
function _unwrap(envelope: JsonObject): JsonObject {
|
|
81
|
+
const inner = envelope["payload"];
|
|
82
|
+
return isObject(inner) ? inner : envelope;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
/** Best-effort: pull (toolName, filePath, proposedContent) from the PreToolUse envelope. */
|
|
75
|
-
function extract(
|
|
86
|
+
function extract(outer: JsonObject): { file: string; content: string } | null {
|
|
87
|
+
const envelope = _unwrap(outer);
|
|
76
88
|
const ti = envelope["tool_input"] ?? envelope["toolInput"] ?? envelope["input"];
|
|
77
89
|
if (!isObject(ti)) return null;
|
|
78
90
|
const fileVal = ti["file_path"] ?? ti["path"] ?? ti["filePath"];
|
|
@@ -231,6 +231,79 @@ interface ConcernDef extends JsonObject {
|
|
|
231
231
|
name: string;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The tool name the host reported for this event, or `""`.
|
|
236
|
+
*
|
|
237
|
+
* Only tool events carry one. Read defensively: the payload is host-shaped and
|
|
238
|
+
* a missing / non-string field must degrade to "unknown tool", never throw.
|
|
239
|
+
*/
|
|
240
|
+
export function _payload_tool_name(envelope: JsonObject): string {
|
|
241
|
+
const payload = envelope["payload"];
|
|
242
|
+
if (!_isObject(payload)) {
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
const raw = payload["tool_name"];
|
|
246
|
+
return typeof raw === "string" ? raw : "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Per-concern tool filter — the `tools:` key.
|
|
251
|
+
*
|
|
252
|
+
* 13 concerns fire on EVERY tool call (6 pre + 7 post), and three of them
|
|
253
|
+
* already re-read `tool_name` and return early — after the dispatcher has
|
|
254
|
+
* already spawned the work. A concern may now declare which tools it cares
|
|
255
|
+
* about, and the dispatcher skips it in-process for everything else.
|
|
256
|
+
*
|
|
257
|
+
* ## Why the filter is here and not in the generated host config
|
|
258
|
+
*
|
|
259
|
+
* The obvious alternative is a host `matcher` per concern group. Rejected on
|
|
260
|
+
* two measurements:
|
|
261
|
+
*
|
|
262
|
+
* - `build_claude_hook_matrix` collapses each event to ONE command string, and
|
|
263
|
+
* `claude_hook_matrix_parity.test.ts` asserts exactly one group per event with
|
|
264
|
+
* exactly one command. Per-concern matchers mean per-concern groups, which
|
|
265
|
+
* breaks that parity contract for a filter the dispatcher can apply itself.
|
|
266
|
+
* - A host matcher would only help the two hosts that support one (Claude,
|
|
267
|
+
* Gemini). The in-process skip helps all eight platforms in the manifest.
|
|
268
|
+
*
|
|
269
|
+
* ## What this does NOT claim
|
|
270
|
+
*
|
|
271
|
+
* It does not claim a latency win. The measured hook cost that was fixed was
|
|
272
|
+
* the *invocation path* (~370 of ~450–500 ms was eager CLI imports); nothing in
|
|
273
|
+
* the tree measures how much of the current ~84 ms p95 the concern bodies
|
|
274
|
+
* account for. `bench_hook_latency` reads the manifest, so the claim is
|
|
275
|
+
* benchable — it is simply not yet benched, and is therefore not asserted.
|
|
276
|
+
* What IS true without a benchmark: a concern that cannot fire on a tool no
|
|
277
|
+
* longer runs at all, instead of running and returning early.
|
|
278
|
+
*
|
|
279
|
+
* Absent `tools:` → the concern runs on every event, unchanged. `"*"` is the
|
|
280
|
+
* explicit form of the same thing. Matching is exact on the host's `tool_name`.
|
|
281
|
+
* A non-tool event (session_start, stop, …) reports no tool name and is NEVER
|
|
282
|
+
* filtered — a lifecycle concern must not be silently skipped by a key that
|
|
283
|
+
* only describes tool events.
|
|
284
|
+
*/
|
|
285
|
+
export function _concern_matches_tool(concern: JsonObject, tool_name: string): boolean {
|
|
286
|
+
const raw = concern["tools"];
|
|
287
|
+
if (raw === undefined || raw === null) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
291
|
+
// A malformed or empty filter must not silence a concern — fail toward
|
|
292
|
+
// running it, the same direction `fail_closed` guards take.
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
const names = raw.filter((t): t is string => typeof t === "string");
|
|
296
|
+
if (names.length === 0 || names.includes("*")) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
// No tool in the payload → not a tool event (or a host that omits it).
|
|
300
|
+
// Either way this key cannot decide, so it does not.
|
|
301
|
+
if (tool_name === "") {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
return names.includes(tool_name);
|
|
305
|
+
}
|
|
306
|
+
|
|
234
307
|
/**
|
|
235
308
|
* Return the ordered concern definitions for (platform, event).
|
|
236
309
|
*/
|
|
@@ -995,7 +1068,13 @@ export function main(argv?: string[]): number {
|
|
|
995
1068
|
// fixed-field schema (PII-exclusion-by-construction), and a concern's raw
|
|
996
1069
|
// stderr is free-form content. This array is in-memory only.
|
|
997
1070
|
const concern_messages: Array<{ rc: number; text: string }> = [];
|
|
1071
|
+
// Per-concern `tools:` filter (see _concern_matches_tool). Applied here so it
|
|
1072
|
+
// is one place, after the envelope exists and before any concern is spawned.
|
|
1073
|
+
const tool_name = _payload_tool_name(envelope);
|
|
998
1074
|
for (const concern of concerns) {
|
|
1075
|
+
if (!_concern_matches_tool(concern, tool_name)) {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
999
1078
|
const concern_started = _now_iso();
|
|
1000
1079
|
const { rc: rawRcResult, stderr: stderr_text, stdout: stdout_text, duration_ms } =
|
|
1001
1080
|
_run_concern(concern, envelope);
|
|
@@ -1032,8 +1111,24 @@ export function main(argv?: string[]): number {
|
|
|
1032
1111
|
typeof reply["reason"] === "string" && (reply["reason"] as string).trim()
|
|
1033
1112
|
? (reply["reason"] as string).trim()
|
|
1034
1113
|
: stderr_text.trim();
|
|
1035
|
-
|
|
1036
|
-
|
|
1114
|
+
// `additional_context` — the longer, model-facing half of a concern's
|
|
1115
|
+
// reply. The contract has promised since v1 that it is forwarded and
|
|
1116
|
+
// "concatenated with \n\n separators"
|
|
1117
|
+
// (docs/contracts/hook-architecture-v1.md § reply shape), but nothing here
|
|
1118
|
+
// ever read the key: `reason` is capped at ~200 chars by convention and is
|
|
1119
|
+
// written for a human reading stderr, so every concern that put its
|
|
1120
|
+
// actionable instruction in `additional_context` delivered it nowhere.
|
|
1121
|
+
// Found 2026-08-06 by tracing why the language-mirror pin reached the model
|
|
1122
|
+
// on no path; `pr_url_reminder` had the same silent gap, and its own header
|
|
1123
|
+
// calls `additional_context` "what surfaces back to the model".
|
|
1124
|
+
const extra =
|
|
1125
|
+
typeof reply["additional_context"] === "string" &&
|
|
1126
|
+
(reply["additional_context"] as string).trim()
|
|
1127
|
+
? (reply["additional_context"] as string).trim()
|
|
1128
|
+
: "";
|
|
1129
|
+
const message = [stated, extra].filter(Boolean).join("\n\n");
|
|
1130
|
+
if (message) {
|
|
1131
|
+
concern_messages.push({ rc, text: message });
|
|
1037
1132
|
}
|
|
1038
1133
|
if (
|
|
1039
1134
|
args.event === "session_start" &&
|