@windyroad/risk-scorer 0.2.0
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 +5 -0
- package/agents/agent.md +21 -0
- package/agents/pipeline.md +152 -0
- package/agents/plan.md +85 -0
- package/agents/policy.md +43 -0
- package/agents/wip.md +79 -0
- package/bin/install.mjs +42 -0
- package/hooks/git-push-gate.sh +117 -0
- package/hooks/hooks.json +24 -0
- package/hooks/lib/gate-helpers.sh +174 -0
- package/hooks/lib/pipeline-state.sh +318 -0
- package/hooks/lib/risk-gate.sh +85 -0
- package/hooks/plan-risk-guidance.sh +52 -0
- package/hooks/risk-hash-refresh.sh +28 -0
- package/hooks/risk-policy-enforce-edit.sh +42 -0
- package/hooks/risk-policy-reset-marker.sh +17 -0
- package/hooks/risk-score-commit-gate.sh +64 -0
- package/hooks/risk-score-mark.sh +120 -0
- package/hooks/risk-score-plan-enforce.sh +31 -0
- package/hooks/risk-score-reset.sh +17 -0
- package/hooks/risk-score.sh +29 -0
- package/hooks/secret-leak-gate.sh +72 -0
- package/hooks/test/risk-gate.bats +107 -0
- package/hooks/wip-risk-gate.sh +44 -0
- package/hooks/wip-risk-mark.sh +32 -0
- package/package.json +28 -0
- package/skills/wr:risk-policy/SKILL.md +178 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: Denies Edit/Write to RISK-POLICY.md unless the
|
|
3
|
+
# /risk-policy skill has been engaged (marker file exists).
|
|
4
|
+
# Mirrors: architect-enforce-edit.sh
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
10
|
+
_enable_err_trap
|
|
11
|
+
|
|
12
|
+
_parse_input
|
|
13
|
+
|
|
14
|
+
FILE_PATH=$(_get_file_path)
|
|
15
|
+
SESSION_ID=$(_get_session_id)
|
|
16
|
+
|
|
17
|
+
if [ -z "$SESSION_ID" ] || [ -z "$FILE_PATH" ]; then
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Only gate RISK-POLICY.md
|
|
22
|
+
BASENAME=$(basename "$FILE_PATH")
|
|
23
|
+
if [ "$BASENAME" != "RISK-POLICY.md" ]; then
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Check for marker
|
|
28
|
+
MARKER="$(_risk_dir "$SESSION_ID")/policy-reviewed"
|
|
29
|
+
if [ -f "$MARKER" ]; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
cat <<'EOF'
|
|
34
|
+
{
|
|
35
|
+
"hookSpecificOutput": {
|
|
36
|
+
"hookEventName": "PreToolUse",
|
|
37
|
+
"permissionDecision": "deny",
|
|
38
|
+
"permissionDecisionReason": "BLOCKED: Cannot edit RISK-POLICY.md directly. Run the /risk-policy skill first -- it enforces ISO 31000 compliance (reads the risk-scorer contract, discovers project context, checks for incidents, validates with you, and smoke-tests the result). Use the Skill tool with skill: \"risk-policy\"."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
EOF
|
|
42
|
+
exit 0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop hook: Clears risk-policy session marker.
|
|
3
|
+
# Mirrors: architect-reset-marker.sh
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
7
|
+
|
|
8
|
+
_parse_input
|
|
9
|
+
|
|
10
|
+
SESSION_ID=$(_get_session_id)
|
|
11
|
+
|
|
12
|
+
if [ -n "$SESSION_ID" ]; then
|
|
13
|
+
RDIR=$(_risk_dir "$SESSION_ID")
|
|
14
|
+
rm -f "${RDIR}/policy-reviewed" "${RDIR}/plan-reviewed"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
exit 0
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: Denies git commit when risk policy is stale,
|
|
3
|
+
# commit risk score is missing/expired/drifted/above threshold.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
source "$SCRIPT_DIR/lib/risk-gate.sh"
|
|
9
|
+
_enable_err_trap
|
|
10
|
+
|
|
11
|
+
_parse_input
|
|
12
|
+
|
|
13
|
+
TOOL_NAME=$(_get_tool_name)
|
|
14
|
+
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
|
15
|
+
|
|
16
|
+
COMMAND=$(_get_command)
|
|
17
|
+
echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git commit' || exit 0
|
|
18
|
+
|
|
19
|
+
SESSION_ID=$(_get_session_id)
|
|
20
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
21
|
+
|
|
22
|
+
# RISK-POLICY.md must exist and not be stale (>14 days)
|
|
23
|
+
if [ ! -f "RISK-POLICY.md" ] || [ ! -s "RISK-POLICY.md" ]; then
|
|
24
|
+
risk_gate_deny "Commit blocked: RISK-POLICY.md is missing. Run /risk-policy to create it before committing."
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
POLICY_STALE=$(python3 -c "
|
|
28
|
+
from datetime import date
|
|
29
|
+
import re
|
|
30
|
+
try:
|
|
31
|
+
text = open('RISK-POLICY.md').read()
|
|
32
|
+
m = re.search(r'Last reviewed:\*{0,2}\s*(\d{4}-\d{2}-\d{2})', text)
|
|
33
|
+
if m:
|
|
34
|
+
reviewed = date.fromisoformat(m.group(1))
|
|
35
|
+
print('yes' if (date.today() - reviewed).days > 14 else 'no')
|
|
36
|
+
else:
|
|
37
|
+
print('no')
|
|
38
|
+
except:
|
|
39
|
+
print('no')
|
|
40
|
+
" 2>/dev/null || echo "no")
|
|
41
|
+
if [ "$POLICY_STALE" = "yes" ]; then
|
|
42
|
+
risk_gate_deny "Commit blocked: RISK-POLICY.md is stale (last reviewed over 2 weeks ago). Run /risk-policy to update it before committing."
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Clean tree bypass
|
|
47
|
+
RDIR=$(_risk_dir "$SESSION_ID")
|
|
48
|
+
if [ -f "${RDIR}/clean" ]; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Risk-reducing/neutral bypass
|
|
53
|
+
if [ -f "${RDIR}/reducing-commit" ]; then
|
|
54
|
+
rm -f "${RDIR}/reducing-commit"
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Gate check: existence, TTL, drift, threshold
|
|
59
|
+
if ! check_risk_gate "$SESSION_ID" "commit"; then
|
|
60
|
+
risk_gate_deny "Commit blocked: ${RISK_GATE_REASON} To proceed: (1) stage files with git add, (2) delegate to risk-scorer-pipeline (subagent_type: 'risk-scorer-pipeline') to assess cumulative pipeline risk. If the commit is risk-neutral or risk-reducing, the scorer will create a bypass marker."
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
exit 0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse:Agent hook: Deterministically writes all risk score files,
|
|
3
|
+
# verdict markers, and bypass markers by parsing structured output from
|
|
4
|
+
# risk-scorer agents. This is the ONLY place score files are written —
|
|
5
|
+
# agents output structured markers, this hook writes the files.
|
|
6
|
+
#
|
|
7
|
+
# Handles: risk-scorer-pipeline, risk-scorer-plan, risk-scorer-wip, risk-scorer-policy
|
|
8
|
+
# Replaces: risk-policy-mark-reviewed.sh (which had fragile P001 backup parsing)
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
13
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
14
|
+
_enable_err_trap
|
|
15
|
+
|
|
16
|
+
_parse_input
|
|
17
|
+
|
|
18
|
+
TOOL_NAME=$(_get_tool_name)
|
|
19
|
+
[ "$TOOL_NAME" = "Agent" ] || exit 0
|
|
20
|
+
|
|
21
|
+
SUBAGENT=$(_get_subagent_type)
|
|
22
|
+
SESSION_ID=$(_get_session_id)
|
|
23
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
24
|
+
|
|
25
|
+
# Only handle risk-scorer agents
|
|
26
|
+
case "$SUBAGENT" in
|
|
27
|
+
*risk-scorer*) ;;
|
|
28
|
+
*) exit 0 ;;
|
|
29
|
+
esac
|
|
30
|
+
|
|
31
|
+
AGENT_OUTPUT=$(_get_tool_output)
|
|
32
|
+
RDIR=$(_risk_dir "$SESSION_ID")
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Pipeline scorer: write commit/push/release scores + bypass markers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
if echo "$SUBAGENT" | grep -qE 'risk-scorer-pipeline'; then
|
|
38
|
+
# Parse RISK_SCORES: commit=N push=N release=N
|
|
39
|
+
SCORES_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_SCORES:' | tail -1) || true
|
|
40
|
+
if [ -n "$SCORES_LINE" ]; then
|
|
41
|
+
COMMIT=$(echo "$SCORES_LINE" | grep -oE 'commit=[0-9]+' | cut -d= -f2) || true
|
|
42
|
+
PUSH=$(echo "$SCORES_LINE" | grep -oE 'push=[0-9]+' | cut -d= -f2) || true
|
|
43
|
+
RELEASE=$(echo "$SCORES_LINE" | grep -oE 'release=[0-9]+' | cut -d= -f2) || true
|
|
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"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Parse RISK_BYPASS: reducing|incident
|
|
51
|
+
BYPASS_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_BYPASS:' | tail -1) || true
|
|
52
|
+
if [ -n "$BYPASS_LINE" ]; then
|
|
53
|
+
BYPASS_TYPE=$(echo "$BYPASS_LINE" | sed 's/^RISK_BYPASS:[[:space:]]*//' | tr -d '[:space:]')
|
|
54
|
+
case "$BYPASS_TYPE" in
|
|
55
|
+
reducing)
|
|
56
|
+
touch "${RDIR}/reducing-commit"
|
|
57
|
+
touch "${RDIR}/reducing-push"
|
|
58
|
+
touch "${RDIR}/reducing-release"
|
|
59
|
+
;;
|
|
60
|
+
incident)
|
|
61
|
+
touch "${RDIR}/incident-release"
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# Refresh pipeline state hash so drift detection matches scoring time
|
|
67
|
+
CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
|
|
68
|
+
if [ -n "$CURRENT_HASH" ]; then
|
|
69
|
+
echo "$CURRENT_HASH" > "${RDIR}/state-hash"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# Save report to .risk-reports/
|
|
73
|
+
REPORT_DIR=".risk-reports"
|
|
74
|
+
mkdir -p "$REPORT_DIR"
|
|
75
|
+
TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M-%S)
|
|
76
|
+
echo "$AGENT_OUTPUT" > "${REPORT_DIR}/${TIMESTAMP}-commit.md"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Plan scorer: write plan-reviewed marker on PASS
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
if echo "$SUBAGENT" | grep -qE 'risk-scorer-plan'; then
|
|
83
|
+
VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_VERDICT:' | tail -1) || true
|
|
84
|
+
VERDICT=$(echo "$VERDICT_LINE" | sed 's/^RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
|
|
85
|
+
case "$VERDICT" in
|
|
86
|
+
PASS) touch "${RDIR}/plan-reviewed" ;;
|
|
87
|
+
FAIL) ;; # Do NOT create marker — plan must be revised
|
|
88
|
+
*) ;; # Unknown verdict — fail closed
|
|
89
|
+
esac
|
|
90
|
+
|
|
91
|
+
# Refresh pipeline state hash
|
|
92
|
+
CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
|
|
93
|
+
if [ -n "$CURRENT_HASH" ]; then
|
|
94
|
+
echo "$CURRENT_HASH" > "${RDIR}/state-hash"
|
|
95
|
+
fi
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# WIP scorer: write wip-reviewed marker (unblocks next edit)
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
if echo "$SUBAGENT" | grep -qE 'risk-scorer-wip'; then
|
|
102
|
+
# WIP assessment was done — unblock next edit regardless of CONTINUE/PAUSE
|
|
103
|
+
# (PAUSE is advisory guidance to the user, not a hard gate)
|
|
104
|
+
touch "${RDIR}/wip-reviewed"
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Policy scorer: write policy-reviewed marker on PASS
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
if echo "$SUBAGENT" | grep -qE 'risk-scorer-policy'; then
|
|
111
|
+
VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_VERDICT:' | tail -1) || true
|
|
112
|
+
VERDICT=$(echo "$VERDICT_LINE" | sed 's/^RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
|
|
113
|
+
case "$VERDICT" in
|
|
114
|
+
PASS) touch "${RDIR}/policy-reviewed" ;;
|
|
115
|
+
FAIL) ;; # Do NOT create marker — policy must be revised
|
|
116
|
+
*) ;; # Unknown verdict — fail closed
|
|
117
|
+
esac
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
exit 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: Denies ExitPlanMode until risk-scorer has reviewed
|
|
3
|
+
# the plan and given PASS. Mirrors architect-plan-enforce.sh pattern.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
9
|
+
_enable_err_trap
|
|
10
|
+
|
|
11
|
+
_parse_input
|
|
12
|
+
|
|
13
|
+
SESSION_ID=$(_get_session_id)
|
|
14
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
15
|
+
|
|
16
|
+
# Check for risk plan review marker
|
|
17
|
+
MARKER="$(_risk_dir "$SESSION_ID")/plan-reviewed"
|
|
18
|
+
if [ -f "$MARKER" ]; then
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
cat <<'EOF'
|
|
23
|
+
{
|
|
24
|
+
"hookSpecificOutput": {
|
|
25
|
+
"hookEventName": "PreToolUse",
|
|
26
|
+
"permissionDecision": "deny",
|
|
27
|
+
"permissionDecisionReason": "BLOCKED: Risk-scorer must review the plan before exiting plan mode. Delegate to risk-scorer-plan (subagent_type: 'risk-scorer-plan') to review the plan file for risk, including projected release risk."
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
EOF
|
|
31
|
+
exit 0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop hook: Clears risk score temp files on session end.
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
6
|
+
|
|
7
|
+
_parse_input
|
|
8
|
+
|
|
9
|
+
SESSION_ID=$(_get_session_id)
|
|
10
|
+
|
|
11
|
+
if [ -n "$SESSION_ID" ]; then
|
|
12
|
+
# Remove the entire session-scoped directory
|
|
13
|
+
RDIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
14
|
+
rm -rf "$RDIR"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
exit 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UserPromptSubmit hook: No-op.
|
|
3
|
+
# Risk scoring is triggered by Edit/Write (WIP nudge gate) and gated actions
|
|
4
|
+
# (commit/push/release gates). This hook is retained only for the session
|
|
5
|
+
# marker lifecycle — creating the WIP marker so the first edit isn't blocked.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
11
|
+
|
|
12
|
+
_parse_input
|
|
13
|
+
|
|
14
|
+
SESSION_ID=$(_get_session_id)
|
|
15
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
16
|
+
|
|
17
|
+
# Create WIP marker so first edit of the session isn't blocked
|
|
18
|
+
RDIR=$(_risk_dir "$SESSION_ID")
|
|
19
|
+
WIP_MARKER="${RDIR}/wip-reviewed"
|
|
20
|
+
if [ ! -f "$WIP_MARKER" ]; then
|
|
21
|
+
touch "$WIP_MARKER"
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# Rotate old risk reports (keep last 7 days)
|
|
25
|
+
if [ -d ".risk-reports" ]; then
|
|
26
|
+
find .risk-reports -name '*.md' -mtime +7 -delete 2>/dev/null || true
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
exit 0
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: Blocks Edit/Write if content contains secret patterns.
|
|
3
|
+
# Mitigates WR-R2 (Secret leakage risk).
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
|
|
9
|
+
# Extract the content being written — check both "new_string" (Edit) and "content" (Write)
|
|
10
|
+
CONTENT=$(echo "$INPUT" | python3 -c "
|
|
11
|
+
import sys, json
|
|
12
|
+
try:
|
|
13
|
+
data = json.load(sys.stdin)
|
|
14
|
+
tool_input = data.get('tool_input', {})
|
|
15
|
+
text = tool_input.get('new_string', '') + tool_input.get('content', '')
|
|
16
|
+
print(text)
|
|
17
|
+
except:
|
|
18
|
+
print('')
|
|
19
|
+
" 2>/dev/null || echo "")
|
|
20
|
+
|
|
21
|
+
if [ -z "$CONTENT" ]; then
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Check for secret patterns
|
|
26
|
+
MATCHED=""
|
|
27
|
+
|
|
28
|
+
# AWS access keys
|
|
29
|
+
if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
|
|
30
|
+
MATCHED="AWS access key"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Private keys
|
|
34
|
+
if echo "$CONTENT" | grep -qE 'BEGIN[[:space:]]+(RSA|DSA|EC|OPENSSH|PGP)?[[:space:]]*PRIVATE KEY'; then
|
|
35
|
+
MATCHED="private key"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# GitHub tokens
|
|
39
|
+
if echo "$CONTENT" | grep -qE 'gh[pousr]_[A-Za-z0-9_]{36,}'; then
|
|
40
|
+
MATCHED="GitHub token"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Generic API key/secret/token assignments with actual values (not variable references)
|
|
44
|
+
if echo "$CONTENT" | grep -qEi '(api_key|api_secret|auth_key|auth_token|secret_key)[[:space:]]*[=:][[:space:]]*["\x27][A-Za-z0-9+/=_-]{16,}'; then
|
|
45
|
+
MATCHED="API key/secret assignment"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Cloudflare auth key pattern (specific to this project's legacy scripts)
|
|
49
|
+
if echo "$CONTENT" | grep -qE 'X-Auth-Key:[[:space:]]*[A-Za-z0-9]{30,}'; then
|
|
50
|
+
MATCHED="Cloudflare auth key"
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Netlify auth token
|
|
54
|
+
if echo "$CONTENT" | grep -qE 'NETLIFY_AUTH_TOKEN[[:space:]]*[=:][[:space:]]*["\x27][A-Za-z0-9_-]{16,}'; then
|
|
55
|
+
MATCHED="Netlify auth token"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if [ -n "$MATCHED" ]; then
|
|
59
|
+
cat <<EOF
|
|
60
|
+
{
|
|
61
|
+
"hookSpecificOutput": {
|
|
62
|
+
"hookEventName": "PreToolUse",
|
|
63
|
+
"permissionDecision": "deny",
|
|
64
|
+
"permissionDecisionReason": "BLOCKED (WR-R2): Detected probable $MATCHED in file content. Do not write secrets to files — use environment variables or CI secrets instead."
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
EOF
|
|
68
|
+
exit 0
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# No secrets detected — allow
|
|
72
|
+
exit 0
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
# Tests for .claude/hooks/lib/risk-gate.sh
|
|
3
|
+
|
|
4
|
+
setup() {
|
|
5
|
+
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
6
|
+
source "$HOOKS_DIR/lib/gate-helpers.sh"
|
|
7
|
+
source "$HOOKS_DIR/lib/risk-gate.sh"
|
|
8
|
+
|
|
9
|
+
TEST_SESSION="bats-test-$$-${BATS_TEST_NUMBER}"
|
|
10
|
+
RDIR=$(_risk_dir "$TEST_SESSION")
|
|
11
|
+
SCORE_FILE="${RDIR}/commit"
|
|
12
|
+
HASH_FILE="${RDIR}/state-hash"
|
|
13
|
+
|
|
14
|
+
export RISK_TTL=5
|
|
15
|
+
rm -f "$SCORE_FILE" "$HASH_FILE"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
teardown() {
|
|
19
|
+
rm -rf "${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Helper: call check_risk_gate directly (not via run) so RISK_GATE_REASON is visible
|
|
23
|
+
assert_gate_denies() {
|
|
24
|
+
local session="$1" action="$2" expected_reason="$3"
|
|
25
|
+
RISK_GATE_REASON=""
|
|
26
|
+
if check_risk_gate "$session" "$action"; then
|
|
27
|
+
echo "Expected gate to deny but it allowed"
|
|
28
|
+
return 1
|
|
29
|
+
fi
|
|
30
|
+
if [[ "$RISK_GATE_REASON" != *"$expected_reason"* ]]; then
|
|
31
|
+
echo "Expected reason to contain '$expected_reason' but got: $RISK_GATE_REASON"
|
|
32
|
+
return 1
|
|
33
|
+
fi
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
assert_gate_allows() {
|
|
37
|
+
local session="$1" action="$2"
|
|
38
|
+
if ! check_risk_gate "$session" "$action"; then
|
|
39
|
+
echo "Expected gate to allow but it denied: $RISK_GATE_REASON"
|
|
40
|
+
return 1
|
|
41
|
+
fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@test "missing score file denies" {
|
|
45
|
+
assert_gate_denies "$TEST_SESSION" "commit" "No commit risk score found"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@test "score file with PENDING denies (non-numeric)" {
|
|
49
|
+
printf 'PENDING' > "$SCORE_FILE"
|
|
50
|
+
assert_gate_denies "$TEST_SESSION" "commit" "invalid value"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@test "score 4 allows (below threshold)" {
|
|
54
|
+
printf '4' > "$SCORE_FILE"
|
|
55
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@test "score 5 denies (at threshold)" {
|
|
59
|
+
printf '5' > "$SCORE_FILE"
|
|
60
|
+
assert_gate_denies "$TEST_SESSION" "commit" "5/25"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@test "score 8 denies (above threshold)" {
|
|
64
|
+
printf '8' > "$SCORE_FILE"
|
|
65
|
+
assert_gate_denies "$TEST_SESSION" "commit" "8/25"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@test "score 1 allows (very low)" {
|
|
69
|
+
printf '1' > "$SCORE_FILE"
|
|
70
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@test "expired score file denies" {
|
|
74
|
+
printf '3' > "$SCORE_FILE"
|
|
75
|
+
# Backdate mtime by 10 seconds (TTL is 5)
|
|
76
|
+
touch -t "$(date -v-10S +%Y%m%d%H%M.%S 2>/dev/null || date -d '10 seconds ago' +%Y%m%d%H%M.%S 2>/dev/null)" "$SCORE_FILE"
|
|
77
|
+
assert_gate_denies "$TEST_SESSION" "commit" "expired"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@test "fresh score file allows" {
|
|
81
|
+
printf '3' > "$SCORE_FILE"
|
|
82
|
+
touch "$SCORE_FILE"
|
|
83
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@test "drift detection: hash mismatch denies" {
|
|
87
|
+
printf '3' > "$SCORE_FILE"
|
|
88
|
+
touch "$SCORE_FILE"
|
|
89
|
+
echo "oldhash123" > "$HASH_FILE"
|
|
90
|
+
assert_gate_denies "$TEST_SESSION" "commit" "drift"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@test "no hash file skips drift check (backwards compat)" {
|
|
94
|
+
printf '3' > "$SCORE_FILE"
|
|
95
|
+
touch "$SCORE_FILE"
|
|
96
|
+
rm -f "$HASH_FILE"
|
|
97
|
+
assert_gate_allows "$TEST_SESSION" "commit"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@test "risk_gate_deny outputs valid JSON" {
|
|
101
|
+
run risk_gate_deny "Test reason"
|
|
102
|
+
[ "$status" -eq 0 ]
|
|
103
|
+
echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" 2>/dev/null
|
|
104
|
+
[[ "$output" == *"permissionDecision"* ]]
|
|
105
|
+
[[ "$output" == *"deny"* ]]
|
|
106
|
+
[[ "$output" == *"Test reason"* ]]
|
|
107
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: Blocks Edit/Write on non-doc files until WIP risk
|
|
3
|
+
# assessment has been completed by the risk-scorer in WIP nudge mode.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
9
|
+
_enable_err_trap
|
|
10
|
+
|
|
11
|
+
_parse_input
|
|
12
|
+
|
|
13
|
+
TOOL_NAME=$(_get_tool_name)
|
|
14
|
+
case "$TOOL_NAME" in
|
|
15
|
+
Edit|Write) ;;
|
|
16
|
+
*) exit 0 ;;
|
|
17
|
+
esac
|
|
18
|
+
|
|
19
|
+
FILE_PATH=$(_get_file_path)
|
|
20
|
+
[ -n "$FILE_PATH" ] || exit 0
|
|
21
|
+
|
|
22
|
+
# Skip doc/governance files
|
|
23
|
+
if _is_doc_file "$FILE_PATH"; then
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
SESSION_ID=$(_get_session_id)
|
|
28
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
29
|
+
|
|
30
|
+
MARKER="$(_risk_dir "$SESSION_ID")/wip-reviewed"
|
|
31
|
+
if [ -f "$MARKER" ]; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
cat <<'EOF'
|
|
36
|
+
{
|
|
37
|
+
"hookSpecificOutput": {
|
|
38
|
+
"hookEventName": "PreToolUse",
|
|
39
|
+
"permissionDecision": "deny",
|
|
40
|
+
"permissionDecisionReason": "WIP risk assessment required. Delegate to risk-scorer-wip (subagent_type: 'risk-scorer-wip') to assess cumulative pipeline risk for changes so far."
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
EOF
|
|
44
|
+
exit 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse hook: Manages the WIP-reviewed marker.
|
|
3
|
+
# - After Edit/Write on non-doc files: clears the marker (blocks next edit)
|
|
4
|
+
# - After Agent (risk-scorer) completion: creates the marker (unblocks next edit)
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
source "$SCRIPT_DIR/lib/gate-helpers.sh"
|
|
10
|
+
_enable_err_trap
|
|
11
|
+
|
|
12
|
+
_parse_input
|
|
13
|
+
|
|
14
|
+
TOOL_NAME=$(_get_tool_name)
|
|
15
|
+
SESSION_ID=$(_get_session_id)
|
|
16
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
17
|
+
|
|
18
|
+
MARKER="$(_risk_dir "$SESSION_ID")/wip-reviewed"
|
|
19
|
+
|
|
20
|
+
case "$TOOL_NAME" in
|
|
21
|
+
Edit|Write)
|
|
22
|
+
FILE_PATH=$(_get_file_path)
|
|
23
|
+
[ -n "$FILE_PATH" ] || exit 0
|
|
24
|
+
|
|
25
|
+
if ! _is_doc_file "$FILE_PATH"; then
|
|
26
|
+
rm -f "$MARKER"
|
|
27
|
+
fi
|
|
28
|
+
;;
|
|
29
|
+
# Agent case handled by risk-score-mark.sh
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
exit 0
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@windyroad/risk-scorer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
|
|
5
|
+
"bin": {
|
|
6
|
+
"windyroad-risk-scorer": "./bin/install.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/windyroad/agent-plugins.git",
|
|
13
|
+
"directory": "packages/risk-scorer"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"claude-code-plugin",
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"ai-coding"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/",
|
|
23
|
+
"agents/",
|
|
24
|
+
"hooks/",
|
|
25
|
+
"skills/",
|
|
26
|
+
".claude-plugin/"
|
|
27
|
+
]
|
|
28
|
+
}
|