@windyroad/risk-scorer 0.19.8-preview.1264 → 0.19.8

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.
@@ -25,6 +25,66 @@ function pendingDir() {
25
25
  return join(process.env.TMPDIR || "/tmp", "claude-risk-pending");
26
26
  }
27
27
 
28
+ function transportDir() {
29
+ return join(process.env.TMPDIR || "/tmp", "codex-review-transport");
30
+ }
31
+
32
+ function normalizeTarget(target) {
33
+ if (typeof target !== "string") return "";
34
+ return target.startsWith("/root/") ? target.slice("/root/".length) : target;
35
+ }
36
+
37
+ function transportId(sessionId, role, target) {
38
+ return createHash("sha256").update(`${sessionId}\0${role}\0${target}`).digest("hex");
39
+ }
40
+
41
+ function transportRegistrationPath(sessionId, role, target) {
42
+ return join(transportDir(), `registration-${transportId(sessionId, role, target)}.json`);
43
+ }
44
+
45
+ function transportReceiptPath(sessionId, role, target, suffix = "") {
46
+ return join(transportDir(), `risk-receipt-${transportId(sessionId, role, target)}.json${suffix}`);
47
+ }
48
+
49
+ function directoryBinding(cwd) {
50
+ if (typeof cwd !== "string" || !cwd) return null;
51
+ let root;
52
+ try { root = realpathSync(cwd); } catch { return null; }
53
+ const stat = statSync(root);
54
+ return { root, physical: `${stat.dev}:${stat.ino}` };
55
+ }
56
+
57
+ function policyHash(root) {
58
+ if (!existsSync(join(root, "RISK-POLICY.md"))) return null;
59
+ const result = spawnSync("bash", ["-c", 'source "$1"; _substance_hash_path RISK-POLICY.md', "risk-policy",
60
+ join(hookDir, "lib", "gate-helpers.sh")], { cwd: root, encoding: "utf8" });
61
+ const hash = result.stdout?.trim();
62
+ return result.status === 0 && /^[a-f0-9]{64}$/.test(hash || "") ? hash : null;
63
+ }
64
+
65
+ function policyBinding(root) {
66
+ const path = join(root, "RISK-POLICY.md");
67
+ if (!existsSync(path)) return { kind: "absent" };
68
+ const hash = policyHash(root);
69
+ return hash ? { kind: "hash", value: hash } : null;
70
+ }
71
+
72
+ function policyMatches(root, binding) {
73
+ if (!binding || !["absent", "hash"].includes(binding.kind)) return false;
74
+ const exists = existsSync(join(root, "RISK-POLICY.md"));
75
+ if (binding.kind === "absent") return !exists;
76
+ const current = policyHash(root);
77
+ return Boolean(current && current === binding.value);
78
+ }
79
+
80
+ function expectedExternalKey(prompt) {
81
+ if (typeof prompt !== "string" || !prompt) return null;
82
+ const result = spawnSync("bash", ["-c", 'source "$1"; value="$(cat)"; derive_external_comms_key_from_prompt "$value"',
83
+ "external-key", join(hookDir, "lib", "external-comms-key.sh")], { input: prompt, encoding: "utf8" });
84
+ const key = result.stdout?.trim();
85
+ return result.status === 0 && /^[a-f0-9]{64}$/.test(key || "") ? key : null;
86
+ }
87
+
28
88
  function fieldType(input, field) {
29
89
  if (!input || !Object.prototype.hasOwnProperty.call(input, field)) return "absent";
30
90
  if (input[field] === null) return "null";
@@ -62,6 +122,18 @@ function statePath(input, target, suffix = "") {
62
122
  }
63
123
 
64
124
  function clearTarget(input, target) {
125
+ const state = statePath(input, target);
126
+ if (existsSync(state)) {
127
+ try {
128
+ const previousRole = readFileSync(state, "utf8");
129
+ if (riskAgentRoles.has(previousRole)) {
130
+ rmSync(transportRegistrationPath(input.session_id, previousRole, normalizeTarget(target)), { force: true });
131
+ for (const suffix of ["", ".claim", ".done"]) {
132
+ rmSync(transportReceiptPath(input.session_id, previousRole, normalizeTarget(target), suffix), { force: true });
133
+ }
134
+ }
135
+ } catch { /* The local state is best-effort cleanup only. */ }
136
+ }
65
137
  for (const suffix of ["", ".claim", ".done"]) {
66
138
  rmSync(statePath(input, target, suffix), { force: true });
67
139
  }
@@ -73,6 +145,7 @@ function spawnTarget(input) {
73
145
  }
74
146
 
75
147
  function rememberSpawn(input) {
148
+ reapRegisteredTransport();
76
149
  const role = input.tool_input?.agent_type;
77
150
  const target = spawnTarget(input);
78
151
  if (typeof target !== "string" || !target) return;
@@ -80,6 +153,29 @@ function rememberSpawn(input) {
80
153
  clearTarget(input, target);
81
154
  if (!riskAgentRoles.has(role)) return;
82
155
  writeFileSync(statePath(input, target), role, "utf8");
156
+ const normalized = normalizeTarget(target);
157
+ const bound = directoryBinding(input.cwd || process.cwd());
158
+ if (!bound) return;
159
+ const prompt = input.tool_input?.message ?? input.tool_input?.prompt ?? "";
160
+ const policy = policyBinding(bound.root);
161
+ if (!policy) return;
162
+ const wipStateHash = role === "wr-risk-scorer:wip" ? stateHash(bound.root) : null;
163
+ const wipCheckoutId = role === "wr-risk-scorer:wip" ? checkoutId(bound.root) : null;
164
+ if (role === "wr-risk-scorer:wip" && (!wipStateHash || !wipCheckoutId)) return;
165
+ const registered = {
166
+ parentSession: input.session_id,
167
+ role,
168
+ target: normalized,
169
+ ...bound,
170
+ policy,
171
+ promptDigest: createHash("sha256").update(prompt).digest("hex"),
172
+ expectedKey: role === "wr-risk-scorer:external-comms" ? expectedExternalKey(prompt) : null,
173
+ wipStateHash,
174
+ wipCheckoutId,
175
+ createdAt: Date.now(),
176
+ };
177
+ mkdirSync(transportDir(), { recursive: true, mode: 0o700 });
178
+ writeFileSync(transportRegistrationPath(input.session_id, role, normalized), JSON.stringify(registered), { mode: 0o600 });
83
179
  }
84
180
 
85
181
  function claimTarget(input, target) {
@@ -159,92 +255,6 @@ function stateHash(root) {
159
255
  return createHash("md5").update(state.stdout).digest("hex");
160
256
  }
161
257
 
162
- function completionId(input, output) {
163
- if (typeof input.session_id !== "string" || typeof input.agent_id !== "string") return null;
164
- return createHash("sha256").update(`${input.session_id}\0${input.agent_id}\0${output}`).digest("hex");
165
- }
166
-
167
- function pendingPath(id, hash, completion, suffix = "") {
168
- return join(pendingDir(), `${id}-${hash}-${completion}${suffix}`);
169
- }
170
-
171
- function freshReceipt(path) {
172
- if (!existsSync(path)) return false;
173
- const ttl = Number.parseInt(process.env.RISK_TTL || "3600", 10) * 1000;
174
- return Date.now() - statSync(path).mtimeMs < ttl;
175
- }
176
-
177
- function reapExpiredReceipts() {
178
- if (!existsSync(pendingDir())) return;
179
- const receipt = /^[0-9a-f]{64}-[0-9a-f]{32}-[0-9a-f]{64}(?:\.claim|\.done)?$/;
180
- for (const name of readdirSync(pendingDir())) {
181
- if (!receipt.test(name)) continue;
182
- const path = join(pendingDir(), name);
183
- try {
184
- if (!freshReceipt(path)) rmSync(path, { force: true });
185
- } catch {
186
- // A concurrent consumer may have renamed or removed it.
187
- }
188
- }
189
- }
190
-
191
- function persistPendingPipeline(input) {
192
- reapExpiredReceipts();
193
- diagnoseSubagentStop(input, "received", "pipeline-receipt-attempt");
194
- if (input.agent_type !== "wr-risk-scorer:pipeline") {
195
- diagnoseSubagentStop(input, "rejected", "unexpected-agent-type");
196
- return;
197
- }
198
- let rejection;
199
- const assessment = pipelineAssessment(input.last_assistant_message, (reason) => { rejection = reason; });
200
- if (!assessment) {
201
- diagnoseSubagentStop(input, "rejected", rejection || "invalid-assessment");
202
- return;
203
- }
204
- const id = checkoutId(assessment.root);
205
- const hash = stateHash(assessment.root);
206
- const completion = completionId(input, assessment.output);
207
- if (!hash) {
208
- diagnoseSubagentStop(input, "rejected", "state-hash-failed");
209
- return;
210
- }
211
- if (!completion) {
212
- diagnoseSubagentStop(input, "rejected", "missing-completion-identity");
213
- return;
214
- }
215
- if (!/^RISK_SCORES: commit=\d+ push=\d+ release=\d+$/m.test(assessment.output)) {
216
- diagnoseSubagentStop(input, "rejected", "missing-risk-scores");
217
- return;
218
- }
219
- mkdirSync(pendingDir(), { recursive: true });
220
- const path = pendingPath(id, hash, completion);
221
- for (const candidate of [path, `${path}.done`]) {
222
- if (freshReceipt(candidate)) {
223
- diagnoseSubagentStop(input, "duplicate", "fresh-receipt-exists");
224
- return;
225
- }
226
- rmSync(candidate, { force: true });
227
- }
228
- try {
229
- writeFileSync(path, JSON.stringify({
230
- role: input.agent_type,
231
- output: assessment.output,
232
- checkoutId: id,
233
- stateHash: hash,
234
- completionId: completion,
235
- createdAt: Date.now(),
236
- }), { flag: "wx", mode: 0o600 });
237
- diagnoseSubagentStop(input, "receipt-written", "checkout-bound-receipt");
238
- } catch (error) {
239
- if (error?.code === "EEXIST") {
240
- diagnoseSubagentStop(input, "duplicate", "receipt-race");
241
- return;
242
- }
243
- diagnoseSubagentStop(input, "rejected", "receipt-write-failed");
244
- throw error;
245
- }
246
- }
247
-
248
258
  function markTarget(input, target, output) {
249
259
  if (typeof target !== "string" || typeof output !== "string" || !output) return;
250
260
 
@@ -280,6 +290,7 @@ function markTarget(input, target, output) {
280
290
  if (result.status === 0) {
281
291
  renameSync(claim.claim, claim.done);
282
292
  rmSync(state, { force: true });
293
+ rmSync(transportRegistrationPath(input.session_id, role, normalizeTarget(target)), { force: true });
283
294
  } else {
284
295
  rmSync(claim.claim, { force: true });
285
296
  process.exitCode = 1;
@@ -298,90 +309,199 @@ function markWait(input) {
298
309
  }
299
310
  }
300
311
 
301
- function markSubagentStop(input) {
302
- const state = typeof input.agent_id === "string" ? statePath(input, input.agent_id) : "";
303
- if (state && existsSync(state)) {
304
- markTarget(input, input.agent_id, input.last_assistant_message);
305
- return;
312
+ function genericTtl() {
313
+ const text = process.env.RISK_TTL || "3600";
314
+ if (!/^[0-9]+$/.test(text)) return null;
315
+ const value = Number(text) * 1000;
316
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
317
+ }
318
+
319
+ function reapRegisteredTransport() {
320
+ const ttl = genericTtl();
321
+ if (!ttl || !existsSync(transportDir())) return;
322
+ for (const name of readdirSync(transportDir())) {
323
+ const path = join(transportDir(), name);
324
+ try {
325
+ const statAge = Date.now() - statSync(path).mtimeMs;
326
+ if (/^(?:receipt|risk-receipt)-[a-f0-9]{64}\.json\.(?:claim|done)$/.test(name)) {
327
+ if (!Number.isFinite(statAge) || statAge < 0 || statAge >= ttl) rmSync(path, { force: true });
328
+ continue;
329
+ }
330
+ if (!/^(?:registration|risk-receipt)-[a-f0-9]{64}\.json$/.test(name)) continue;
331
+ const value = JSON.parse(readFileSync(path, "utf8"));
332
+ if (!riskAgentRoles.has(value.role)) continue;
333
+ const timestamp = name.startsWith("risk-receipt-") ? value.completedAt : value.createdAt;
334
+ const age = Date.now() - timestamp;
335
+ if (!Number.isFinite(age) || age < 0 || age >= ttl) rmSync(path, { force: true });
336
+ } catch { /* Another completion may own or remove this transport file. */ }
306
337
  }
307
- persistPendingPipeline(input);
308
338
  }
309
339
 
310
- function consumePending(input) {
311
- if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) return;
312
- reapExpiredReceipts();
313
- let root;
314
- try {
315
- root = realpathSync(process.cwd());
316
- } catch {
317
- return;
340
+ function registrationCandidates(input, role, target) {
341
+ reapRegisteredTransport();
342
+ if (!existsSync(transportDir())) return [];
343
+ const current = directoryBinding(input.cwd || process.cwd());
344
+ return readdirSync(transportDir())
345
+ .filter((name) => /^registration-[a-f0-9]{64}\.json$/.test(name))
346
+ .flatMap((name) => {
347
+ const path = join(transportDir(), name);
348
+ try {
349
+ const registered = JSON.parse(readFileSync(path, "utf8"));
350
+ if (registered.role !== role || registered.target !== target) return [];
351
+ if (role !== "wr-risk-scorer:pipeline" && (!current || registered.root !== current.root || registered.physical !== current.physical)) return [];
352
+ return [{ path, registered }];
353
+ } catch { return []; }
354
+ });
355
+ }
356
+
357
+ function singleLine(output, label) {
358
+ return [...output.matchAll(new RegExp(`^${label}:[ \\t]*(.+)$`, "gm"))];
359
+ }
360
+
361
+ function applicableAssessment(role, output, registered, reject = () => {}) {
362
+ if (typeof output !== "string" || !output) return reject("missing-output"), null;
363
+ if (role === "wr-risk-scorer:pipeline") {
364
+ let reason;
365
+ const assessment = pipelineAssessment(output, (value) => { reason = value; });
366
+ if (!assessment || !/^RISK_SCORES: commit=\d+ push=\d+ release=\d+$/m.test(assessment.output)) {
367
+ reject(reason || "missing-risk-scores");
368
+ return null;
369
+ }
370
+ const hash = stateHash(assessment.root);
371
+ if (!hash) return reject("state-hash-failed"), null;
372
+ const assessmentPolicy = policyBinding(assessment.root);
373
+ if (!assessmentPolicy) return reject("policy-hash-failed"), null;
374
+ return { output: assessment.output, assessmentRoot: assessment.root, assessmentPolicy,
375
+ checkoutId: checkoutId(assessment.root), stateHash: hash };
376
+ }
377
+ if (role === "wr-risk-scorer:external-comms") {
378
+ const verdicts = singleLine(output, "EXTERNAL_COMMS_RISK_VERDICT");
379
+ const keys = singleLine(output, "EXTERNAL_COMMS_RISK_KEY");
380
+ const key = keys.length === 1 ? keys[0][1].trim().replaceAll("`", "") : "";
381
+ if (verdicts.length !== 1 || verdicts[0][1].trim() !== "PASS") return reject("non-pass-verdict"), null;
382
+ if (!/^[a-f0-9]{64}$/.test(key)) return reject("invalid-external-comms-key"), null;
383
+ if (!registered.expectedKey) return reject("missing-external-comms-key-binding"), null;
384
+ if (registered.expectedKey !== key) return reject("external-comms-key-mismatch"), null;
385
+ return { output, root: registered.root, key };
386
+ }
387
+ if (role === "wr-risk-scorer:plan" || role === "wr-risk-scorer:policy") {
388
+ const verdicts = singleLine(output, "RISK_VERDICT");
389
+ if (verdicts.length !== 1 || verdicts[0][1].trim() !== "PASS") return reject("non-pass-verdict"), null;
390
+ return { output, root: registered.root };
391
+ }
392
+ if (role === "wr-risk-scorer:wip") {
393
+ const verdicts = singleLine(output, "RISK_VERDICT");
394
+ if (verdicts.length !== 1 || !/^(CONTINUE|COMMIT)$/.test(verdicts[0][1].trim())) return reject("non-authorizing-wip-verdict"), null;
395
+ const currentHash = stateHash(registered.root);
396
+ if (!currentHash || currentHash !== registered.wipStateHash || checkoutId(registered.root) !== registered.wipCheckoutId) return reject("wip-state-changed"), null;
397
+ return { output, root: registered.root, wipStateHash: currentHash, wipCheckoutId: registered.wipCheckoutId };
398
+ }
399
+ reject("unsupported-marker-role");
400
+ return null;
401
+ }
402
+
403
+ function registrationValid(input, registered) {
404
+ const ttl = genericTtl();
405
+ const age = Date.now() - registered.createdAt;
406
+ if (!ttl) return diagnoseSubagentStop(input, "rejected", "invalid-risk-ttl"), false;
407
+ if (!Number.isFinite(age) || age < 0 || age >= ttl) return diagnoseSubagentStop(input, "rejected", "stale-parent-registration"), false;
408
+ if (!registered.policy || !["absent", "hash"].includes(registered.policy.kind)) return diagnoseSubagentStop(input, "rejected", "invalid-policy-binding"), false;
409
+ if (!policyMatches(registered.root, registered.policy)) return diagnoseSubagentStop(input, "rejected", "policy-changed"), false;
410
+ return true;
411
+ }
412
+
413
+ function persistRegisteredPending(input) {
414
+ const role = input.agent_type;
415
+ const target = normalizeTarget(input.task_name || input.agent_name || input.agent_id);
416
+ if (!riskAgentRoles.has(role) || !target) return false;
417
+ const candidates = registrationCandidates(input, role, target);
418
+ if (candidates.length !== 1) {
419
+ diagnoseSubagentStop(input, "rejected", candidates.length ? "ambiguous-parent-registration" : "missing-parent-registration");
420
+ return false;
421
+ }
422
+ const { registered } = candidates[0];
423
+ if (!registrationValid(input, registered)) return true;
424
+ let rejection;
425
+ const assessment = applicableAssessment(role, input.last_assistant_message, registered, (reason) => { rejection = reason; });
426
+ if (!assessment) {
427
+ diagnoseSubagentStop(input, "rejected", rejection || "invalid-assessment");
428
+ return true;
429
+ }
430
+ const path = transportReceiptPath(registered.parentSession, role, target);
431
+ if (existsSync(path) || existsSync(`${path}.done`)) {
432
+ diagnoseSubagentStop(input, "duplicate", "fresh-parent-bound-receipt-exists");
433
+ return true;
318
434
  }
319
- const git = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
320
- if (git.status !== 0 || realpathSync(git.stdout.trim()) !== root) return;
321
-
322
- const id = checkoutId(root);
323
- const hash = stateHash(root);
324
- if (!hash) return;
325
- const prefix = `${id}-${hash}-`;
326
- const pendingPaths = existsSync(pendingDir())
327
- ? readdirSync(pendingDir())
328
- .filter((name) => name.startsWith(prefix) && !name.endsWith(".claim") && !name.endsWith(".done"))
329
- .map((name) => join(pendingDir(), name))
330
- .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
331
- : [];
332
- const path = pendingPaths[0];
333
- if (!path) return;
334
-
335
- const claim = `${path}.claim`;
336
435
  try {
337
- writeFileSync(claim, "", { flag: "wx", mode: 0o600 });
436
+ writeFileSync(path, JSON.stringify({ ...registered, ...assessment, completedAt: Date.now() }), { flag: "wx", mode: 0o600 });
437
+ diagnoseSubagentStop(input, "receipt-written", "parent-bound-receipt");
338
438
  } catch (error) {
339
- if (error?.code === "EEXIST") return;
340
- throw error;
439
+ diagnoseSubagentStop(input, error?.code === "EEXIST" ? "duplicate" : "rejected", error?.code === "EEXIST" ? "receipt-race" : "receipt-write-failed");
341
440
  }
441
+ return true;
442
+ }
342
443
 
343
- try {
344
- const pending = JSON.parse(readFileSync(path, "utf8"));
345
- const ttl = Number.parseInt(process.env.RISK_TTL || "3600", 10) * 1000;
346
- if (pending.role !== "wr-risk-scorer:pipeline" || pending.checkoutId !== id ||
347
- pending.stateHash !== hash || typeof pending.output !== "string" ||
348
- typeof pending.completionId !== "string" || !path.endsWith(`-${pending.completionId}`) ||
349
- !Number.isFinite(pending.createdAt) || Date.now() - pending.createdAt < 0 ||
350
- Date.now() - pending.createdAt >= ttl) return;
351
-
352
- const synthetic = {
353
- ...input,
354
- cwd: root,
355
- tool_name: "Agent",
356
- tool_input: { subagent_type: pending.role, prompt: "" },
357
- tool_response: { content: [{ type: "text", text: pending.output }] },
358
- };
359
- const result = spawnSync(join(hookDir, "risk-score-mark.sh"), {
360
- cwd: root,
361
- env: process.env,
362
- input: JSON.stringify(synthetic),
363
- encoding: "utf8",
364
- });
365
- if (result.status !== 0) {
366
- process.exitCode = 1;
367
- return;
368
- }
369
- const assessedAt = new Date(pending.createdAt);
370
- const markers = ["commit", "push", "release", "commit-born", "push-born", "release-born"];
371
- if (/^RISK_BYPASS:\s*reducing\s*$/m.test(pending.output)) {
372
- markers.push("reducing-commit", "reducing-push", "reducing-release");
373
- } else if (/^RISK_BYPASS:\s*incident\s*$/m.test(pending.output)) {
374
- markers.push("incident-release");
375
- }
376
- for (const marker of markers) {
377
- const markerPath = join(riskDir(input.session_id), marker);
378
- if (existsSync(markerPath)) utimesSync(markerPath, assessedAt, assessedAt);
444
+ function markerPaths(role, session, pending) {
445
+ const dir = riskDir(session);
446
+ if (role === "wr-risk-scorer:pipeline") return ["commit", "push", "release", "commit-born", "push-born", "release-born", "state-hash", "checkout-id"].map((name) => join(dir, name));
447
+ if (role === "wr-risk-scorer:plan") return [join(dir, "plan-reviewed"), join(dir, "state-hash")];
448
+ if (role === "wr-risk-scorer:wip") return [join(dir, "wip-reviewed")];
449
+ if (role === "wr-risk-scorer:policy") return [join(dir, "policy-reviewed")];
450
+ if (role === "wr-risk-scorer:external-comms") return [join(dir, `external-comms-risk-reviewed-${pending.key}`)];
451
+ return [];
452
+ }
453
+
454
+ function consumeRegisteredPending(input) {
455
+ reapRegisteredTransport();
456
+ if (!existsSync(transportDir())) return;
457
+ const current = directoryBinding(process.cwd());
458
+ for (const name of readdirSync(transportDir()).filter((entry) => /^risk-receipt-[a-f0-9]{64}\.json$/.test(entry))) {
459
+ const path = join(transportDir(), name);
460
+ let pending;
461
+ try { pending = JSON.parse(readFileSync(path, "utf8")); } catch { continue; }
462
+ if (pending.parentSession !== input.session_id || !riskAgentRoles.has(pending.role)) continue;
463
+ if (!registrationValid(input, pending)) continue;
464
+ if (pending.role === "wr-risk-scorer:pipeline") {
465
+ if (!current || checkoutId(current.root) !== pending.checkoutId || stateHash(current.root) !== pending.stateHash) continue;
466
+ if (current.root !== pending.assessmentRoot || !policyMatches(current.root, pending.assessmentPolicy)) continue;
467
+ } else {
468
+ if (!current || current.root !== pending.root || current.physical !== pending.physical) continue;
469
+ if (pending.role === "wr-risk-scorer:wip" &&
470
+ (checkoutId(current.root) !== pending.wipCheckoutId || stateHash(current.root) !== pending.wipStateHash)) continue;
379
471
  }
380
- renameSync(path, `${path}.done`);
381
- for (const stale of pendingPaths.slice(1)) rmSync(stale, { force: true });
382
- } finally {
383
- rmSync(claim, { force: true });
472
+ const claim = `${path}.claim`;
473
+ try { writeFileSync(claim, "", { flag: "wx", mode: 0o600 }); }
474
+ catch (error) { if (error?.code === "EEXIST") continue; throw error; }
475
+ try {
476
+ const markerRoot = pending.role === "wr-risk-scorer:pipeline" ? pending.assessmentRoot : pending.root;
477
+ const synthetic = { ...input, session_id: pending.parentSession, cwd: markerRoot, tool_name: "Agent",
478
+ tool_input: { subagent_type: pending.role, prompt: "" }, tool_response: { content: [{ type: "text", text: pending.output }] } };
479
+ const result = spawnSync(join(hookDir, "risk-score-mark.sh"), { cwd: markerRoot, env: process.env, input: JSON.stringify(synthetic), encoding: "utf8" });
480
+ if (result.status !== 0) {
481
+ diagnoseSubagentStop(input, "rejected", "marker-writer-failed");
482
+ continue;
483
+ }
484
+ const assessedAt = new Date(pending.completedAt);
485
+ for (const marker of markerPaths(pending.role, pending.parentSession, pending)) if (existsSync(marker)) utimesSync(marker, assessedAt, assessedAt);
486
+ renameSync(path, `${path}.done`);
487
+ rmSync(transportRegistrationPath(pending.parentSession, pending.role, pending.target), { force: true });
488
+ rmSync(statePath({ session_id: pending.parentSession }, pending.target), { force: true });
489
+ } finally { rmSync(claim, { force: true }); }
490
+ }
491
+ }
492
+
493
+ function markSubagentStop(input) {
494
+ const state = typeof input.agent_id === "string" ? statePath(input, input.agent_id) : "";
495
+ if (state && existsSync(state)) {
496
+ markTarget(input, input.agent_id, input.last_assistant_message);
497
+ return;
384
498
  }
499
+ persistRegisteredPending(input);
500
+ }
501
+
502
+ function consumePending(input) {
503
+ if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) return;
504
+ consumeRegisteredPending(input);
385
505
  }
386
506
 
387
507
  let body = "";
@@ -398,27 +518,18 @@ try {
398
518
  process.exit(0);
399
519
  }
400
520
 
401
- if (process.argv.includes("--consume-pending")) {
402
- consumePending(input);
403
- process.exit(process.exitCode || 0);
404
- }
405
-
406
- if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) {
407
- if (process.argv.includes("--subagent-stop")) {
408
- diagnoseSubagentStop(input, "rejected", "invalid-session-id");
521
+ try {
522
+ if (process.argv.includes("--consume-pending")) {
523
+ consumePending(input);
524
+ } else if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) {
525
+ if (process.argv.includes("--subagent-stop")) diagnoseSubagentStop(input, "rejected", "invalid-session-id");
526
+ } else {
527
+ if (SPAWN_TOOLS.has(input.tool_name)) rememberSpawn(input);
528
+ if (CLOSE_TOOLS.has(input.tool_name)) markClose(input);
529
+ if (WAIT_TOOLS.has(input.tool_name)) markWait(input);
530
+ if (input.hook_event_name === "SubagentStop") markSubagentStop(input);
409
531
  }
410
- process.exit(0);
411
- }
412
-
413
- if (SPAWN_TOOLS.has(input.tool_name)) {
414
- rememberSpawn(input);
415
- }
416
- if (CLOSE_TOOLS.has(input.tool_name)) {
417
- markClose(input);
418
- }
419
- if (WAIT_TOOLS.has(input.tool_name)) {
420
- markWait(input);
421
- }
422
- if (input.hook_event_name === "SubagentStop") {
423
- markSubagentStop(input);
532
+ } catch {
533
+ diagnoseSubagentStop(input, "rejected", "transport-error");
534
+ process.exitCode = 0;
424
535
  }
@@ -9,6 +9,6 @@ _parse_input
9
9
 
10
10
  TOOL_NAME="$(_get_tool_name)"
11
11
  EVENT_NAME="$(printf '%s' "$_HOOK_INPUT" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("hook_event_name", ""))' 2>/dev/null || true)"
12
- [ "$TOOL_NAME" = "Bash" ] || [ "$EVENT_NAME" = "UserPromptSubmit" ] || exit 0
12
+ [ "$EVENT_NAME" = "PreToolUse" ] || [ "$EVENT_NAME" = "UserPromptSubmit" ] || [ "$TOOL_NAME" = "Bash" ] || exit 0
13
13
  _enter_hook_cwd || exit 0
14
14
  printf '%s' "$_HOOK_INPUT" | node "$SCRIPT_DIR/codex-agent-completion.mjs" --consume-pending
@@ -55,6 +55,8 @@ if messages:
55
55
  run_hook staleness-check.sh
56
56
  ;;
57
57
  pre-tool)
58
+ # Import an exact-parent background receipt before every guarded action.
59
+ "$SCRIPT_DIR/risk-pending-receipt.sh" <<<"$INPUT" || true
58
60
  case "$(tool_name)" in
59
61
  Edit|Write)
60
62
  run_hook secret-leak-gate.sh
@@ -63,10 +65,6 @@ if messages:
63
65
  run_hook risk-policy-enforce-edit.sh
64
66
  ;;
65
67
  Bash)
66
- # Current Codex emits SubagentStop in the child conversation but does
67
- # not expose native collaboration calls to the parent's PostToolUse.
68
- # Import the exact checkout/hash-bound receipt before enforcing gates.
69
- "$SCRIPT_DIR/risk-pending-receipt.sh" <<<"$INPUT" || true
70
68
  run_hook git-push-gate.sh
71
69
  run_hook risk-score-commit-gate.sh
72
70
  run_hook external-comms-gate.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.19.8-preview.1264",
3
+ "version": "0.19.8",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "scripts": {
6
6
  "prepack": "node scripts/sync-codex-skills.mjs --pack",