@windyroad/risk-scorer 0.4.1-preview.208 → 0.4.1-preview.212

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/README.md CHANGED
@@ -13,6 +13,7 @@ The risk-scorer plugin brings ISO 31000-aligned risk management to your AI codin
13
13
  3. **Gates pushes** -- blocks `git push` for high-risk changesets (use `npm run push:watch` instead)
14
14
  4. **Detects secrets** -- scans edits for API keys, tokens, passwords, and other credentials before they're written
15
15
  5. **Reviews plans** -- scores implementation plans for risk before you start building
16
+ 6. **Gates outbound prose** -- reviews `gh issue/pr` bodies, security advisories, npm publish content, and `.changeset/*.md` drafts for confidential-information leaks before they reach external surfaces
16
17
 
17
18
  All thresholds are configurable through your project's `RISK-POLICY.md`.
18
19
 
@@ -46,13 +47,14 @@ This creates a `RISK-POLICY.md` tailored to your project, defining impact levels
46
47
  | `risk-score-commit-gate.sh` | Bash (git commit) | Blocks commits when risk exceeds threshold |
47
48
  | `risk-score-plan-enforce.sh` | ExitPlanMode | Ensures plans are risk-scored before execution |
48
49
  | `plan-risk-guidance.sh` | EnterPlanMode | Injects risk guidance into plan mode |
50
+ | `external-comms-gate.sh` | Bash, Edit, Write | Gates outbound prose (`gh issue/pr`, `gh api .../security-advisories`, `npm publish`, `.changeset/*.md`) on confidential-information leak review |
49
51
  | `wip-risk-mark.sh` | After edit | Records WIP risk assessment |
50
- | `risk-score-mark.sh` | Agent completes | Marks risk review as done |
52
+ | `risk-score-mark.sh` | Agent completes | Marks risk review as done; writes external-comms marker on `wr-risk-scorer:external-comms` PASS |
51
53
  | `risk-hash-refresh.sh` | After Bash | Refreshes content hashes |
52
54
 
53
55
  ## Agents
54
56
 
55
- The plugin includes five specialised agents:
57
+ The plugin includes six specialised agents:
56
58
 
57
59
  | Agent | Purpose |
58
60
  |-------|---------|
@@ -61,6 +63,48 @@ The plugin includes five specialised agents:
61
63
  | `wr-risk-scorer:pipeline` | Scores pipeline actions (commit, push, release) |
62
64
  | `wr-risk-scorer:plan` | Reviews implementation plans for risk |
63
65
  | `wr-risk-scorer:policy` | Validates `RISK-POLICY.md` for ISO 31000 compliance |
66
+ | `wr-risk-scorer:external-comms` | Reviews drafts of outbound prose (gh issues/PRs, advisories, npm publish, changeset bodies) for confidential-information leaks per `RISK-POLICY.md` |
67
+
68
+ ## On-demand assessment skills
69
+
70
+ | Skill | Purpose |
71
+ |-------|---------|
72
+ | `/wr-risk-scorer:assess-wip` | WIP risk nudge for the current uncommitted diff |
73
+ | `/wr-risk-scorer:assess-release` | Pipeline risk assessment for the unpushed queue (pre-satisfies the commit gate) |
74
+ | `/wr-risk-scorer:assess-external-comms` | External-comms leak review for a draft outbound body (pre-satisfies the external-comms gate) |
75
+ | `/wr-risk-scorer:create-risk` | Create a standing-risk register entry |
76
+ | `/wr-risk-scorer:update-policy` | Generate or update `RISK-POLICY.md` |
77
+
78
+ ## External-comms gate
79
+
80
+ The `external-comms-gate.sh` hook intercepts outbound prose tool calls and the
81
+ `.changeset/*.md` author surface so confidential-information leaks are caught
82
+ before they reach a public or vendor-private channel.
83
+
84
+ Gated surfaces:
85
+ - `gh issue create` / `gh issue comment` / `gh issue edit`
86
+ - `gh pr create` / `gh pr comment` / `gh pr edit`
87
+ - `gh api .../security-advisories` and `gh api .../comments`
88
+ - `npm publish`
89
+ - `PreToolUse:Write` and `PreToolUse:Edit` on `.changeset/*.md` (P073 — gated at author time, before the changeset body lands in CHANGELOG.md and every published npm tarball)
90
+
91
+ Behaviour:
92
+ 1. A hybrid regex pre-filter (`hooks/lib/leak-detect.sh`) catches high-confidence
93
+ leak shapes (credentials, business-context-paired financial figures and
94
+ user-counts) and denies immediately with the matched class.
95
+ 2. Anything not pre-filtered is delegated to the `wr-risk-scorer:external-comms`
96
+ subagent for context-aware review against `RISK-POLICY.md` Confidential
97
+ Information classes. The PostToolUse marker hook writes a per-draft marker
98
+ on `EXTERNAL_COMMS_RISK_VERDICT: PASS`.
99
+ 3. If `RISK-POLICY.md` is absent, the gate runs in advisory-only mode and
100
+ permits the call (graceful adoption).
101
+
102
+ Override: `BYPASS_RISK_GATE=1` short-circuits the gate (consistent with
103
+ `git-push-gate.sh`). Reserved for cases the user has confirmed safe.
104
+
105
+ The canonical hook lives at `packages/shared/hooks/external-comms-gate.sh` and
106
+ is synced into each consumer plugin via `scripts/sync-external-comms-gate.sh`
107
+ per ADR-017 (CI runs `npm run check:external-comms-gate` to detect drift).
64
108
 
65
109
  ## Updating and Uninstalling
66
110
 
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: external-comms
3
+ description: Reviews drafts of external-facing prose (gh issues / PRs / advisories, npm publish content, .changeset/*.md bodies) for confidential-information leaks per RISK-POLICY.md. Read-only — emits a structured PASS/FAIL verdict consumed by the external-comms-gate marker hook.
4
+ tools:
5
+ - Read
6
+ - Glob
7
+ - Grep
8
+ model: inherit
9
+ ---
10
+
11
+ You are the External-Comms Risk Reviewer. Your single job: read the draft of an outbound prose tool call (a `gh issue create --body ...`, a PR description, a security-advisory body, a `.changeset/*.md` file, or the README diff that `npm publish` will publish) and return a structured PASS/FAIL verdict against RISK-POLICY.md's Confidential Information classes.
12
+
13
+ You are read-only. You do NOT write files, do NOT commit, do NOT modify the draft. Your verdict is consumed by the `risk-score-mark.sh` PostToolUse hook (P064 / ADR-028 amended), which writes the marker that allows the gated tool call to proceed.
14
+
15
+ ## What you receive
16
+
17
+ The invoking skill (`/wr-risk-scorer:assess-external-comms`) or the agent that hit the gate provides:
18
+
19
+ - The **draft body** verbatim — the exact prose that would land on the external surface.
20
+ - The **target surface** — one of: `gh-issue-create`, `gh-issue-comment`, `gh-issue-edit`, `gh-pr-create`, `gh-pr-comment`, `gh-pr-edit`, `gh-api-security-advisories`, `gh-api-comments`, `npm-publish`, `changeset-author`.
21
+ - The **destination** when known (e.g. `anthropics/claude-code#52831`).
22
+
23
+ Read `RISK-POLICY.md` (project root) to get the authoritative Confidential Information class list. As of P064 it covers:
24
+
25
+ - Client names, project names, engagement details
26
+ - Revenue figures, pricing, financial metrics
27
+ - User counts, download statistics, traffic volumes
28
+ - Internal business strategy or roadmap details
29
+
30
+ The hybrid pre-filter (`packages/*/hooks/lib/leak-detect.sh`) has already caught HIGH-CONFIDENCE shapes (credentials, business-context-paired financial figures and user counts). If the gate denied with a hard-fail reason, the draft did NOT reach you. Your job is the AMBIGUOUS prose layer: text that mentions clients-by-paraphrase, hints at internal architecture, names embargoed product features, or quotes prod URL fragments, where context decides whether it is a leak.
31
+
32
+ ## Review process
33
+
34
+ 1. **Read the draft and the surface**. The surface determines the audience: `gh-api-security-advisories` lands on a vendor private channel; `gh-issue-create` lands publicly on a third-party repo; `npm-publish` lands as a permanently-published artefact. The same content may be safe for one surface and a leak on another.
35
+ 2. **Read RISK-POLICY.md** to ground every finding against the named class. Do not invent classes; do not score by analogy if the policy already names a class that fits.
36
+ 3. **Pass each Confidential Information class against the draft**. For each match, note the specific substring + the policy class it violates.
37
+ 4. **Apply context-aware judgement**:
38
+ - A package name (`@windyroad/itil`) is fine to mention; an internal *codename* for an unreleased product is not.
39
+ - A generic test failure description (`Node 20 build broke`) is fine; a description that quotes prod-environment hostnames or internal-staging-URL fragments is a leak.
40
+ - A user-count figure surrounded by marketing context (an existing public press release sentence) is not new information; the same figure newly disclosed here would be a leak.
41
+ - A `.changeset/*.md` body lands in CHANGELOG.md, the Release PR body, the GitHub Release page, AND every published npm tarball. Treat it as the highest-exposure surface; mistakes here are durable across every publishing artefact (P073).
42
+
43
+ ## Verdict format (MANDATORY)
44
+
45
+ End your report with a structured block consumed by `risk-score-mark.sh`. Every field is required.
46
+
47
+ ```
48
+ EXTERNAL_COMMS_RISK_VERDICT: PASS
49
+ EXTERNAL_COMMS_RISK_KEY: <sha256 hex string>
50
+ ```
51
+
52
+ OR for a failed review:
53
+
54
+ ```
55
+ EXTERNAL_COMMS_RISK_VERDICT: FAIL
56
+ EXTERNAL_COMMS_RISK_KEY: <sha256 hex string>
57
+ EXTERNAL_COMMS_RISK_REASON: <one-line description of the leak class + matched fragment>
58
+ ```
59
+
60
+ Compute the key as:
61
+
62
+ ```
63
+ printf '%s\n%s' "<draft body verbatim>" "<surface name>" | shasum -a 256 | cut -d' ' -f1
64
+ ```
65
+
66
+ The key MUST match the gate's computation exactly — a key mismatch means the marker is written for a different draft and the original gated call will continue to deny.
67
+
68
+ ## Grounding (ADR-026)
69
+
70
+ Every FAIL verdict MUST cite:
71
+
72
+ - The specific RISK-POLICY.md class violated (verbatim — copy the bullet from the policy).
73
+ - The exact substring from the draft that triggered the call.
74
+ - A one-line explanation of why this combination of surface + content constitutes a leak.
75
+
76
+ Example:
77
+
78
+ > EXTERNAL_COMMS_RISK_REASON: "Client names" class — draft contains "Acme Corp" naming a paying engagement; gh-issue-create on a public third-party repo would publicly disclose the client relationship.
79
+
80
+ ## Constraints
81
+
82
+ - You are a reviewer, not an editor — do NOT propose rewrites in the verdict block. (Free prose suggestions outside the verdict block are fine and helpful.)
83
+ - Do NOT score by analogy when the policy names the class.
84
+ - Do NOT write to `/tmp/` or any marker location yourself — the PostToolUse hook owns that.
85
+ - Do NOT skip the `EXTERNAL_COMMS_RISK_KEY` line; without it, the marker hook has no key to write the marker against and the gate will deny again on retry.
86
+ - When the draft is empty (e.g. `npm publish` with no extractable body fragment), review the staged content the publish would push (README diff, package.json description) instead. If neither is available, FAIL with reason "draft body unresolvable; cannot risk-review without text" so the user can pre-review manually.
87
+
88
+ ## Below-Appetite Output Rule (ADR-013 Rule 5)
89
+
90
+ When the verdict is PASS and no Confidential Information class matched, your output may be terse: a one-line "no Confidential Information class matched" plus the verdict block. Do not pad with advisory prose; policy-authorised drafts proceed silently.
91
+
92
+ ## Above-Appetite (FAIL) Output
93
+
94
+ When the verdict is FAIL, surface remediation suggestions in PROSE BEFORE the verdict block — what specific substrings to redact, paraphrase, or move to a private channel. The verdict block itself stays structured and machine-parseable.
@@ -0,0 +1,203 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: gates outbound prose for risk/leak review (P064 / ADR-028 amended).
3
+ #
4
+ # Surface (matched on Bash command text or Edit/Write file_path):
5
+ # - gh issue create | comment | edit (public issue bodies)
6
+ # - gh pr create | comment | edit (public PR bodies)
7
+ # - gh api .../security-advisories (advisory drafts)
8
+ # - gh api .../comments (any REST surface accepting prose)
9
+ # - npm publish (README / package metadata to npm)
10
+ # - PreToolUse:Write|Edit on .changeset/*.md (P073 — gates author-time)
11
+ #
12
+ # Gate behaviour:
13
+ # 1. BYPASS_RISK_GATE=1 short-circuits the gate (consistent with git-push-gate.sh).
14
+ # 2. RISK-POLICY.md absent → advisory-only mode (permits with systemMessage).
15
+ # 3. Hybrid leak-pattern pre-filter (lib/leak-detect.sh) hard-fails on
16
+ # credentials, prod-URL prefixes, business-context-paired financial figures,
17
+ # or business-context-paired user counts. Deny includes the matched class.
18
+ # 4. Otherwise: check for a per-evaluator marker keyed on
19
+ # sha256(draft_body + '\n' + surface). Marker present → permit.
20
+ # Marker absent → deny with directive to delegate to wr-risk-scorer:external-comms.
21
+ #
22
+ # Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/external-comms-reviewed-<sha256>
23
+ # Marker writer: PostToolUse:Agent hook (risk-score-mark.sh) on subagent
24
+ # type wr-risk-scorer:external-comms.
25
+ #
26
+ # Composite-marker scheme (combining with wr-voice-tone:agent verdict for
27
+ # the same draft) is deferred until P038 lands its evaluator. This iteration
28
+ # ships the risk-evaluator side as a standalone gate; the two hooks compose
29
+ # at the PreToolUse:Bash matcher level when both packages are installed.
30
+ # See ADR-028 amendment Reassessment Criteria.
31
+
32
+ set -euo pipefail
33
+
34
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
35
+ # shellcheck source=lib/leak-detect.sh
36
+ source "$SCRIPT_DIR/lib/leak-detect.sh"
37
+
38
+ # ---------- Bypass ----------
39
+ if [ "${BYPASS_RISK_GATE:-0}" = "1" ]; then
40
+ exit 0
41
+ fi
42
+
43
+ INPUT=$(cat)
44
+
45
+ # Extract tool name + tool_input via python3 (consistent with sibling hooks).
46
+ TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
47
+ import sys, json
48
+ try:
49
+ print(json.load(sys.stdin).get('tool_name', ''))
50
+ except Exception:
51
+ print('')
52
+ " 2>/dev/null || echo "")
53
+
54
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
55
+ import sys, json
56
+ try:
57
+ print(json.load(sys.stdin).get('session_id', ''))
58
+ except Exception:
59
+ print('')
60
+ " 2>/dev/null || echo "")
61
+
62
+ # Permit silently when session_id is absent; the gate cannot key a marker.
63
+ [ -n "$SESSION_ID" ] || exit 0
64
+
65
+ # ---------- Surface detection ----------
66
+ SURFACE=""
67
+ DRAFT=""
68
+
69
+ case "$TOOL_NAME" in
70
+ Bash)
71
+ COMMAND=$(printf '%s' "$INPUT" | python3 -c "
72
+ import sys, json
73
+ try:
74
+ print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
75
+ except Exception:
76
+ print('')
77
+ " 2>/dev/null || echo "")
78
+
79
+ # Surface match — most-specific first.
80
+ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue create(\s|$)'; then
81
+ SURFACE="gh-issue-create"
82
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue comment(\s|$)'; then
83
+ SURFACE="gh-issue-comment"
84
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue edit(\s|$)'; then
85
+ SURFACE="gh-issue-edit"
86
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr create(\s|$)'; then
87
+ SURFACE="gh-pr-create"
88
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr comment(\s|$)'; then
89
+ SURFACE="gh-pr-comment"
90
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr edit(\s|$)'; then
91
+ SURFACE="gh-pr-edit"
92
+ elif echo "$COMMAND" | grep -qE 'gh api .*security-advisories'; then
93
+ SURFACE="gh-api-security-advisories"
94
+ elif echo "$COMMAND" | grep -qE 'gh api .*/comments'; then
95
+ SURFACE="gh-api-comments"
96
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm publish(\s|$)'; then
97
+ SURFACE="npm-publish"
98
+ else
99
+ exit 0
100
+ fi
101
+
102
+ # Best-effort body extraction: --body 'TEXT' or --body "TEXT" or --field summary='TEXT'.
103
+ # When absent (npm publish, --body-file), DRAFT="" is acceptable: the agent will
104
+ # be invoked with command context and read whatever body source the call uses.
105
+ DRAFT=$(printf '%s' "$COMMAND" | python3 -c "
106
+ import sys, re
107
+ cmd = sys.stdin.read()
108
+ # Match --body '...' or --body \"...\" or --field summary='...'
109
+ for pat in [r\"--body[= ]'([^']*)'\", r'--body[= ]\"([^\"]*)\"',
110
+ r\"--field [a-zA-Z_]+='([^']*)'\", r'--field [a-zA-Z_]+=\"([^\"]*)\"']:
111
+ m = re.search(pat, cmd)
112
+ if m:
113
+ print(m.group(1))
114
+ break
115
+ " 2>/dev/null || echo "")
116
+ ;;
117
+
118
+ Write|Edit)
119
+ FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "
120
+ import sys, json
121
+ try:
122
+ ti = json.load(sys.stdin).get('tool_input', {})
123
+ print(ti.get('file_path', ti.get('path', '')))
124
+ except Exception:
125
+ print('')
126
+ " 2>/dev/null || echo "")
127
+
128
+ case "$FILE_PATH" in
129
+ *.changeset/*.md|*/.changeset/*.md|.changeset/*.md)
130
+ SURFACE="changeset-author"
131
+ ;;
132
+ *)
133
+ exit 0
134
+ ;;
135
+ esac
136
+
137
+ DRAFT=$(printf '%s' "$INPUT" | python3 -c "
138
+ import sys, json
139
+ try:
140
+ ti = json.load(sys.stdin).get('tool_input', {})
141
+ print(ti.get('content', '') + ti.get('new_string', ''))
142
+ except Exception:
143
+ print('')
144
+ " 2>/dev/null || echo "")
145
+ ;;
146
+
147
+ *)
148
+ exit 0
149
+ ;;
150
+ esac
151
+
152
+ # ---------- Helpers ----------
153
+ deny_with_reason() {
154
+ local reason="$1"
155
+ python3 -c "
156
+ import json, sys
157
+ print(json.dumps({
158
+ 'hookSpecificOutput': {
159
+ 'hookEventName': 'PreToolUse',
160
+ 'permissionDecision': 'deny',
161
+ 'permissionDecisionReason': sys.argv[1]
162
+ }
163
+ }))
164
+ " "$reason"
165
+ }
166
+
167
+ permit_with_advisory() {
168
+ local msg="$1"
169
+ python3 -c "
170
+ import json, sys
171
+ print(json.dumps({'systemMessage': sys.argv[1]}))
172
+ " "$msg"
173
+ }
174
+
175
+ # ---------- Advisory-only fallback when policy file is absent ----------
176
+ if [ ! -f "RISK-POLICY.md" ]; then
177
+ permit_with_advisory "RISK-POLICY.md not found — wr-risk-scorer:external-comms gate is advisory-only on $SURFACE."
178
+ exit 0
179
+ fi
180
+
181
+ # ---------- Hard-fail leak-pattern pre-filter ----------
182
+ if ! leak_detect_scan "$DRAFT"; then
183
+ REASON=$(printf 'BLOCKED (P064 external-comms gate): %s on %s. Remove the leak before retrying. Override only if intentional: BYPASS_RISK_GATE=1.' \
184
+ "$LEAK_DETECT_REASON" "$SURFACE")
185
+ deny_with_reason "$REASON"
186
+ exit 0
187
+ fi
188
+
189
+ # ---------- Marker-based gate ----------
190
+ SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
191
+ mkdir -p "$SESSION_DIR"
192
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
193
+ MARKER="${SESSION_DIR}/external-comms-reviewed-${KEY}"
194
+
195
+ if [ -f "$MARKER" ]; then
196
+ exit 0
197
+ fi
198
+
199
+ # Marker absent — deny + delegate.
200
+ REASON=$(printf 'BLOCKED (P064 external-comms gate): %s draft has not been risk-reviewed. Delegate to wr-risk-scorer:external-comms (subagent_type: '"'"'wr-risk-scorer:external-comms'"'"') with the draft body for review. The PostToolUse hook will mark this draft reviewed when the subagent emits EXTERNAL_COMMS_RISK_VERDICT: PASS. Use /wr-risk-scorer:assess-external-comms for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
201
+ "$SURFACE")
202
+ deny_with_reason "$REASON"
203
+ exit 0
package/hooks/hooks.json CHANGED
@@ -7,6 +7,7 @@
7
7
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/secret-leak-gate.sh" }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wip-risk-gate.sh" }] },
8
8
  { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/git-push-gate.sh" }] },
9
9
  { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-commit-gate.sh" }] },
10
+ { "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-gate.sh" }] },
10
11
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-policy-enforce-edit.sh" }] },
11
12
  { "matcher": "ExitPlanMode", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-plan-enforce.sh" }] },
12
13
  { "matcher": "EnterPlanMode", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/plan-risk-guidance.sh" }] }
@@ -0,0 +1,99 @@
1
+ #!/bin/bash
2
+ # Hybrid leak-pattern pre-filter for external-comms-gate.sh (P064 / ADR-028 amended).
3
+ #
4
+ # Architecture (architect verdict — P064 iteration):
5
+ # - **Regex pre-filter** (this file) catches HIGH-CONFIDENCE leak shapes
6
+ # (credentials, prod URL prefixes, money/revenue + financial-context tokens).
7
+ # Hits deny immediately with a specific reason — no subagent round-trip.
8
+ # - **Subagent judgement** (wr-risk-scorer:external-comms) handles ambiguous
9
+ # prose. Anything this filter does NOT match is delegated to the agent for
10
+ # context-aware review against RISK-POLICY.md Confidential Information
11
+ # classes (client names, project names, engagement details, internal
12
+ # strategy/roadmap detail).
13
+ #
14
+ # Usage:
15
+ # source lib/leak-detect.sh
16
+ # if leak_detect_scan "$DRAFT"; then
17
+ # # No high-confidence hit; delegate to subagent.
18
+ # else
19
+ # # Set $LEAK_DETECT_REASON; deny with that reason.
20
+ # fi
21
+ #
22
+ # Returns 0 when the draft is CLEAN of high-confidence patterns.
23
+ # Returns 1 when a HARD-FAIL pattern is matched. $LEAK_DETECT_REASON
24
+ # is set to a one-line human-readable description of what matched.
25
+ #
26
+ # This is intentionally conservative — false negatives are expected and
27
+ # routed to the subagent. False positives here block the call entirely
28
+ # so the regex set must be high-specificity. Add new patterns only when
29
+ # the false-positive rate is verified low against representative drafts.
30
+
31
+ LEAK_DETECT_REASON=""
32
+
33
+ leak_detect_scan() {
34
+ local draft="$1"
35
+ LEAK_DETECT_REASON=""
36
+
37
+ [ -z "$draft" ] && return 0
38
+
39
+ # ---- Credentials (high confidence) ----
40
+
41
+ # AWS access keys: AKIA prefix + 16 upper-alphanum chars.
42
+ if echo "$draft" | grep -qE 'A''KIA[0-9A-Z]{16}'; then
43
+ LEAK_DETECT_REASON="AWS access key pattern detected in draft"
44
+ return 1
45
+ fi
46
+
47
+ # GitHub tokens: ghp_/ghs_/gho_/ghu_/ghr_ + 36+ url-safe chars.
48
+ if echo "$draft" | grep -qE 'g''h[pousr]_[A-Za-z0-9_]{36,}'; then
49
+ LEAK_DETECT_REASON="GitHub token pattern detected in draft"
50
+ return 1
51
+ fi
52
+
53
+ # Private keys.
54
+ if echo "$draft" | grep -qE 'BEGIN[[:space:]]+(RSA|DSA|EC|OPENSSH|PGP)?[[:space:]]*PRIVATE KEY'; then
55
+ LEAK_DETECT_REASON="Private key block detected in draft"
56
+ return 1
57
+ fi
58
+
59
+ # Bearer / Authorization headers with non-trivial value.
60
+ if echo "$draft" | grep -qE '[Bb]earer[[:space:]]+[A-Za-z0-9_.-]{20,}'; then
61
+ LEAK_DETECT_REASON="Bearer token detected in draft"
62
+ return 1
63
+ fi
64
+
65
+ # Generic api_key / api_secret / auth_token assignments with literal value.
66
+ if echo "$draft" | grep -qEi '(api_key|api_secret|auth_token|secret_key)[[:space:]]*[=:][[:space:]]*["\x27][A-Za-z0-9+/=_-]{16,}'; then
67
+ LEAK_DETECT_REASON="API key/secret/token assignment detected in draft"
68
+ return 1
69
+ fi
70
+
71
+ # ---- Confidential business metrics (RISK-POLICY.md) ----
72
+
73
+ # Revenue / financial figures: $<digits><K|M|B>? near financial-context
74
+ # keywords (ARR, MRR, revenue, profit). High-specificity to avoid catching
75
+ # generic price strings ($5 widget) — requires a financial token nearby.
76
+ if echo "$draft" | grep -qEi '\$[0-9]+([0-9.,]*)[KMB]?\b.{0,40}\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b'; then
77
+ LEAK_DETECT_REASON="Revenue/financial figure with business-context keyword detected"
78
+ return 1
79
+ fi
80
+ if echo "$draft" | grep -qEi '\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b.{0,40}\$[0-9]+([0-9.,]*)[KMB]?\b'; then
81
+ LEAK_DETECT_REASON="Business-context keyword paired with revenue/financial figure detected"
82
+ return 1
83
+ fi
84
+
85
+ # User counts with explicit unit-of-business: <digits><K|M> + (users|customers|MAU|DAU|signups).
86
+ # Requires comma-formatted thousands or K/M suffix to skip "5 users tested this".
87
+ if echo "$draft" | grep -qEi '\b[0-9]{1,3}(,[0-9]{3})+\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
88
+ LEAK_DETECT_REASON="User-count with business-metric keyword detected"
89
+ return 1
90
+ fi
91
+ if echo "$draft" | grep -qEi '\b[0-9]+[KMB]\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
92
+ LEAK_DETECT_REASON="User-count (K/M/B suffix) with business-metric keyword detected"
93
+ return 1
94
+ fi
95
+
96
+ # ---- Allowlist-bypass: signed/known-public marketing language is fine ----
97
+ # No hits — signal CLEAN to the caller.
98
+ return 0
99
+ }
@@ -121,4 +121,24 @@ if echo "$SUBAGENT" | grep -qE 'risk-scorer.policy'; then
121
121
  esac
122
122
  fi
123
123
 
124
+ # ---------------------------------------------------------------------------
125
+ # External-comms reviewer (P064 / ADR-028 amended): write per-draft marker
126
+ # keyed on sha256(draft + '\n' + surface). Subagent emits the key; this hook
127
+ # trusts and uses it. Marker file: external-comms-reviewed-<key>.
128
+ # ---------------------------------------------------------------------------
129
+ if echo "$SUBAGENT" | grep -qE 'risk-scorer.external-comms'; then
130
+ VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_RISK_VERDICT:' | tail -1) || true
131
+ KEY_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_RISK_KEY:' | tail -1) || true
132
+ VERDICT=$(echo "$VERDICT_LINE" | sed 's/^EXTERNAL_COMMS_RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
133
+ KEY=$(echo "$KEY_LINE" | sed 's/^EXTERNAL_COMMS_RISK_KEY:[[:space:]]*//' | tr -d '[:space:]')
134
+ # Validate key: 64 hex chars (sha256 output). Reject anything else.
135
+ if echo "$KEY" | grep -qE '^[0-9a-f]{64}$'; then
136
+ case "$VERDICT" in
137
+ PASS) touch "${RDIR}/external-comms-reviewed-${KEY}" ;;
138
+ FAIL) ;; # Do NOT create marker — draft must be revised
139
+ *) ;; # Unknown verdict — fail closed
140
+ esac
141
+ fi
142
+ fi
143
+
124
144
  exit 0
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env bats
2
+ # Tests for external-comms-gate.sh (P064 / ADR-028 amended).
3
+ # Behavioural: the gate denies outbound prose tool calls until the
4
+ # wr-risk-scorer:external-comms subagent has reviewed the draft and a
5
+ # per-evaluator marker has been written. Hard-fail leak patterns deny
6
+ # immediately without delegation.
7
+ #
8
+ # Note: secret-shaped strings are constructed at runtime via concatenation
9
+ # so this test file itself does not trip the secret-leak-gate hook.
10
+
11
+ setup() {
12
+ HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
13
+ HOOK="$HOOKS_DIR/external-comms-gate.sh"
14
+
15
+ TEST_SESSION="bats-extcomms-gate-$$-${BATS_TEST_NUMBER}"
16
+ RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
17
+ rm -rf "$RDIR"
18
+ mkdir -p "$RDIR"
19
+
20
+ # Default: assume RISK-POLICY.md is present in repo root (it is).
21
+ TEST_PROJECT_DIR="$(mktemp -d)"
22
+ printf "## Confidential Information\n- Client names\n- Revenue figures\n" \
23
+ > "$TEST_PROJECT_DIR/RISK-POLICY.md"
24
+
25
+ unset BYPASS_RISK_GATE
26
+
27
+ # Construct secret-shaped strings at runtime to avoid tripping the
28
+ # repo's own secret-leak-gate when this file is committed/edited.
29
+ GH_TOKEN_LIKE="g""hp""_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
30
+ AWS_KEY_LIKE="A""KIA""IOSFODNN7EXAMPLE"
31
+ }
32
+
33
+ teardown() {
34
+ rm -rf "$RDIR"
35
+ rm -rf "$TEST_PROJECT_DIR"
36
+ unset BYPASS_RISK_GATE
37
+ }
38
+
39
+ # ---------- Helpers ----------
40
+
41
+ # Build a PreToolUse:Bash input for a given command via python so quoting is safe.
42
+ build_bash_input() {
43
+ local cmd="$1"
44
+ python3 -c "
45
+ import json, sys
46
+ print(json.dumps({
47
+ 'session_id': '$TEST_SESSION',
48
+ 'tool_name': 'Bash',
49
+ 'tool_input': {'command': sys.argv[1]},
50
+ }))
51
+ " "$cmd"
52
+ }
53
+
54
+ # Build a PreToolUse:Write input for a changeset path with body content.
55
+ build_write_input() {
56
+ local file_path="$1"
57
+ local content="$2"
58
+ python3 -c "
59
+ import json, sys
60
+ print(json.dumps({
61
+ 'session_id': '$TEST_SESSION',
62
+ 'tool_name': 'Write',
63
+ 'tool_input': {'file_path': sys.argv[1], 'content': sys.argv[2]},
64
+ }))
65
+ " "$file_path" "$content"
66
+ }
67
+
68
+ # Run the hook in a project dir with RISK-POLICY.md present, piping JSON via stdin.
69
+ run_hook() {
70
+ local input="$1"
71
+ run bash -c "cd '$TEST_PROJECT_DIR' && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
72
+ }
73
+
74
+ # ---------- Tests ----------
75
+
76
+ @test "non-matching Bash command (ls) is allowed silently" {
77
+ INPUT=$(build_bash_input "ls -la")
78
+ run_hook "$INPUT"
79
+ [ "$status" -eq 0 ]
80
+ [ -z "$output" ]
81
+ }
82
+
83
+ @test "gh issue create with clean draft denies and prompts external-comms delegation (no marker yet)" {
84
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
85
+ run_hook "$INPUT"
86
+ [ "$status" -eq 0 ]
87
+ [[ "$output" == *"permissionDecision"* ]]
88
+ [[ "$output" == *"deny"* ]]
89
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
90
+ }
91
+
92
+ @test "hard-fail credential pattern (GitHub token) denies immediately with leak reason" {
93
+ INPUT=$(build_bash_input "gh issue comment 42 --body 'token=${GH_TOKEN_LIKE}'")
94
+ run_hook "$INPUT"
95
+ [ "$status" -eq 0 ]
96
+ [[ "$output" == *"permissionDecision"* ]]
97
+ [[ "$output" == *"deny"* ]]
98
+ [[ "$output" == *"GitHub token"* ]] || [[ "$output" == *"credential"* ]]
99
+ }
100
+
101
+ @test "hard-fail credential pattern (AWS key) denies immediately" {
102
+ INPUT=$(build_bash_input "gh pr create --title T --body 'AWS=${AWS_KEY_LIKE}'")
103
+ run_hook "$INPUT"
104
+ [ "$status" -eq 0 ]
105
+ [[ "$output" == *"deny"* ]]
106
+ [[ "$output" == *"AWS"* ]] || [[ "$output" == *"credential"* ]]
107
+ }
108
+
109
+ @test "BYPASS_RISK_GATE=1 short-circuits the deny" {
110
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure'")
111
+ run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
112
+ [ "$status" -eq 0 ]
113
+ [ -z "$output" ]
114
+ }
115
+
116
+ @test "marker present for matching draft+surface key allows the call" {
117
+ DRAFT="we observed a build failure on Node 20"
118
+ SURFACE="gh-issue-create"
119
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
120
+ touch "${RDIR}/external-comms-reviewed-${KEY}"
121
+
122
+ INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
123
+ run_hook "$INPUT"
124
+ [ "$status" -eq 0 ]
125
+ [ -z "$output" ]
126
+ }
127
+
128
+ @test "RISK-POLICY.md absent yields advisory-only mode (permits)" {
129
+ rm -f "$TEST_PROJECT_DIR/RISK-POLICY.md"
130
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a failure'")
131
+ run_hook "$INPUT"
132
+ [ "$status" -eq 0 ]
133
+ # Must NOT deny when policy file is absent.
134
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
135
+ [[ "$output" != *"\"permissionDecision\":\"deny\""* ]]
136
+ }
137
+
138
+ @test "PreToolUse:Write on .changeset/*.md with revenue leak content denies" {
139
+ INPUT=$(build_write_input ".changeset/wr-risk-scorer-extcomms.md" "Add gate; covers Acme Corp \$2.4M ARR client.")
140
+ run_hook "$INPUT"
141
+ [ "$status" -eq 0 ]
142
+ [[ "$output" == *"deny"* ]]
143
+ }
144
+
145
+ @test "PreToolUse:Write on .changeset/*.md with clean content denies-then-delegates (no marker yet)" {
146
+ INPUT=$(build_write_input ".changeset/wr-risk-scorer-extcomms.md" "Add external-comms gate covering gh issue and pr surfaces.")
147
+ run_hook "$INPUT"
148
+ [ "$status" -eq 0 ]
149
+ [[ "$output" == *"deny"* ]]
150
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
151
+ }
152
+
153
+ @test "PreToolUse:Write on a non-changeset path is ignored" {
154
+ INPUT=$(build_write_input "src/foo.ts" "Acme Corp client revenue \$2.4M")
155
+ run_hook "$INPUT"
156
+ [ "$status" -eq 0 ]
157
+ [ -z "$output" ]
158
+ }
159
+
160
+ @test "gh api security-advisories triggers the gate" {
161
+ INPUT=$(build_bash_input "gh api repos/foo/bar/security-advisories --method POST --field summary='leak via X'")
162
+ run_hook "$INPUT"
163
+ [ "$status" -eq 0 ]
164
+ [[ "$output" == *"deny"* ]]
165
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
166
+ }
167
+
168
+ @test "npm publish triggers the gate" {
169
+ INPUT=$(build_bash_input "npm publish")
170
+ run_hook "$INPUT"
171
+ [ "$status" -eq 0 ]
172
+ [[ "$output" == *"deny"* ]]
173
+ [[ "$output" == *"wr-risk-scorer:external-comms"* ]]
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.4.1-preview.208",
3
+ "version": "0.4.1-preview.212",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: wr-risk-scorer:assess-external-comms
3
+ description: On-demand external-comms risk review. Reviews a draft of an outbound prose tool call (gh issue/pr body, security advisory, npm publish content, or .changeset/*.md body) for confidential-information leaks per RISK-POLICY.md. Delegates to wr-risk-scorer:external-comms and pre-satisfies the external-comms-gate marker for the current session.
4
+ allowed-tools: Read, Glob, Grep, Bash, AskUserQuestion, Skill
5
+ ---
6
+
7
+ # External-Comms Risk Assessment Skill
8
+
9
+ Run a confidential-information leak review on demand against any drafted outbound prose — outside a hook gate trigger. Pre-satisfies the `external-comms-gate.sh` marker for the current session so the gated tool call (gh issue/pr/api/npm publish/changeset write) proceeds without re-prompting.
10
+
11
+ This skill is **read-only**. It does not commit, push, or modify files. The marker is written automatically by the `PostToolUse:Agent` hook (`risk-score-mark.sh`) after the subagent completes — the skill never writes to `${TMPDIR:-/tmp}/claude-risk-*` directly.
12
+
13
+ ## When to use
14
+
15
+ - Before drafting a `gh issue create`/`gh pr create`/`gh issue comment`/`gh pr comment` to a third-party repo.
16
+ - Before drafting a `gh api .../security-advisories` body for a vendor private channel.
17
+ - Before authoring a `.changeset/*.md` body that will land in CHANGELOG.md and every published npm tarball (P073).
18
+ - Before `npm publish` when the README diff is non-trivial.
19
+ - After hitting the external-comms gate's deny-and-delegate prompt: this skill is the structured walkthrough that closes the loop.
20
+
21
+ ## Steps
22
+
23
+ ### 1. Parse arguments
24
+
25
+ Read `$ARGUMENTS` for either:
26
+
27
+ - A draft body verbatim (e.g. the user pastes the prose they're about to post).
28
+ - A surface hint (`gh-issue-create`, `gh-pr-comment`, `gh-api-security-advisories`, `gh-issue-edit`, `gh-pr-edit`, `gh-issue-comment`, `gh-pr-create`, `gh-api-comments`, `npm-publish`, `changeset-author`).
29
+ - A destination hint (`anthropics/claude-code#52831`, `vendor private channel`, `npm public registry`).
30
+
31
+ If both draft and surface are present, proceed to step 3. If either is missing, step 2.
32
+
33
+ ### 2. Resolve missing context
34
+
35
+ If the draft is missing, use `AskUserQuestion`:
36
+
37
+ > "What draft do you want me to review? Paste the body verbatim — I will pass it to the external-comms reviewer."
38
+
39
+ If the surface is missing AND cannot be inferred from context (e.g. user just said "before I post this comment"), use `AskUserQuestion`:
40
+
41
+ - header: "Target surface"
42
+ - options:
43
+ 1. `gh issue create` (public third-party repo)
44
+ 2. `gh issue comment` (public third-party repo)
45
+ 3. `gh pr create` / `gh pr comment` (public third-party repo)
46
+ 4. `gh api .../security-advisories` (vendor private channel)
47
+ 5. `npm publish` (permanently published artefact)
48
+ 6. `.changeset/*.md` (lands in CHANGELOG + Release PR + every npm tarball)
49
+
50
+ Do not ask if the surface is obvious from the conversation context.
51
+
52
+ ### 3. Construct the review prompt
53
+
54
+ Build a self-contained prompt for the `wr-risk-scorer:external-comms` subagent that includes:
55
+
56
+ - The **draft body** verbatim (between explicit `<draft>...</draft>` markers so the agent's substring extraction is unambiguous).
57
+ - The **target surface** (one of the canonical strings above).
58
+ - The **destination** when known.
59
+ - A reminder to compute `EXTERNAL_COMMS_RISK_KEY = sha256(draft + '\n' + surface)`.
60
+
61
+ ### 4. Delegate to wr-risk-scorer:external-comms
62
+
63
+ Invoke the subagent via the `Skill` tool:
64
+
65
+ ```
66
+ subagent_type: wr-risk-scorer:external-comms
67
+ prompt: <constructed review prompt from step 3>
68
+ ```
69
+
70
+ Wait for the subagent to complete. The subagent will output a structured verdict block (`EXTERNAL_COMMS_RISK_VERDICT: PASS|FAIL` + `EXTERNAL_COMMS_RISK_KEY: <sha>` + optional `EXTERNAL_COMMS_RISK_REASON: ...`). The `PostToolUse:Agent` hook (`risk-score-mark.sh`) reads that output and writes the marker automatically.
71
+
72
+ **Do not write to `${TMPDIR:-/tmp}/claude-risk-*` yourself.** The hook is the only correct mechanism.
73
+
74
+ ### 5. Present results
75
+
76
+ Present the full review report to the user. Highlight:
77
+
78
+ - The verdict (PASS / FAIL).
79
+ - Each Confidential Information class the draft matched against (FAIL only).
80
+ - The exact substrings that triggered each finding (FAIL only).
81
+ - Whether the gate is now pre-satisfied for the current session for this exact draft+surface key (PASS only): "The next attempt to <surface> with this draft body will proceed without re-prompting."
82
+
83
+ ### 6. Above-appetite handling (ADR-013 Rule 6)
84
+
85
+ If the verdict is FAIL, do NOT auto-rewrite the draft. Use `AskUserQuestion`:
86
+
87
+ - header: "Leak detected — next step"
88
+ - options:
89
+ 1. `Rewrite the draft and re-review` — return to step 1 with the rewritten body.
90
+ 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.
91
+ 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).
92
+ 4. `Cancel` — abandon the post.
93
+
94
+ 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.
95
+
96
+ $ARGUMENTS