@windyroad/risk-scorer 0.13.5 → 0.14.0-preview.762

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.13.5"
313
+ "version": "0.14.0"
314
314
  }
package/README.md CHANGED
@@ -113,8 +113,10 @@ Behaviour:
113
113
  3. If `RISK-POLICY.md` is absent, the gate runs in advisory-only mode and
114
114
  permits the call (graceful adoption).
115
115
 
116
- Override: `BYPASS_RISK_GATE=1` short-circuits the gate (consistent with
117
- `git-push-gate.sh`). Reserved for cases the user has confirmed safe.
116
+ There is no env override of the gate (the `BYPASS_RISK_GATE` knob was removed —
117
+ P377/RFC-029). Clear the gate by reducing risk to within appetite, or — for a
118
+ false-positive leak flag — by letting the reviewer re-judge. A genuine gate
119
+ misfire is recovered per ADR-048 (documented recovery), not an env bypass.
118
120
 
119
121
  The canonical hook lives at `packages/shared/hooks/external-comms-gate.sh` and
120
122
  is synced into each consumer plugin via `scripts/sync-external-comms-gate.sh`
@@ -26,7 +26,7 @@ If the action is risk-reducing or risk-neutral, add on a separate line:
26
26
  RISK_BYPASS: reducing
27
27
  ```
28
28
 
29
- For live incidents, use:
29
+ For a live-outage restore-service release that must proceed despite red/unreadable CI (the narrow CI-gate short-circuit case — see Incident-context scoring below; the change must still be net-risk-reducing per ADR-042 Rule 1b, no appetite carve-out):
30
30
  ```
31
31
  RISK_BYPASS: incident
32
32
  ```
@@ -164,11 +164,11 @@ Acceptable `RISK_BYPASS_REASON:` values cite the ticket ID closed, the prior
164
164
  risk report remediated, or the removed risk — matching one of the three
165
165
  criteria above.
166
166
 
167
- For live incidents (outage, security, information disclosure), include `RISK_BYPASS: incident`.
167
+ **Incident-context scoring (ADR-042 Rule 1b; P377/RFC-029 — no incident carve-out).** An active incident is a risk being realised (Likelihood already 5). Score the incident-response change against that live realised-risk baseline: weigh P(it increases impact) vs P(it reduces impact & likelihood / restores service) + P(it introduces a new incident). When net risk-reducing, emit `RISK_BYPASS: reducing` (the incident path collapses into the risk-reducing path — no separate appetite carve-out). `RISK_BYPASS: incident` is retained ONLY for the narrow operational case where a restore-service release must proceed despite red/unreadable CI during a live outage (it short-circuits the CI-status gate, which `reducing` does not); emit it only for a genuine live outage requiring immediate deployment AND only when the change is net-risk-reducing per the baseline scoring above.
168
168
 
169
169
  ## Below-Appetite Output Rule (ADR-013 Rule 5)
170
170
 
171
- When ALL cumulative scores are within appetite (≤ 4 per RISK-POLICY.md), your output MUST contain ONLY:
171
+ When ALL cumulative scores are within appetite (≤ the appetite threshold in `RISK-POLICY.md` § Risk Appetite — read it; `Threshold: N`, default 4/Low when absent; do NOT hardcode 4), your output MUST contain ONLY:
172
172
  1. The Pipeline Risk Report structure (layers, risk items, summary table)
173
173
  2. `RISK_SCORES: commit=N push=N release=N`
174
174
  3. `RISK_BYPASS: reducing` (if applicable)
@@ -177,7 +177,7 @@ Do NOT emit: "Suggested Actions", "Your call:", advisory warnings, back-pressure
177
177
 
178
178
  ## Above-Appetite Remediations
179
179
 
180
- When ANY cumulative score exceeds appetite (> 4), the verbal verdict is **STOP**.
180
+ When ANY cumulative score exceeds appetite (> the RISK-POLICY.md appetite threshold, default 4 — read it, do NOT hardcode), the verbal verdict is **STOP**.
181
181
  The scorer is not the primary decision-maker — the hook gate will block the
182
182
  action — but the scorer's verdict must match the structured score so the agent
183
183
  does not waste tool calls acting on an ambiguous nudge.
@@ -214,7 +214,7 @@ When a pipeline run identifies a **register-worthy risk shape**, emit a structur
214
214
 
215
215
  ### Trigger conditions (emit a hint when ANY fire)
216
216
 
217
- 1. **Above-appetite residual** — any cumulative residual score exceeds appetite (> 4 per `RISK-POLICY.md`). A risk that breaches appetite on this change is a standing-risk candidate, not just a one-off remediation target.
217
+ 1. **Above-appetite residual** — any cumulative residual score exceeds the appetite threshold declared in `RISK-POLICY.md` § Risk Appetite (default 4; read it, do NOT hardcode). A risk that breaches appetite on this change is a standing-risk candidate, not just a one-off remediation target.
218
218
  2. **Confidentiality disclosure** — the Confidential Information Disclosure check (below) flagged business metrics (revenue, user counts, pricing, client names, traffic volumes) in the diff. Confidentiality leaks are standing-risk-shaped even after the immediate remediation.
219
219
  3. **User-stated precondition** — the User-Stated Preconditions Check (below) flagged an unmet paired capability as a standalone Risk item. Unmet preconditions are standing-risk-shaped because the dependency gap persists until the paired capability ships.
220
220
 
package/agents/wip.md CHANGED
@@ -47,7 +47,7 @@ Always provide the cumulative risk picture:
47
47
 
48
48
  ### Below-Appetite Rule (ADR-013 Rule 5)
49
49
 
50
- If cumulative risk is **within appetite** (< 5): provide the assessment table and verdict only. Do NOT emit advisory prose, recommendations, or suggestions. The verdict is `RISK_VERDICT: CONTINUE`.
50
+ If cumulative risk is **within appetite** ( the appetite threshold in `RISK-POLICY.md` § Risk Appetite — read it; `Threshold: N`, default 4/Low when absent; do NOT hardcode): provide the assessment table and verdict only. Do NOT emit advisory prose, recommendations, or suggestions. The verdict is `RISK_VERDICT: CONTINUE`.
51
51
 
52
52
  ### Above-Appetite Remediations
53
53
 
@@ -89,7 +89,7 @@ After assessing the risk profile, check whether uncommitted changes represent **
89
89
  - `packages/*/skills/**/*.bats`
90
90
  - `docs/decisions/*.md`
91
91
 
92
- AND cumulative risk is **within appetite** (≤ 4), AND at least one completion signal is present:
92
+ AND cumulative risk is **within appetite** (≤ the RISK-POLICY.md appetite threshold, default 4; read it, do NOT hardcode), AND at least one completion signal is present:
93
93
  - A problem file diff contains "Fix Released" or a status transition keyword. Under the flat layout this surfaces as a `.known-error.md` / `.closed.md` filename suffix; under the per-state subdir layout it surfaces as a path move into `docs/problems/known-error/` or `docs/problems/closed/`.
94
94
  - A SKILL.md was modified alongside a problem file update
95
95
 
@@ -24,7 +24,7 @@
24
24
  # PreToolUse, nothing to read.)
25
25
  #
26
26
  # Gate behaviour:
27
- # 1. BYPASS_RISK_GATE=1 short-circuits the gate (consistent with git-push-gate.sh).
27
+ # 1. (removed P377/RFC-029) — BYPASS_RISK_GATE env override no longer exists; the gate is cleared only by delegating to the reviewer.
28
28
  # 2. POLICY_FILE absent → advisory-only mode (permits with systemMessage).
29
29
  # 3. Hybrid leak-pattern pre-filter (lib/leak-detect.sh) hard-fails on
30
30
  # credentials, prod-URL prefixes, business-context-paired financial figures,
@@ -97,10 +97,11 @@ EXTERNAL_COMMS_POLICY_FILE="${EXTERNAL_COMMS_POLICY_FILE:-RISK-POLICY.md}"
97
97
  EXTERNAL_COMMS_LEAK_PREFILTER="${EXTERNAL_COMMS_LEAK_PREFILTER:-yes}"
98
98
  EXTERNAL_COMMS_SKIP_SURFACES="${EXTERNAL_COMMS_SKIP_SURFACES:-}"
99
99
 
100
- # ---------- Bypass ----------
101
- if [ "${BYPASS_RISK_GATE:-0}" = "1" ]; then
102
- exit 0
103
- fi
100
+ # P377/RFC-029: the BYPASS_RISK_GATE=1 env override is REMOVED (never
101
+ # authorised). There is no env escape from the external-comms gate — clear it
102
+ # by delegating to the wr-risk-scorer:external-comms reviewer (the gate marks
103
+ # the draft reviewed on a PASS verdict). A genuine gate misfire is recovered
104
+ # per ADR-048 (documented recovery), not an env bypass.
104
105
 
105
106
  INPUT=$(cat)
106
107
 
@@ -316,7 +317,7 @@ fi
316
317
  # EXTERNAL_COMMS_LEAK_PREFILTER=yes (risk) or =no (voice-tone).
317
318
  if [ "$EXTERNAL_COMMS_LEAK_PREFILTER" = "yes" ]; then
318
319
  if ! leak_detect_scan "$DRAFT"; then
319
- REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s on %s. Remove the leak before retrying. Override only if intentional (pre-session env): BYPASS_RISK_GATE=1.' \
320
+ REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s on %s. Remove the leak before retrying. There is no env override (P377/RFC-029 BYPASS_RISK_GATE removed).' \
320
321
  "$EXTERNAL_COMMS_EVALUATOR_ID" "$LEAK_DETECT_REASON" "$SURFACE")
321
322
  deny_with_reason "$REASON"
322
323
  exit 0
@@ -386,7 +387,7 @@ fi
386
387
  # PostToolUse mark hook can derive the canonical marker key locally
387
388
  # (sha256(DRAFT + '\n' + SURFACE)). Single fire per gate cycle.
388
389
  VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
389
- REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers (for the changeset-author surface the body is the changeset summary WITHOUT the leading `---` frontmatter block — the gate strips frontmatter before hashing the marker key). The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. Override only when intentional (pre-session env): BYPASS_RISK_GATE=1.' \
390
+ REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers (for the changeset-author surface the body is the changeset summary WITHOUT the leading `---` frontmatter block — the gate strips frontmatter before hashing the marker key). The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. There is no env override (P377/RFC-029 BYPASS_RISK_GATE removed).' \
390
391
  "$EXTERNAL_COMMS_EVALUATOR_ID" "$SURFACE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$SURFACE" "$VERDICT_PREFIX" "$EXTERNAL_COMMS_ASSESS_SKILL")
391
392
  deny_with_reason "$REASON"
392
393
  exit 0
@@ -146,7 +146,7 @@ print(('yes' if score > N else 'no') + ' ' + str(N))
146
146
  if [ "$DENIED" = "yes" ]; then
147
147
  RISK_GATE_CATEGORY="threshold"
148
148
  RISK_GATE_SCORE="$SCORE"
149
- RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 exceeds the project appetite of ${APPETITE}/25 (RISK-POLICY.md). To proceed: (1) split the ${ACTION}, (2) add risk-reducing measures, or (3) for a LIVE INCIDENT, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') with incident context for an incident bypass."
149
+ RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 exceeds the project appetite of ${APPETITE}/25 (RISK-POLICY.md). Reduce risk or halt — there is no proceed-anyway path (P377/RFC-029). To proceed within appetite: (1) split the ${ACTION}, (2) add risk-reducing measures and re-score, or (3) if this is incident response, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') with incident context it scores the change against the live realised-risk baseline (ADR-042 Rule 1b) and clears it via the risk-reducing path if it is net-risk-reducing."
150
150
  return 1
151
151
  fi
152
152
 
@@ -164,8 +164,7 @@ print(('yes' if score > N else 'no') + ' ' + str(N))
164
164
  # status,conclusion,databaseId,url` for the working branch's most recent
165
165
  # CI run.
166
166
  #
167
- # Decision table:
168
- # - bypass marker present (${RDIR}/ci-bypass-${ACTION}) → allow, consume
167
+ # Decision table (no override — ci-bypass removed P377/RFC-029):
169
168
  # - gh failure (auth / timeout / API error) → DENY (fail-CLOSED, per
170
169
  # P208 safe-high-fix-risk classifier — a buggy harden must NOT
171
170
  # degrade to allow)
@@ -182,19 +181,16 @@ check_ci_status() {
182
181
  local ACTION="$2"
183
182
  local RDIR
184
183
  RDIR=$(_risk_dir "$SESSION_ID")
185
- local BYPASS_MARKER="${RDIR}/ci-bypass-${ACTION}"
186
184
 
187
185
  CI_GATE_REASON=""
188
186
  CI_GATE_CATEGORY=""
189
187
 
190
- # One-shot bypass marker consumed on use, same family as
191
- # reducing-push / incident-release. Documented override for the
192
- # legitimate "first push triggers CI" edge case and infra incidents.
193
- if [ -f "$BYPASS_MARKER" ]; then
194
- rm -f "$BYPASS_MARKER"
195
- CI_GATE_CATEGORY="bypass"
196
- return 0
197
- fi
188
+ # P377/RFC-029: the `ci-bypass-${ACTION}` one-shot override is REMOVED
189
+ # (never authorised). The CI-status gate is fail-closed per P208 with NO
190
+ # escape red/unreadable CI hard-fails; fix the underlying CI / gh failure.
191
+ # The "first push triggers CI" case is handled by the empty-history natural
192
+ # allow below (no marker needed). Live-outage releases use the separate
193
+ # `incident-release` marker (git-push-gate.sh), justified by ADR-042 Rule 1b.
198
194
 
199
195
  # Resolve current branch. If we're not in a git repo or HEAD is
200
196
  # detached, skip the CI check (the surrounding push/release gate
@@ -220,7 +216,7 @@ check_ci_status() {
220
216
 
221
217
  if [ -n "${GH_EXIT:-}" ] && [ "$GH_EXIT" != "0" ]; then
222
218
  CI_GATE_CATEGORY="gh-error"
223
- CI_GATE_REASON="CI status check failed (gh exit ${GH_EXIT}: auth / timeout / API error). Fail-closed per P208 safe-high-fix-risk. Fix the underlying gh failure, or to override for a legitimate first-push-triggers-CI run, create the bypass marker: touch ${BYPASS_MARKER}"
219
+ CI_GATE_REASON="CI status check failed (gh exit ${GH_EXIT}: auth / timeout / API error). Fail-closed per P208 safe-high-fix-risk. Fix the underlying gh / CI failure before pushing/releasing there is no override (P377/RFC-029; the ci-bypass marker was removed)."
224
220
  return 1
225
221
  fi
226
222
 
@@ -256,14 +252,14 @@ except Exception:
256
252
 
257
253
  if [ "$STATUS" = "PARSE_ERROR" ]; then
258
254
  CI_GATE_CATEGORY="gh-error"
259
- CI_GATE_REASON="CI status check returned unparseable response. Fail-closed per P208 safe-high-fix-risk. To override for a legitimate first-push case, create the bypass marker: touch ${BYPASS_MARKER}"
255
+ CI_GATE_REASON="CI status check returned unparseable response. Fail-closed per P208 safe-high-fix-risk. Fix the gh / CI failure before pushing/releasing there is no override (P377/RFC-029)."
260
256
  return 1
261
257
  fi
262
258
 
263
259
  case "$STATUS" in
264
260
  queued|in_progress|pending|requested|waiting)
265
261
  CI_GATE_CATEGORY="pending"
266
- CI_GATE_REASON="Latest CI run on branch '${BRANCH}' is still in flight (status: ${STATUS}). Wait for it to settle: ${URL}. To override, create the bypass marker: touch ${BYPASS_MARKER}"
262
+ CI_GATE_REASON="Latest CI run on branch '${BRANCH}' is still in flight (status: ${STATUS}). Wait for it to settle before pushing/releasing: ${URL} (no override P377/RFC-029)."
267
263
  return 1
268
264
  ;;
269
265
  completed)
@@ -274,7 +270,7 @@ except Exception:
274
270
  ;;
275
271
  failure|cancelled|timed_out|action_required|startup_failure)
276
272
  CI_GATE_CATEGORY="red"
277
- CI_GATE_REASON="Latest CI run on branch '${BRANCH}' concluded ${CONCLUSION}: ${URL}. Fix CI before pushing/releasing. To override for a legitimate first-push or infra-incident case, create the bypass marker: touch ${BYPASS_MARKER}"
273
+ CI_GATE_REASON="Latest CI run on branch '${BRANCH}' concluded ${CONCLUSION}: ${URL}. Fix CI before pushing/releasing there is no override (P377/RFC-029; ci-bypass removed). A live-outage restore-service release uses the separate incident-release path."
278
274
  return 1
279
275
  ;;
280
276
  *)
@@ -32,9 +32,17 @@ if [ -n "$SESSION_ID" ] && [ -f "$RELEASE_SCORE_FILE" ]; then
32
32
  fi
33
33
 
34
34
  # --- Read appetite from RISK-POLICY.md ---
35
- APPETITE="5"
35
+ # Default 4 (Low) — MUST match the canonical gate parser default in
36
+ # packages/risk-scorer/hooks/lib/risk-gate.sh (P377/RFC-029: a default of 5
37
+ # here was an ADR-065 confirmation violation — the canonical default is 4).
38
+ # The authoritative parser lives in risk-gate.sh; this guidance script mirrors
39
+ # its 3 tolerated phrasings (Threshold: N | exceeds N | N / Low appetite).
40
+ APPETITE="4"
36
41
  if [ -f "RISK-POLICY.md" ]; then
37
- EXTRACTED=$(grep -oP 'Threshold:\s*\K[0-9]+' RISK-POLICY.md 2>/dev/null | head -1 || echo "")
42
+ EXTRACTED=$(grep -oP '(?:Threshold:\s*|exceeds\s+)\K[0-9]+' RISK-POLICY.md 2>/dev/null | head -1 || echo "")
43
+ if [ -z "$EXTRACTED" ]; then
44
+ EXTRACTED=$(grep -oP '[0-9]+(?=\s*/\s*Low appetite)' RISK-POLICY.md 2>/dev/null | head -1 || echo "")
45
+ fi
38
46
  if [ -n "$EXTRACTED" ]; then
39
47
  APPETITE="$EXTRACTED"
40
48
  fi
@@ -69,7 +69,8 @@ fi
69
69
  # TTL is unexpired; consumed on drift or TTL expiry so a genuine risk-
70
70
  # profile change forces a fresh wr-risk-scorer:pipeline rescore. Mirrors
71
71
  # the clean-marker persist-until-drift precedent (above) — distinct from
72
- # incident-release / ci-bypass, which remain deliberate one-time overrides.
72
+ # incident-release, which remains the one-shot live-outage override
73
+ # (ci-bypass was removed per P377/RFC-029).
73
74
  if [ -f "${RDIR}/reducing-commit" ]; then
74
75
  NOW=$(date +%s)
75
76
  MARK_TIME=$(_mtime "${RDIR}/reducing-commit")
@@ -129,25 +129,19 @@ _run_check() {
129
129
  export FAKE_GH_EXIT=1
130
130
  result=$(_run_check "push")
131
131
  [[ "$result" == DENY:* ]]
132
- # Must point at the ci-bypass marker for the documented override path.
133
- [[ "$result" == *"ci-bypass-push"* ]]
132
+ # P377/RFC-029: ci-bypass removed the deny states there is no override
133
+ # and does not instruct the user to create/touch a bypass marker.
134
+ [[ "$result" == *"no override"* ]]
135
+ [[ "$result" != *"touch "* ]]
134
136
  }
135
137
 
136
- @test "check_ci_status allows when ci-bypass marker is present and consumes it" {
138
+ @test "P377/RFC-029: a ci-bypass marker is IGNORED red CI still denies (override removed)" {
137
139
  : > "$RDIR/ci-bypass-push"
138
140
  export FAKE_GH_OUTPUT='[{"status":"completed","conclusion":"failure","databaseId":7,"url":"https://github.com/x/y/actions/runs/7"}]'
139
141
  result=$(_run_check "push")
140
- [[ "$result" == "ALLOW" ]]
141
- # Bypass markers are one-shot — same family as reducing-push / incident-release.
142
- [ ! -f "$RDIR/ci-bypass-push" ]
143
- }
144
-
145
- @test "check_ci_status bypass marker is action-scoped (push marker does not bypass release)" {
146
- : > "$RDIR/ci-bypass-push"
147
- export FAKE_GH_OUTPUT='[{"status":"completed","conclusion":"failure","databaseId":8,"url":"https://github.com/x/y/actions/runs/8"}]'
148
- result=$(_run_check "release")
142
+ # The marker no longer short-circuits; red CI is fail-closed regardless.
149
143
  [[ "$result" == DENY:* ]]
150
- # push bypass must not have been consumed by a release check
144
+ # And the gate does not consume / act on the marker.
151
145
  [ -f "$RDIR/ci-bypass-push" ]
152
146
  }
153
147
 
@@ -107,16 +107,15 @@ run_hook() {
107
107
  [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
108
108
  }
109
109
 
110
- # P173: the BYPASS_RISK_GATE override is clarified as pre-session — it only
111
- # takes effect when set in Claude Code's process env before the session
112
- # started, not via a mid-session Bash export. The in-flight escape-hatch is
113
- # delegation to the external-comms subagent (already named in the deny).
114
- @test "P173 marker-absent deny clarifies the env override is pre-session" {
110
+ # P377/RFC-029: the BYPASS_RISK_GATE env override was removed. The only
111
+ # clearance path named in the deny is delegation to the external-comms subagent.
112
+ @test "marker-absent deny names the reviewer and offers no env override (P377/RFC-029)" {
115
113
  INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
116
114
  run_hook "$INPUT"
117
115
  [ "$status" -eq 0 ]
118
116
  [[ "$output" == *"deny"* ]]
119
- [[ "$output" == *"pre-session"* ]]
117
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
118
+ [[ "$output" != *"BYPASS_RISK_GATE=1"* ]]
120
119
  }
121
120
 
122
121
  @test "hard-fail credential pattern (GitHub token) denies immediately with leak reason" {
@@ -136,11 +135,14 @@ run_hook() {
136
135
  [[ "$output" == *"AWS"* ]] || [[ "$output" == *"credential"* ]]
137
136
  }
138
137
 
139
- @test "BYPASS_RISK_GATE=1 short-circuits the deny" {
138
+ @test "BYPASS_RISK_GATE=1 does NOT bypass the gate (removed P377/RFC-029)" {
140
139
  INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure'")
141
140
  run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
142
141
  [ "$status" -eq 0 ]
143
- [ -z "$output" ]
142
+ # The env override no longer short-circuits the gate still denies the
143
+ # unreviewed external-comms draft.
144
+ [[ "$output" == *"deny"* ]]
145
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
144
146
  }
145
147
 
146
148
  @test "per-evaluator marker (external-comms-risk-reviewed-<KEY>) allows the call (ADR-028 amended 2026-05-14)" {
@@ -319,11 +321,15 @@ run_hook() {
319
321
  [ -z "$output" ]
320
322
  }
321
323
 
322
- @test "P082: BYPASS_RISK_GATE=1 short-circuits the git commit gate" {
323
- INPUT=$(build_bash_input "git commit -m \"fix(foo): handle null input\"")
324
+ @test "P082→P377/RFC-029: BYPASS_RISK_GATE=1 is inert — does not short-circuit the gate" {
325
+ # gh-issue is an unconditionally-gated external surface (no visibility
326
+ # precondition). Setting BYPASS_RISK_GATE=1 has no effect: the unreviewed
327
+ # draft still denies, identically to the no-env-var case.
328
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
324
329
  run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
325
330
  [ "$status" -eq 0 ]
326
- [ -z "$output" ]
331
+ [[ "$output" == *"deny"* ]]
332
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
327
333
  }
328
334
 
329
335
  @test "P082: per-evaluator marker keyed on (body, git-commit-message) permits the call" {
@@ -7,10 +7,12 @@ import { execSync } from "node:child_process";
7
7
 
8
8
  const MARKETPLACE_REPO = "windyroad/agent-plugins";
9
9
  const MARKETPLACE_NAME = "windyroad";
10
+ const CODEX_MARKETPLACE_PATH = ".";
11
+ const CODEX_MARKETPLACE_NAME = "windyroad-local";
10
12
 
11
13
  let _dryRun = false;
12
14
 
13
- export { MARKETPLACE_REPO, MARKETPLACE_NAME };
15
+ export { MARKETPLACE_REPO, MARKETPLACE_NAME, CODEX_MARKETPLACE_PATH, CODEX_MARKETPLACE_NAME };
14
16
 
15
17
  export function setDryRun(value) {
16
18
  _dryRun = value;
@@ -35,16 +37,34 @@ export function run(cmd, label) {
35
37
  }
36
38
  }
37
39
 
38
- export function checkPrerequisites() {
40
+ function runtimesFor(runtime = "claude") {
41
+ if (runtime === "both") return ["claude", "codex"];
42
+ return [runtime];
43
+ }
44
+
45
+ export function checkPrerequisites({ runtime = "claude" } = {}) {
39
46
  if (_dryRun) return;
40
47
 
41
- try {
42
- execSync("claude --version", { stdio: "pipe" });
43
- } catch {
44
- console.error(
45
- "Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
46
- );
47
- process.exit(1);
48
+ for (const currentRuntime of runtimesFor(runtime)) {
49
+ if (currentRuntime === "claude") {
50
+ try {
51
+ execSync("claude --version", { stdio: "pipe" });
52
+ } catch {
53
+ console.error(
54
+ "Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
55
+ );
56
+ process.exit(1);
57
+ }
58
+ } else if (currentRuntime === "codex") {
59
+ try {
60
+ execSync("codex --version", { stdio: "pipe" });
61
+ } catch {
62
+ console.error(
63
+ "Error: 'codex' CLI not found. Install Codex CLI first:\n https://developers.openai.com/codex\n"
64
+ );
65
+ process.exit(1);
66
+ }
67
+ }
48
68
  }
49
69
  }
50
70
 
@@ -55,6 +75,13 @@ export function addMarketplace() {
55
75
  );
56
76
  }
57
77
 
78
+ export function addCodexMarketplace() {
79
+ return run(
80
+ `codex plugin marketplace add ${CODEX_MARKETPLACE_PATH}`,
81
+ `Codex marketplace: ${CODEX_MARKETPLACE_NAME}`
82
+ );
83
+ }
84
+
58
85
  export function installPlugin(pluginName, { scope = "project" } = {}) {
59
86
  return run(
60
87
  `claude plugin install ${pluginName}@${MARKETPLACE_NAME} --scope ${scope}`,
@@ -62,6 +89,13 @@ export function installPlugin(pluginName, { scope = "project" } = {}) {
62
89
  );
63
90
  }
64
91
 
92
+ export function installCodexPlugin(pluginName) {
93
+ return run(
94
+ `codex plugin add ${pluginName}@${CODEX_MARKETPLACE_NAME}`,
95
+ pluginName
96
+ );
97
+ }
98
+
65
99
  export function updatePlugin(pluginName, { scope = "project" } = {}) {
66
100
  return run(
67
101
  `claude plugin update "${pluginName}@${MARKETPLACE_NAME}" --scope ${scope}`,
@@ -69,18 +103,36 @@ export function updatePlugin(pluginName, { scope = "project" } = {}) {
69
103
  );
70
104
  }
71
105
 
106
+ export function updateCodexMarketplace() {
107
+ return run(
108
+ `codex plugin marketplace add ${CODEX_MARKETPLACE_PATH}`,
109
+ `Codex marketplace: ${CODEX_MARKETPLACE_NAME}`
110
+ );
111
+ }
112
+
72
113
  export function uninstallPlugin(pluginName) {
73
114
  return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
74
115
  }
75
116
 
117
+ export function uninstallCodexPlugin(pluginName) {
118
+ return run(`codex plugin remove ${pluginName}`, `Removing ${pluginName}`);
119
+ }
120
+
76
121
  /**
77
122
  * Install a single package: marketplace add + plugin install.
78
123
  */
79
- export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
124
+ export function installPackage(pluginName, { deps = [], scope = "project", runtime = "claude" } = {}) {
80
125
  console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
81
126
 
82
- addMarketplace();
83
- installPlugin(pluginName, { scope });
127
+ if (runtime === "claude" || runtime === "both") {
128
+ addMarketplace();
129
+ installPlugin(pluginName, { scope });
130
+ }
131
+
132
+ if (runtime === "codex" || runtime === "both") {
133
+ addCodexMarketplace();
134
+ installCodexPlugin(pluginName);
135
+ }
84
136
 
85
137
  if (deps.length > 0) {
86
138
  console.log(`\nNote: This plugin works best with:`);
@@ -90,34 +142,47 @@ export function installPackage(pluginName, { deps = [], scope = "project" } = {}
90
142
  }
91
143
 
92
144
  console.log(
93
- `\nDone! Restart Claude Code to activate.\n`
145
+ `\nDone! Restart ${runtime === "codex" ? "Codex" : runtime === "both" ? "Claude Code and Codex" : "Claude Code"} to activate.\n`
94
146
  );
95
147
  }
96
148
 
97
149
  /**
98
150
  * Update a single package.
99
151
  */
100
- export function updatePackage(pluginName, { scope = "project" } = {}) {
152
+ export function updatePackage(pluginName, { scope = "project", runtime = "claude" } = {}) {
101
153
  console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
102
154
 
103
- run(
104
- `claude plugin marketplace update ${MARKETPLACE_NAME}`,
105
- "Updating marketplace"
106
- );
107
- updatePlugin(pluginName, { scope });
155
+ if (runtime === "claude" || runtime === "both") {
156
+ run(
157
+ `claude plugin marketplace update ${MARKETPLACE_NAME}`,
158
+ "Updating marketplace"
159
+ );
160
+ updatePlugin(pluginName, { scope });
161
+ }
108
162
 
109
- console.log("\nDone! Restart Claude Code to apply updates.\n");
163
+ if (runtime === "codex" || runtime === "both") {
164
+ updateCodexMarketplace();
165
+ installCodexPlugin(pluginName);
166
+ }
167
+
168
+ console.log(`\nDone! Restart ${runtime === "codex" ? "Codex" : runtime === "both" ? "Claude Code and Codex" : "Claude Code"} to apply updates.\n`);
110
169
  }
111
170
 
112
171
  /**
113
172
  * Uninstall a single package.
114
173
  */
115
- export function uninstallPackage(pluginName) {
174
+ export function uninstallPackage(pluginName, { runtime = "claude" } = {}) {
116
175
  console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
117
176
 
118
- uninstallPlugin(pluginName);
177
+ if (runtime === "claude" || runtime === "both") {
178
+ uninstallPlugin(pluginName);
179
+ }
180
+
181
+ if (runtime === "codex" || runtime === "both") {
182
+ uninstallCodexPlugin(pluginName);
183
+ }
119
184
 
120
- console.log("\nDone. Restart Claude Code to apply changes.\n");
185
+ console.log(`\nDone. Restart ${runtime === "codex" ? "Codex" : runtime === "both" ? "Claude Code and Codex" : "Claude Code"} to apply changes.\n`);
121
186
  }
122
187
 
123
188
  /**
@@ -131,6 +196,7 @@ export function parseStandardArgs(argv) {
131
196
  update: args.includes("--update"),
132
197
  dryRun: args.includes("--dry-run"),
133
198
  scope: "project",
199
+ runtime: "claude",
134
200
  };
135
201
  const scopeIdx = args.indexOf("--scope");
136
202
  if (scopeIdx !== -1 && args[scopeIdx + 1]) {
@@ -142,5 +208,15 @@ export function parseStandardArgs(argv) {
142
208
  process.exit(1);
143
209
  }
144
210
  }
211
+ const runtimeIdx = args.indexOf("--runtime");
212
+ if (runtimeIdx !== -1 && args[runtimeIdx + 1]) {
213
+ const val = args[runtimeIdx + 1];
214
+ if (["claude", "codex", "both"].includes(val)) {
215
+ flags.runtime = val;
216
+ } else {
217
+ console.error("--runtime requires: claude, codex, or both");
218
+ process.exit(1);
219
+ }
220
+ }
145
221
  return flags;
146
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.13.5",
3
+ "version": "0.14.0-preview.762",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"
@@ -100,7 +100,7 @@ If the verdict is FAIL, do NOT auto-rewrite the draft. Use `AskUserQuestion`:
100
100
  - options:
101
101
  1. `Rewrite the draft and re-review` — return to step 1 with the rewritten body.
102
102
  2. `Move to a private channel` — direct the user to a non-public surface (vendor private email, internal Slack, etc.) where the leak does not apply.
103
- 3. `Override anyway` — set `BYPASS_RISK_GATE=1` for the next gated tool call. Reserved for cases where the user has confirmed the content is safe to publish (e.g. the "client name" is actually their own org).
103
+ 3. `Re-review with context` — if the flag is a false positive (e.g. the "client name" is actually the user's own org), give that context to the `wr-risk-scorer:external-comms` reviewer and let it re-judge; a PASS verdict clears the gate via the review marker. There is no `BYPASS_RISK_GATE` env override (removed P377/RFC-029) — the reviewer's judgement, not an env knob, is the clearance path.
104
104
  4. `Cancel` — abandon the post.
105
105
 
106
106
  Do not make the decision unilaterally — per ADR-013 Rule 1, all leak/no-leak judgement calls outside the regex pre-filter belong to the user.
@@ -82,11 +82,6 @@ Present the full risk report to the user. Highlight:
82
82
  - Any risks above appetite (score ≥ 5 per RISK-POLICY.md)
83
83
  - Whether the gate is now pre-satisfied for the current session (i.e., a subsequent `git commit` will reuse the bypass marker without re-triggering the scorer)
84
84
 
85
- If any score is above appetite, use `AskUserQuestion` to ask whether the user wants to:
86
- - (a) Proceed anyway (bypass)
87
- - (b) Remediate the identified risks first
88
- - (c) Cancel
89
-
90
- Do not make the decision unilaterally — per ADR-013 Rule 1, all above-appetite risk decisions are the user's.
85
+ If any score is above appetite, the action is **framework-mediated, not a user decision** (P377/RFC-029; ADR-044 category-3 explicitly excludes above-appetite). Per **ADR-042 Rule 1**: auto-apply scorer remediations incrementally until residual risk is within appetite, OR halt per ADR-042 Rule 5 if the scorer cannot converge. **MUST NOT commit/push/release above appetite, and MUST NOT use `AskUserQuestion` to ask whether to proceed anyway** — there is no "proceed anyway (bypass)" option; the answer is always reduce-risk-or-halt. The only sanctioned non-blocking path is the risk-reducing path (a genuinely risk-reducing change clears via the `reducing` bypass). Under non-interactive contexts the ADR-013 Rule 6 fail-safe is the terminal fallback: skip the action and report the uncommitted/unreleased state.
91
86
 
92
87
  $ARGUMENTS
@@ -162,6 +162,11 @@ Write the policy using the structure derived from the agent contract (step 1). T
162
162
  - The impact levels with project-specific descriptions from step 5
163
163
  - The likelihood levels (universal 1-5 scale)
164
164
  - The risk matrix (Impact × Likelihood table) and label bands
165
+ - **An `## Authorized Bypass Scenarios` section** (P377/RFC-029) stating exactly which gate bypasses are sanctioned — and that nothing else is. The canonical text to write:
166
+ - **Risk-reducing / risk-neutral changes** proceed via the risk-reducing path (a change that lowers or holds residual risk is not blocked by the gate). The scorer emits `RISK_BYPASS: reducing`; the gate honours a drift-revalidated, TTL-bounded `reducing-*` marker.
167
+ - **Incident response** is NOT a separate carve-out — an active incident is a risk already being realised (Likelihood 5), so an incident-response change is scored against that live baseline and proceeds only if net-risk-reducing (per ADR-042 Rule 1b). The `incident-release` marker exists only to let a net-reducing restore-service release proceed despite red/unreadable CI during a live outage.
168
+ - **Above appetite is never bypassable by a prompt or an env var.** There is no "commit/release anyway" question and no `BYPASS_RISK_GATE` / `ci-bypass` override (removed P377/RFC-029) — above appetite, the action auto-remediates to within appetite or halts (ADR-042 Rule 1).
169
+ - **Default-permitted-when-silent**: a project whose RISK-POLICY.md predates this section still permits the risk-reducing and incident paths above; this section makes the policy the explicit single source of truth and SHOULD be added at the next review.
165
170
  - A note that both the risk-scorer agent and problem management process reference this policy
166
171
 
167
172
  ## Updating an existing policy