@windyroad/risk-scorer 0.3.5 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pipeline.md +3 -2
- package/agents/plan.md +3 -2
- package/agents/test/risk-scorer-structured-remediations.bats +23 -0
- package/agents/wip.md +3 -2
- package/hooks/git-push-gate.sh +7 -22
- package/hooks/lib/risk-gate.sh +49 -3
- package/hooks/risk-score-mark.sh +7 -3
- package/hooks/test/risk-gate.bats +114 -0
- package/package.json +1 -1
- package/skills/create-risk/SKILL.md +172 -0
package/agents/pipeline.md
CHANGED
|
@@ -149,9 +149,9 @@ exceeds appetite. The only sanctioned above-appetite output is the Risk Report
|
|
|
149
149
|
structure, `RISK_SCORES: ...`, and the structured `RISK_REMEDIATIONS:` block
|
|
150
150
|
defined below.
|
|
151
151
|
|
|
152
|
-
Emit a structured `RISK_REMEDIATIONS:` block after the `RISK_SCORES:` line. This gives the calling skill machine-readable input
|
|
152
|
+
Emit a structured `RISK_REMEDIATIONS:` block after the `RISK_SCORES:` line. This gives the calling skill machine-readable input.
|
|
153
153
|
|
|
154
|
-
Format (5 columns
|
|
154
|
+
Format (5 columns):
|
|
155
155
|
```
|
|
156
156
|
RISK_REMEDIATIONS:
|
|
157
157
|
- R1 | <description of remediation> | <effort S/M/L> | <risk_delta -N> | <files affected>
|
|
@@ -161,6 +161,7 @@ RISK_REMEDIATIONS:
|
|
|
161
161
|
Column definitions:
|
|
162
162
|
- **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
|
|
163
163
|
- **risk_delta**: estimated reduction in residual risk if this remediation is applied (e.g., `-3` means risk drops by 3 points)
|
|
164
|
+
- **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
|
|
164
165
|
|
|
165
166
|
Include downstream back-pressure in the remediation list:
|
|
166
167
|
- **Commit**: If adding this commit would push the push queue risk >= 5, include a remediation to split the commit.
|
package/agents/plan.md
CHANGED
|
@@ -55,7 +55,7 @@ not policy-authorised — the only sanctioned FAIL output is the Plan Risk Repor
|
|
|
55
55
|
the `RISK_VERDICT: FAIL` marker, and the structured `RISK_REMEDIATIONS:` block
|
|
56
56
|
defined below.
|
|
57
57
|
|
|
58
|
-
Emit a structured `RISK_REMEDIATIONS:` block after the verdict (5 columns
|
|
58
|
+
Emit a structured `RISK_REMEDIATIONS:` block after the verdict (5 columns):
|
|
59
59
|
```
|
|
60
60
|
RISK_REMEDIATIONS:
|
|
61
61
|
- R1 | <description of what the plan must add/change> | <effort S/M/L> | <risk_delta -N> | <affected area>
|
|
@@ -64,8 +64,9 @@ RISK_REMEDIATIONS:
|
|
|
64
64
|
Column definitions:
|
|
65
65
|
- **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
|
|
66
66
|
- **risk_delta**: estimated reduction in residual risk if this remediation is applied
|
|
67
|
+
- **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
|
|
67
68
|
|
|
68
|
-
Do NOT emit free-text "consider" or "you should" prose. The
|
|
69
|
+
Do NOT emit free-text "consider" or "you should" prose outside the structured block. The `RISK_REMEDIATIONS:` block is the only output for above-appetite guidance.
|
|
69
70
|
|
|
70
71
|
## Control Discovery
|
|
71
72
|
|
|
@@ -98,3 +98,26 @@ setup() {
|
|
|
98
98
|
run grep -q "risk_delta" "$PLAN"
|
|
99
99
|
[ "$status" -eq 0 ]
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
# P108: scorer writes prose descriptions; agent decides (ADR-042 Rule 2a)
|
|
104
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@test "pipeline.md RISK_REMEDIATIONS format has no action_class column" {
|
|
107
|
+
# ADR-042 Rule 2a: no structured action_class column. The agent reads
|
|
108
|
+
# the description and decides. Match only markdown-table column-header
|
|
109
|
+
# rows so prose mentions of "action_class" (e.g. "No structured
|
|
110
|
+
# action_class column.") do not trip the assertion (P114).
|
|
111
|
+
run grep -qE '^\| *action_class\b' "$PIPELINE"
|
|
112
|
+
[ "$status" -ne 0 ]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@test "wip.md RISK_REMEDIATIONS format has no action_class column" {
|
|
116
|
+
run grep -qE '^\| *action_class\b' "$WIP"
|
|
117
|
+
[ "$status" -ne 0 ]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "plan.md RISK_REMEDIATIONS format has no action_class column" {
|
|
121
|
+
run grep -qE '^\| *action_class\b' "$PLAN"
|
|
122
|
+
[ "$status" -ne 0 ]
|
|
123
|
+
}
|
package/agents/wip.md
CHANGED
|
@@ -61,7 +61,7 @@ structured `RISK_REMEDIATIONS:` block defined below.
|
|
|
61
61
|
|
|
62
62
|
Provide the assessment table, then emit a structured `RISK_REMEDIATIONS:` block with specific risk-reducing actions:
|
|
63
63
|
|
|
64
|
-
Format (5 columns
|
|
64
|
+
Format (5 columns):
|
|
65
65
|
```
|
|
66
66
|
RISK_REMEDIATIONS:
|
|
67
67
|
- R1 | Commit current changes to move WIP forward | S | -2 | <uncommitted files>
|
|
@@ -73,8 +73,9 @@ RISK_REMEDIATIONS:
|
|
|
73
73
|
Column definitions:
|
|
74
74
|
- **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
|
|
75
75
|
- **risk_delta**: estimated reduction in residual risk if this remediation is applied (e.g., `-3` means risk drops by 3 points)
|
|
76
|
+
- **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
|
|
76
77
|
|
|
77
|
-
Do NOT emit free-text suggestions
|
|
78
|
+
Do NOT emit free-text suggestions outside the structured block. The `RISK_REMEDIATIONS:` block is the only output for above-appetite guidance.
|
|
78
79
|
|
|
79
80
|
The verdict is `RISK_VERDICT: PAUSE`. This blocks the next edit until the risk is addressed.
|
|
80
81
|
|
package/hooks/git-push-gate.sh
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
PUSH_SCORE_TIME=$(_mtime "$PUSH_SCORE_FILE")
|
|
55
|
-
PUSH_AGE=$(( PUSH_NOW - PUSH_SCORE_TIME ))
|
|
56
|
-
PUSH_TTL="${RISK_TTL:-1800}"
|
|
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
|
package/hooks/lib/risk-gate.sh
CHANGED
|
@@ -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
|
-
local TTL_SECONDS="${RISK_TTL:-
|
|
33
|
+
local TTL_SECONDS="${RISK_TTL:-3600}"
|
|
34
|
+
|
|
35
|
+
RISK_GATE_CATEGORY=""
|
|
36
|
+
RISK_GATE_SCORE=""
|
|
21
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
|
|
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
|
package/hooks/risk-score-mark.sh
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wr-risk-scorer:create-risk
|
|
3
|
+
description: Create a new standing-risk entry in docs/risks/. Examines existing risks, gathers impact/likelihood/controls from the user, writes a file matching docs/risks/TEMPLATE.md, and updates the register index.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Risk Register Entry Generator
|
|
8
|
+
|
|
9
|
+
Create a new standing-risk file in `docs/risks/` following the format defined by `docs/risks/TEMPLATE.md`. The register captures persistent risks (distinct from the ephemeral per-change reports in `.risk-reports/`), and its criteria come from `RISK-POLICY.md`.
|
|
10
|
+
|
|
11
|
+
This skill is the invocation surface for populating the register (scaffolded by P033; populated per P102). Per ADR-015, it is a plugin-namespaced on-demand skill. Per ADR-014, the skill commits its own work.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
### 1. Discover existing risks
|
|
16
|
+
|
|
17
|
+
Scan for existing risk files:
|
|
18
|
+
- Glob `docs/risks/R*.md` (skip `README.md`, `TEMPLATE.md`)
|
|
19
|
+
- Note the highest numbered risk to determine the next sequence number
|
|
20
|
+
- Read any risks related to the topic being discussed (if the user has mentioned a topic)
|
|
21
|
+
- If `docs/risks/` does not exist, explain that `/wr-risk-scorer:update-policy` must be run first (it ships the scaffolding) and stop
|
|
22
|
+
|
|
23
|
+
### 2. Gather context from the user
|
|
24
|
+
|
|
25
|
+
You MUST use the AskUserQuestion tool to collect context that cannot be derived. Do not proceed to step 3 until you have answers. Apply ADR-013 Rule 6 non-interactive defaults if the tool is unavailable (AFK mode): choose the most conservative option for each question and note auto-selection in the output.
|
|
26
|
+
|
|
27
|
+
Auto-derive where possible (do not ask):
|
|
28
|
+
- **ID number** — next free slot per step 3 (do not ask per `feedback_dont_ask_trivial_id_choices.md`).
|
|
29
|
+
- **Today's date** — use the current date for `Identified` and `Last reviewed`.
|
|
30
|
+
- **Category** — infer from description keywords where unambiguous: "token", "secret", "leak" → `infosec`; "install", "hook", "pipeline" → `operational`. Confirm only if ambiguous.
|
|
31
|
+
- **Next review** — default to 6 months from today.
|
|
32
|
+
|
|
33
|
+
Ask the user (one AskUserQuestion call with grouped questions):
|
|
34
|
+
|
|
35
|
+
1. **What is the risk?** A short title and 1-2 paragraph description — what could go wrong, for whom, and why it matters. This is the condition, not the control.
|
|
36
|
+
2. **Impact level (from `RISK-POLICY.md`)?** 1 Negligible · 2 Minor · 3 Moderate · 4 Significant · 5 Severe. Read the policy's Impact table to the user if they need the descriptions.
|
|
37
|
+
3. **Likelihood level?** 1 Rare · 2 Unlikely · 3 Possible · 4 Likely · 5 Almost certain.
|
|
38
|
+
4. **Existing controls?** Each control names what it does and where it is implemented (file path or `ADR-NNN`). If none, leave empty.
|
|
39
|
+
5. **Residual impact and likelihood** (after controls). If controls are minimal, residual = inherent — do not fabricate reductions. Per ADR-026, quantitative reduction claims must cite evidence (test, hook gate, pipeline report). If no evidence, state "Residual same as inherent pending control evidence" in the Treatment section and set residual = inherent.
|
|
40
|
+
6. **Treatment choice?** Accept · Mitigate · Transfer · Avoid. Include brief justification.
|
|
41
|
+
7. **Owner?** Persona or role (e.g. `solo-developer`, `plugin-maintainer`, `tech-lead`).
|
|
42
|
+
|
|
43
|
+
If the user has already provided this context in the conversation (e.g. as arguments, or as part of a pipeline-finding hand-off), use what they have given and only ask about what is missing.
|
|
44
|
+
|
|
45
|
+
### 3. Determine sequence number and filename
|
|
46
|
+
|
|
47
|
+
- Next number = **max of the local and origin highest risk numbers**, plus 1 (or 001 if none exist).
|
|
48
|
+
- Filename: `R<NNN>-<kebab-case-title>.active.md`
|
|
49
|
+
- Pad the number to 3 digits (001, 002, ... 010, 011, etc.)
|
|
50
|
+
|
|
51
|
+
**Why compare against origin?** Per ADR-019 confirmation criterion 2, ticket-creator skills MUST re-check next-number assignment against `git ls-tree origin/<base>` before assigning. Without it, parallel sessions can mint the same ID for different risks, causing a destructive surgical rebase on push.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Local-max number
|
|
55
|
+
local_max=$(ls docs/risks/R*.md 2>/dev/null | sed 's/.*\///' | grep -oE '^R[0-9]+' | sed 's/^R//' | sort -n | tail -1)
|
|
56
|
+
|
|
57
|
+
# Origin-max number — reads remote-tracking ref. `--name-only` required per P056
|
|
58
|
+
# to avoid false-matches on blob SHAs.
|
|
59
|
+
origin_max=$(git ls-tree --name-only origin/main docs/risks/ 2>/dev/null | sed 's|^docs/risks/||' | grep -oE '^R[0-9]+' | sed 's/^R//' | sort -n | tail -1)
|
|
60
|
+
|
|
61
|
+
# Take the max of the two and increment.
|
|
62
|
+
next=$(printf '%03d' $(( $(echo -e "${local_max:-0}\n${origin_max:-0}" | sort -n | tail -1) + 1 )))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If the local choice would have collided with an origin risk file created since the last fetch, the `git ls-tree` lookup catches it here and the renumber is automatic. Log the renumber in the user-facing report (e.g. "Bumped next risk number from R012 → R013 to avoid collision with origin").
|
|
66
|
+
|
|
67
|
+
### 4. Compute scores and bands
|
|
68
|
+
|
|
69
|
+
Use the Risk Matrix from `RISK-POLICY.md`:
|
|
70
|
+
|
|
71
|
+
- **Inherent Score** = Impact × Likelihood
|
|
72
|
+
- **Residual Score** = Impact × Likelihood (after controls)
|
|
73
|
+
- **Band** (for each) per the Label Bands table: 1-2 Very Low · 3-4 Low · 5-9 Medium · 10-16 High · 17-25 Very High
|
|
74
|
+
- **Within appetite?** = residual score ≤ `RISK-POLICY.md`'s appetite threshold (read the threshold at runtime; do not hardcode)
|
|
75
|
+
|
|
76
|
+
### 5. Write the risk file
|
|
77
|
+
|
|
78
|
+
Write the file to `docs/risks/` using the structure from `TEMPLATE.md`:
|
|
79
|
+
|
|
80
|
+
```markdown
|
|
81
|
+
# Risk R<NNN>: <Title>
|
|
82
|
+
|
|
83
|
+
**Status**: Active
|
|
84
|
+
**Category**: <infosec | operational | brand | delivery>
|
|
85
|
+
**Identified**: <YYYY-MM-DD>
|
|
86
|
+
**Owner**: <persona or role>
|
|
87
|
+
**Last reviewed**: <YYYY-MM-DD>
|
|
88
|
+
**Next review**: <YYYY-MM-DD + 6 months>
|
|
89
|
+
|
|
90
|
+
## Description
|
|
91
|
+
|
|
92
|
+
<1-2 paragraph description from step 2.>
|
|
93
|
+
|
|
94
|
+
## Inherent Risk
|
|
95
|
+
|
|
96
|
+
Impact × Likelihood *before* controls.
|
|
97
|
+
|
|
98
|
+
- **Impact**: <level> (<label>)
|
|
99
|
+
- **Likelihood**: <level> (<label>)
|
|
100
|
+
- **Inherent Score**: <product>
|
|
101
|
+
- **Inherent Band**: <band>
|
|
102
|
+
|
|
103
|
+
## Controls
|
|
104
|
+
|
|
105
|
+
- **<control-name>** — <what it does>. Implemented in <file path or ADR-NNN>.
|
|
106
|
+
|
|
107
|
+
## Residual Risk
|
|
108
|
+
|
|
109
|
+
Impact × Likelihood *after* controls.
|
|
110
|
+
|
|
111
|
+
- **Impact**: <level> (<label>)
|
|
112
|
+
- **Likelihood**: <level> (<label>)
|
|
113
|
+
- **Residual Score**: <product>
|
|
114
|
+
- **Residual Band**: <band>
|
|
115
|
+
- **Within appetite?**: <Yes | No>
|
|
116
|
+
|
|
117
|
+
## Treatment
|
|
118
|
+
|
|
119
|
+
<Accept | Mitigate | Transfer | Avoid>. <Justification.>
|
|
120
|
+
|
|
121
|
+
## Monitoring
|
|
122
|
+
|
|
123
|
+
- **Trigger to re-assess**: <event or threshold>
|
|
124
|
+
- **Metrics**: <if any>
|
|
125
|
+
|
|
126
|
+
## Related
|
|
127
|
+
|
|
128
|
+
- Criteria: `RISK-POLICY.md`
|
|
129
|
+
- Realised-as: <links to `docs/problems/P<NNN>` if any>
|
|
130
|
+
- Treatment ADRs: <links if any>
|
|
131
|
+
- Personas affected: <links to `docs/jtbd/<persona>/persona.md`>
|
|
132
|
+
|
|
133
|
+
## Change Log
|
|
134
|
+
|
|
135
|
+
- <YYYY-MM-DD>: Initial identification.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 6. Update the register index
|
|
139
|
+
|
|
140
|
+
`docs/risks/README.md` has a **Register** table that MUST reflect the new risk. Append a row with the following columns:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
| R<NNN> | <Title> | <Category> | <Inherent Score> | <Residual Score> | <Treatment verb> | <Owner> | <Next review date> |
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
This step is not optional: the README drifts from the register without it, and the ISO 27001 audit signal depends on the index being accurate.
|
|
147
|
+
|
|
148
|
+
### 7. Confirm with the user
|
|
149
|
+
|
|
150
|
+
Present the written file path, inherent/residual bands, and any `Within appetite?: No` flag. Ask via AskUserQuestion:
|
|
151
|
+
|
|
152
|
+
1. Does the description accurately capture the risk?
|
|
153
|
+
2. Are the inherent and residual scores defensible?
|
|
154
|
+
3. Is the treatment choice appropriate for the residual band?
|
|
155
|
+
4. Should the owner or next review date be adjusted?
|
|
156
|
+
|
|
157
|
+
Apply any feedback by editing the file and re-updating the README row if scores/treatment change.
|
|
158
|
+
|
|
159
|
+
### 8. Commit the risk (ADR-014)
|
|
160
|
+
|
|
161
|
+
Per ADR-014, this skill commits its own work. Stage both files and commit:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
git add docs/risks/R<NNN>-<title>.active.md docs/risks/README.md
|
|
165
|
+
git commit -m "docs(risks): open R<NNN> <title>"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The commit message convention `docs(risks): open R<NNN> <title>` matches `docs/risks/README.md` step 6 and mirrors `docs(problems): open P<NNN>` used by `/wr-itil:manage-problem`.
|
|
169
|
+
|
|
170
|
+
If the commit-gate pattern-matches `git commit` text and blocks, run `/wr-risk-scorer:assess-release` first to produce a fresh pipeline marker, then retry the commit.
|
|
171
|
+
|
|
172
|
+
$ARGUMENTS
|