@windyroad/risk-scorer 0.18.10 → 0.18.11

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.
@@ -310,5 +310,5 @@
310
310
  }
311
311
  },
312
312
  "name": "wr-risk-scorer",
313
- "version": "0.18.10"
313
+ "version": "0.18.11"
314
314
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-risk-scorer",
3
- "version": "0.18.10",
3
+ "version": "0.18.11",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
4
5
  import { dirname, isAbsolute, join } from "node:path";
5
6
  import { spawnSync } from "node:child_process";
6
7
  import { fileURLToPath } from "node:url";
@@ -28,6 +29,10 @@ function riskDir(sessionId) {
28
29
  return join(process.env.TMPDIR || "/tmp", `claude-risk-${sessionId}`);
29
30
  }
30
31
 
32
+ function pendingDir() {
33
+ return join(process.env.TMPDIR || "/tmp", "claude-risk-pending");
34
+ }
35
+
31
36
  function statePath(input, target, suffix = "") {
32
37
  return join(riskDir(input.session_id), `codex-agent-${Buffer.from(target).toString("base64url")}${suffix}`);
33
38
  }
@@ -98,6 +103,63 @@ function pipelineAssessment(output) {
98
103
  return { root, output: sanitized };
99
104
  }
100
105
 
106
+ function checkoutId(root) {
107
+ const stat = statSync(root);
108
+ return createHash("sha256").update(`${stat.dev}:${stat.ino}`).digest("hex");
109
+ }
110
+
111
+ function stateHash(root) {
112
+ const state = spawnSync(join(hookDir, "lib/pipeline-state.sh"), ["--hash-inputs"], {
113
+ cwd: root,
114
+ encoding: "utf8",
115
+ });
116
+ if (state.status !== 0) return null;
117
+ return createHash("md5").update(state.stdout).digest("hex");
118
+ }
119
+
120
+ function completionId(input, output) {
121
+ if (typeof input.session_id !== "string" || typeof input.agent_id !== "string") return null;
122
+ return createHash("sha256").update(`${input.session_id}\0${input.agent_id}\0${output}`).digest("hex");
123
+ }
124
+
125
+ function pendingPath(id, hash, completion, suffix = "") {
126
+ return join(pendingDir(), `${id}-${hash}-${completion}${suffix}`);
127
+ }
128
+
129
+ function freshReceipt(path) {
130
+ if (!existsSync(path)) return false;
131
+ const ttl = Number.parseInt(process.env.RISK_TTL || "3600", 10) * 1000;
132
+ return Date.now() - statSync(path).mtimeMs < ttl;
133
+ }
134
+
135
+ function persistPendingPipeline(input) {
136
+ if (input.agent_type !== "wr-risk-scorer:pipeline") return;
137
+ const assessment = pipelineAssessment(input.last_assistant_message);
138
+ if (!assessment) return;
139
+ const id = checkoutId(assessment.root);
140
+ const hash = stateHash(assessment.root);
141
+ const completion = completionId(input, assessment.output);
142
+ if (!hash || !completion || !/^RISK_SCORES: commit=\d+ push=\d+ release=\d+$/m.test(assessment.output)) return;
143
+ mkdirSync(pendingDir(), { recursive: true });
144
+ const path = pendingPath(id, hash, completion);
145
+ for (const candidate of [path, `${path}.done`]) {
146
+ if (freshReceipt(candidate)) return;
147
+ rmSync(candidate, { force: true });
148
+ }
149
+ try {
150
+ writeFileSync(path, JSON.stringify({
151
+ role: input.agent_type,
152
+ output: assessment.output,
153
+ checkoutId: id,
154
+ stateHash: hash,
155
+ completionId: completion,
156
+ createdAt: Date.now(),
157
+ }), { flag: "wx", mode: 0o600 });
158
+ } catch (error) {
159
+ if (error?.code !== "EEXIST") throw error;
160
+ }
161
+ }
162
+
101
163
  function markTarget(input, target, output) {
102
164
  if (typeof target !== "string" || typeof output !== "string" || !output) return;
103
165
 
@@ -152,7 +214,88 @@ function markWait(input) {
152
214
  }
153
215
 
154
216
  function markSubagentStop(input) {
155
- markTarget(input, input.agent_id, input.last_assistant_message);
217
+ const state = typeof input.agent_id === "string" ? statePath(input, input.agent_id) : "";
218
+ if (state && existsSync(state)) {
219
+ markTarget(input, input.agent_id, input.last_assistant_message);
220
+ return;
221
+ }
222
+ persistPendingPipeline(input);
223
+ }
224
+
225
+ function consumePending(input) {
226
+ if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) return;
227
+ let root;
228
+ try {
229
+ root = realpathSync(process.cwd());
230
+ } catch {
231
+ return;
232
+ }
233
+ const git = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
234
+ if (git.status !== 0 || realpathSync(git.stdout.trim()) !== root) return;
235
+
236
+ const id = checkoutId(root);
237
+ const hash = stateHash(root);
238
+ if (!hash) return;
239
+ const prefix = `${id}-${hash}-`;
240
+ const pendingPaths = existsSync(pendingDir())
241
+ ? readdirSync(pendingDir())
242
+ .filter((name) => name.startsWith(prefix) && !name.endsWith(".claim") && !name.endsWith(".done"))
243
+ .map((name) => join(pendingDir(), name))
244
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
245
+ : [];
246
+ const path = pendingPaths[0];
247
+ if (!path) return;
248
+
249
+ const claim = `${path}.claim`;
250
+ try {
251
+ writeFileSync(claim, "", { flag: "wx", mode: 0o600 });
252
+ } catch (error) {
253
+ if (error?.code === "EEXIST") return;
254
+ throw error;
255
+ }
256
+
257
+ try {
258
+ const pending = JSON.parse(readFileSync(path, "utf8"));
259
+ const ttl = Number.parseInt(process.env.RISK_TTL || "3600", 10) * 1000;
260
+ if (pending.role !== "wr-risk-scorer:pipeline" || pending.checkoutId !== id ||
261
+ pending.stateHash !== hash || typeof pending.output !== "string" ||
262
+ typeof pending.completionId !== "string" || !path.endsWith(`-${pending.completionId}`) ||
263
+ !Number.isFinite(pending.createdAt) || Date.now() - pending.createdAt < 0 ||
264
+ Date.now() - pending.createdAt >= ttl) return;
265
+
266
+ const synthetic = {
267
+ ...input,
268
+ cwd: root,
269
+ tool_name: "Agent",
270
+ tool_input: { subagent_type: pending.role, prompt: "" },
271
+ tool_response: { content: [{ type: "text", text: pending.output }] },
272
+ };
273
+ const result = spawnSync(join(hookDir, "risk-score-mark.sh"), {
274
+ cwd: root,
275
+ env: process.env,
276
+ input: JSON.stringify(synthetic),
277
+ encoding: "utf8",
278
+ });
279
+ if (result.status !== 0) {
280
+ process.exitCode = 1;
281
+ return;
282
+ }
283
+ const assessedAt = new Date(pending.createdAt);
284
+ const markers = ["commit", "push", "release", "commit-born", "push-born", "release-born"];
285
+ if (/^RISK_BYPASS:\s*reducing\s*$/m.test(pending.output)) {
286
+ markers.push("reducing-commit", "reducing-push", "reducing-release");
287
+ } else if (/^RISK_BYPASS:\s*incident\s*$/m.test(pending.output)) {
288
+ markers.push("incident-release");
289
+ }
290
+ for (const marker of markers) {
291
+ const markerPath = join(riskDir(input.session_id), marker);
292
+ if (existsSync(markerPath)) utimesSync(markerPath, assessedAt, assessedAt);
293
+ }
294
+ renameSync(path, `${path}.done`);
295
+ for (const stale of pendingPaths.slice(1)) rmSync(stale, { force: true });
296
+ } finally {
297
+ rmSync(claim, { force: true });
298
+ }
156
299
  }
157
300
 
158
301
  let body = "";
@@ -166,6 +309,11 @@ try {
166
309
  process.exit(0);
167
310
  }
168
311
 
312
+ if (process.argv.includes("--consume-pending")) {
313
+ consumePending(input);
314
+ process.exit(process.exitCode || 0);
315
+ }
316
+
169
317
  if (!/^[A-Za-z0-9-]+$/.test(input.session_id || "")) process.exit(0);
170
318
 
171
319
  if (["collaborationspawn_agent", "spawn_agent", "multi_agent_v1__spawn_agent"].includes(input.tool_name)) {
@@ -0,0 +1,14 @@
1
+ #!/bin/bash
2
+ # Consume a checkout-bound Codex SubagentStop receipt in the parent session.
3
+
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ source "$SCRIPT_DIR/lib/gate-helpers.sh"
8
+ _parse_input
9
+
10
+ TOOL_NAME="$(_get_tool_name)"
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
13
+ _enter_hook_cwd || exit 0
14
+ printf '%s' "$_HOOK_INPUT" | node "$SCRIPT_DIR/codex-agent-completion.mjs" --consume-pending
@@ -47,6 +47,9 @@ if messages:
47
47
  fi
48
48
  ;;
49
49
  user-prompt)
50
+ # Codex code-mode tools do not expose a parent PreToolUse event. Import a
51
+ # completed child receipt on the next already-enabled parent prompt.
52
+ "$SCRIPT_DIR/risk-pending-receipt.sh" <<<"$INPUT" || true
50
53
  run_hook risk-score.sh
51
54
  run_hook staleness-check.sh
52
55
  ;;
@@ -59,6 +62,10 @@ if messages:
59
62
  run_hook risk-policy-enforce-edit.sh
60
63
  ;;
61
64
  Bash)
65
+ # Current Codex emits SubagentStop in the child conversation but does
66
+ # not expose native collaboration calls to the parent's PostToolUse.
67
+ # Import the exact checkout/hash-bound receipt before enforcing gates.
68
+ "$SCRIPT_DIR/risk-pending-receipt.sh" <<<"$INPUT" || true
62
69
  run_hook git-push-gate.sh
63
70
  run_hook risk-score-commit-gate.sh
64
71
  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.18.10",
3
+ "version": "0.18.11",
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",