@windyroad/risk-scorer 0.3.5-preview.188 → 0.3.6-preview.192

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-risk-scorer",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Pipeline risk scoring, commit/push/release gates for Claude Code"
5
5
  }
@@ -32,7 +32,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git push(\s|$)'; then
32
32
  exit 0
33
33
  fi
34
34
 
35
- # Gate push:watch on push risk score
35
+ # Gate push:watch on push risk score (inherits three-band TTL via check_risk_gate — P090)
36
36
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
37
37
  if [ -n "$SESSION_ID" ]; then
38
38
  RDIR=$(_risk_dir "$SESSION_ID")
@@ -45,27 +45,12 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
45
45
  if [ -f "${RDIR}/clean" ]; then
46
46
  exit 0
47
47
  fi
48
- PUSH_SCORE_FILE="${RDIR}/push"
49
- if [ ! -f "$PUSH_SCORE_FILE" ]; then
50
- risk_gate_deny "Push blocked: No push risk score found. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk."
51
- exit 0
52
- fi
53
- PUSH_NOW=$(date +%s)
54
- PUSH_SCORE_TIME=$(_mtime "$PUSH_SCORE_FILE")
55
- PUSH_AGE=$(( PUSH_NOW - PUSH_SCORE_TIME ))
56
- PUSH_TTL="${RISK_TTL:-3600}"
57
- if [ "$PUSH_AGE" -ge "$PUSH_TTL" ]; then
58
- risk_gate_deny "Push blocked: Push risk score expired (${PUSH_AGE}s old, TTL ${PUSH_TTL}s). Delegate to risk-scorer to rescore."
59
- exit 0
60
- fi
61
- PUSH_SCORE=$(cat "$PUSH_SCORE_FILE" 2>/dev/null || echo "")
62
- if ! echo "$PUSH_SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
63
- risk_gate_deny "Push blocked: Push risk score is not yet available (scoring in progress). Wait a moment and retry."
64
- exit 0
65
- fi
66
- PUSH_DENIED=$(python3 -c "print('yes' if float('${PUSH_SCORE}') >= 5 else 'no')" 2>/dev/null || echo "no")
67
- if [ "$PUSH_DENIED" = "yes" ]; then
68
- risk_gate_deny "Push blocked: Push risk score ${PUSH_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') — it will create a bypass marker."
48
+ if ! check_risk_gate "$SESSION_ID" "push"; then
49
+ if [ "$RISK_GATE_CATEGORY" = "threshold" ]; then
50
+ risk_gate_deny "Push blocked: Push risk score ${RISK_GATE_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') it will create a bypass marker."
51
+ else
52
+ risk_gate_deny "Push blocked: ${RISK_GATE_REASON}"
53
+ fi
69
54
  exit 0
70
55
  fi
71
56
  fi
@@ -9,6 +9,18 @@ source "$_RISK_GATE_DIR/gate-helpers.sh"
9
9
 
10
10
  # Check risk gate for a given action. Returns 0 if allowed, 1 if denied.
11
11
  # Sets RISK_GATE_REASON on failure with human-readable message.
12
+ # Also sets RISK_GATE_CATEGORY ∈ {missing, expired, drift, invalid, threshold}
13
+ # and RISK_GATE_SCORE (on threshold) for callers that customise deny messages.
14
+ #
15
+ # Implements the three-band TTL policy (P090, ADR-009 footnote):
16
+ # Band A: age < TTL/2 → pass silently (no slide).
17
+ # Band B: TTL/2 ≤ age < TTL → if state-hash is invariant since the
18
+ # scorer ran, pass AND slide the marker
19
+ # forward (touch score file); bounded by
20
+ # a 2×TTL hard-cap from the scorer-run
21
+ # birth time stored in <action>-born.
22
+ # If the hash drifted, halt as before.
23
+ # Band C: age ≥ TTL → halt with the existing expired message.
12
24
  # Usage: check_risk_gate "$SESSION_ID" "commit"
13
25
  check_risk_gate() {
14
26
  local SESSION_ID="$1"
@@ -16,24 +28,37 @@ check_risk_gate() {
16
28
  local RDIR
17
29
  RDIR=$(_risk_dir "$SESSION_ID")
18
30
  local SCORE_FILE="${RDIR}/${ACTION}"
31
+ local BORN_FILE="${RDIR}/${ACTION}-born"
19
32
  local HASH_FILE="${RDIR}/state-hash"
20
33
  local TTL_SECONDS="${RISK_TTL:-3600}"
21
34
 
35
+ RISK_GATE_CATEGORY=""
36
+ RISK_GATE_SCORE=""
37
+
22
38
  # 1. Score file must exist (fail-closed)
23
39
  if [ ! -f "$SCORE_FILE" ]; then
40
+ RISK_GATE_CATEGORY="missing"
24
41
  RISK_GATE_REASON="No ${ACTION} risk score found. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk."
25
42
  return 1
26
43
  fi
27
44
 
28
- # 2. TTL check score file mtime must be within TTL
45
+ # 2. TTL — Band C hard expiry first
29
46
  local NOW=$(date +%s)
30
47
  local SCORE_TIME=$(_mtime "$SCORE_FILE")
31
48
  local AGE=$(( NOW - SCORE_TIME ))
32
49
  if [ "$AGE" -ge "$TTL_SECONDS" ]; then
50
+ RISK_GATE_CATEGORY="expired"
33
51
  RISK_GATE_REASON="Risk score expired (${AGE}s old, TTL ${TTL_SECONDS}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
34
52
  return 1
35
53
  fi
36
54
 
55
+ # Detect Band B candidacy (age in [TTL/2, TTL))
56
+ local HALF_TTL=$(( TTL_SECONDS / 2 ))
57
+ local BAND_B=0
58
+ if [ "$AGE" -ge "$HALF_TTL" ]; then
59
+ BAND_B=1
60
+ fi
61
+
37
62
  # 3. Drift detection — pipeline state hash must match
38
63
  # The hash is computed from git diff HEAD --stat at prompt submit time.
39
64
  # If you staged files AFTER the prompt, the hash will differ.
@@ -43,15 +68,34 @@ check_risk_gate() {
43
68
  local CURRENT_HASH
44
69
  CURRENT_HASH=$("$_RISK_GATE_DIR/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
45
70
  if [ "$STORED_HASH" != "$CURRENT_HASH" ]; then
71
+ RISK_GATE_CATEGORY="drift"
46
72
  RISK_GATE_REASON="Pipeline state drift: working tree changed since the last ${ACTION} risk assessment. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore against the current state."
47
73
  return 1
48
74
  fi
75
+
76
+ # Band B + hash invariant: slide the marker forward, bounded by 2×TTL
77
+ # from the scorer-run birth time. The hard cap prevents an unchanged-but-
78
+ # perpetually-idle tree from riding a single marker indefinitely.
79
+ if [ "$BAND_B" = "1" ]; then
80
+ if [ -f "$BORN_FILE" ]; then
81
+ local BORN_TIME=$(_mtime "$BORN_FILE")
82
+ local BORN_AGE=$(( NOW - BORN_TIME ))
83
+ local HARD_CAP=$(( TTL_SECONDS * 2 ))
84
+ if [ "$BORN_AGE" -ge "$HARD_CAP" ]; then
85
+ RISK_GATE_CATEGORY="expired"
86
+ RISK_GATE_REASON="Risk score expired (${BORN_AGE}s total since scoring, hard cap ${HARD_CAP}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
87
+ return 1
88
+ fi
89
+ fi
90
+ touch "$SCORE_FILE"
91
+ fi
49
92
  fi
50
- # No hash file = backward compat, skip drift check
93
+ # No hash file = backward compat, skip drift check and Band B slide
51
94
 
52
95
  # 4. Read and validate score
53
96
  local SCORE=$(cat "$SCORE_FILE" 2>/dev/null || echo "")
54
97
  if ! echo "$SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
98
+ RISK_GATE_CATEGORY="invalid"
55
99
  RISK_GATE_REASON="Risk score file contains an invalid value. Re-run the risk-scorer agent."
56
100
  return 1
57
101
  fi
@@ -63,6 +107,8 @@ print('yes' if score >= 5 else 'no')
63
107
  " 2>/dev/null || echo "no")
64
108
 
65
109
  if [ "$DENIED" = "yes" ]; then
110
+ RISK_GATE_CATEGORY="threshold"
111
+ RISK_GATE_SCORE="$SCORE"
66
112
  RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 (Medium or above). 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."
67
113
  return 1
68
114
  fi
@@ -42,9 +42,13 @@ if echo "$SUBAGENT" | grep -qE 'risk-scorer.pipeline'; then
42
42
  PUSH=$(echo "$SCORES_LINE" | grep -oE 'push=[0-9]+' | cut -d= -f2) || true
43
43
  RELEASE=$(echo "$SCORES_LINE" | grep -oE 'release=[0-9]+' | cut -d= -f2) || true
44
44
 
45
- [ -n "$COMMIT" ] && printf '%s' "$COMMIT" > "${RDIR}/commit"
46
- [ -n "$PUSH" ] && printf '%s' "$PUSH" > "${RDIR}/push"
47
- [ -n "$RELEASE" ] && printf '%s' "$RELEASE" > "${RDIR}/release"
45
+ # Birth markers (<action>-born) capture the scorer-run timestamp. Band B
46
+ # of the three-band TTL policy (P090) uses them to enforce a 2×TTL
47
+ # hard-cap on sliding-window extension, so an unchanged-but-idle tree
48
+ # cannot ride a single score indefinitely.
49
+ [ -n "$COMMIT" ] && { printf '%s' "$COMMIT" > "${RDIR}/commit"; touch "${RDIR}/commit-born"; }
50
+ [ -n "$PUSH" ] && { printf '%s' "$PUSH" > "${RDIR}/push"; touch "${RDIR}/push-born"; }
51
+ [ -n "$RELEASE" ] && { printf '%s' "$RELEASE" > "${RDIR}/release"; touch "${RDIR}/release-born"; }
48
52
  fi
49
53
 
50
54
  # Parse RISK_BYPASS: reducing|incident
@@ -105,3 +105,117 @@ assert_gate_allows() {
105
105
  [[ "$output" == *"deny"* ]]
106
106
  [[ "$output" == *"Test reason"* ]]
107
107
  }
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Three-band TTL policy (P090)
111
+ # Band A: age < TTL/2 → pass silently, no slide
112
+ # Band B: TTL/2 <= age < TTL → consult state-hash; if invariant, pass + slide
113
+ # the marker forward (touch score file) bounded by 2*TTL hard cap
114
+ # from the scorer-run birth time (<action>-born); if drifted, halt
115
+ # Band C: age >= TTL → halt with existing "expired" message
116
+ # ---------------------------------------------------------------------------
117
+
118
+ # Helper: backdate file mtime by N seconds (portable between macOS and Linux)
119
+ _backdate() {
120
+ local file="$1" seconds="$2"
121
+ local stamp
122
+ stamp=$(date -v-${seconds}S +%Y%m%d%H%M.%S 2>/dev/null \
123
+ || date -d "${seconds} seconds ago" +%Y%m%d%H%M.%S 2>/dev/null)
124
+ touch -t "$stamp" "$file"
125
+ }
126
+
127
+ # Helper: write a matching state-hash for the current working tree
128
+ _write_matching_hash() {
129
+ local target="$1"
130
+ local hash
131
+ hash=$("$HOOKS_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
132
+ echo "$hash" > "$target"
133
+ }
134
+
135
+ @test "Band A (age < TTL/2): passes, does NOT slide the marker" {
136
+ printf '3' > "$SCORE_FILE"
137
+ touch "$SCORE_FILE"
138
+ rm -f "$HASH_FILE"
139
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
140
+ sleep 1
141
+ assert_gate_allows "$TEST_SESSION" "commit"
142
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
143
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
144
+ }
145
+
146
+ @test "Band B (TTL/2 <= age < TTL) with hash invariant: passes AND slides marker forward" {
147
+ printf '3' > "$SCORE_FILE"
148
+ _backdate "$SCORE_FILE" 3
149
+ _write_matching_hash "$HASH_FILE"
150
+ touch "${SCORE_FILE}-born"
151
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
152
+ assert_gate_allows "$TEST_SESSION" "commit"
153
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
154
+ [ "$AFTER_MTIME" -gt "$BEFORE_MTIME" ]
155
+ }
156
+
157
+ @test "Band B with no hash file: passes but does NOT slide (no invariance proof)" {
158
+ printf '3' > "$SCORE_FILE"
159
+ _backdate "$SCORE_FILE" 3
160
+ rm -f "$HASH_FILE"
161
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
162
+ sleep 1
163
+ assert_gate_allows "$TEST_SESSION" "commit"
164
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
165
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
166
+ }
167
+
168
+ @test "Band B with hash mismatch: denies with drift (no slide)" {
169
+ printf '3' > "$SCORE_FILE"
170
+ _backdate "$SCORE_FILE" 3
171
+ echo "staleold" > "$HASH_FILE"
172
+ touch "${SCORE_FILE}-born"
173
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
174
+ assert_gate_denies "$TEST_SESSION" "commit" "drift"
175
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
176
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
177
+ }
178
+
179
+ @test "Band B with hard-cap exceeded (born-age >= 2*TTL): denies even if hash invariant" {
180
+ printf '3' > "$SCORE_FILE"
181
+ _backdate "$SCORE_FILE" 3
182
+ _write_matching_hash "$HASH_FILE"
183
+ touch "${SCORE_FILE}-born"
184
+ _backdate "${SCORE_FILE}-born" 12
185
+ assert_gate_denies "$TEST_SESSION" "commit" "expired"
186
+ }
187
+
188
+ @test "Band C (age >= TTL): denies regardless of hash invariance" {
189
+ printf '3' > "$SCORE_FILE"
190
+ _backdate "$SCORE_FILE" 10
191
+ _write_matching_hash "$HASH_FILE"
192
+ touch "${SCORE_FILE}-born"
193
+ assert_gate_denies "$TEST_SESSION" "commit" "expired"
194
+ }
195
+
196
+ @test "Band B denial exports RISK_GATE_CATEGORY=drift on hash mismatch" {
197
+ printf '3' > "$SCORE_FILE"
198
+ _backdate "$SCORE_FILE" 3
199
+ echo "staleold" > "$HASH_FILE"
200
+ RISK_GATE_CATEGORY=""
201
+ ! check_risk_gate "$TEST_SESSION" "commit"
202
+ [ "$RISK_GATE_CATEGORY" = "drift" ]
203
+ }
204
+
205
+ @test "Band C denial exports RISK_GATE_CATEGORY=expired" {
206
+ printf '3' > "$SCORE_FILE"
207
+ _backdate "$SCORE_FILE" 10
208
+ RISK_GATE_CATEGORY=""
209
+ ! check_risk_gate "$TEST_SESSION" "commit"
210
+ [ "$RISK_GATE_CATEGORY" = "expired" ]
211
+ }
212
+
213
+ @test "Threshold denial exports RISK_GATE_CATEGORY=threshold and RISK_GATE_SCORE" {
214
+ printf '7' > "$SCORE_FILE"
215
+ touch "$SCORE_FILE"
216
+ RISK_GATE_CATEGORY=""
217
+ RISK_GATE_SCORE=""
218
+ ! check_risk_gate "$TEST_SESSION" "commit"
219
+ [ "$RISK_GATE_CATEGORY" = "threshold" ]
220
+ [ "$RISK_GATE_SCORE" = "7" ]
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.3.5-preview.188",
3
+ "version": "0.3.6-preview.192",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"