@windyroad/voice-tone 0.5.3 → 0.5.4-preview.374
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.
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
# (Voice-tone evaluator: skips leak pre-filter — leak detection is the
|
|
25
25
|
# risk evaluator's concern; voice-tone reviews tone/voice only.)
|
|
26
26
|
# 4. Otherwise: check for THIS evaluator's per-evaluator marker keyed on
|
|
27
|
-
#
|
|
27
|
+
# compute_external_comms_key(draft, surface) =
|
|
28
|
+
# sha256(normalize(draft, surface) + '\n' + surface) — the SINGLE
|
|
29
|
+
# canonical key shared with the mark hook (lib/external-comms-key.sh).
|
|
30
|
+
# For the changeset-author surface normalize() strips the leading YAML
|
|
31
|
+
# frontmatter block so the gate (which sees the FULL Write content) and
|
|
32
|
+
# the mark hook (which sees only the <draft> body) hash identical input
|
|
33
|
+
# (P010 / ADR-028 amended 2026-05-25). Marker present → permit.
|
|
28
34
|
# Marker absent → deny with directive to delegate to this plugin's
|
|
29
35
|
# subagent (configured via external-comms-evaluator.conf).
|
|
30
36
|
#
|
|
@@ -48,6 +54,12 @@ set -euo pipefail
|
|
|
48
54
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
49
55
|
# shellcheck source=lib/leak-detect.sh
|
|
50
56
|
source "$SCRIPT_DIR/lib/leak-detect.sh"
|
|
57
|
+
# shellcheck source=lib/external-comms-key.sh
|
|
58
|
+
# Provides compute_external_comms_key — the SINGLE canonical marker-key
|
|
59
|
+
# normalization shared with the PostToolUse mark hook (ADR-028 amended
|
|
60
|
+
# 2026-05-25 / P010). Sourced via the same $SCRIPT_DIR/lib convention as
|
|
61
|
+
# leak-detect.sh so byte-identity holds across the synced per-package copies.
|
|
62
|
+
source "$SCRIPT_DIR/lib/external-comms-key.sh"
|
|
51
63
|
|
|
52
64
|
# ---------- Per-package evaluator config (ADR-028 amended 2026-05-14) ----------
|
|
53
65
|
# Each consumer plugin ships its own external-comms-evaluator.conf alongside this
|
|
@@ -231,7 +243,12 @@ fi
|
|
|
231
243
|
# ---------- Marker-based gate (per-evaluator marker per ADR-028 amended 2026-05-14) ----------
|
|
232
244
|
SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
|
|
233
245
|
mkdir -p "$SESSION_DIR"
|
|
234
|
-
|
|
246
|
+
# Canonical marker key — normalize() strips changeset frontmatter + trailing
|
|
247
|
+
# whitespace so this PreToolUse key matches the PostToolUse mark-hook key
|
|
248
|
+
# (compute_external_comms_key in lib/external-comms-key.sh; P010 / ADR-028
|
|
249
|
+
# amended 2026-05-25). For changeset-author $DRAFT is the FULL Write content
|
|
250
|
+
# (frontmatter + body); compute_external_comms_key strips the frontmatter.
|
|
251
|
+
KEY=$(compute_external_comms_key "$DRAFT" "$SURFACE")
|
|
235
252
|
MARKER="${SESSION_DIR}/external-comms-${EXTERNAL_COMMS_EVALUATOR_ID}-reviewed-${KEY}"
|
|
236
253
|
|
|
237
254
|
if [ -f "$MARKER" ]; then
|
|
@@ -244,7 +261,7 @@ fi
|
|
|
244
261
|
# PostToolUse mark hook can derive the canonical marker key locally
|
|
245
262
|
# (sha256(DRAFT + '\n' + SURFACE)). Single fire per gate cycle.
|
|
246
263
|
VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
|
|
247
|
-
REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers. The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
|
|
264
|
+
REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers (for the changeset-author surface the body is the changeset summary WITHOUT the leading `---` frontmatter block — the gate strips frontmatter before hashing the marker key). The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
|
|
248
265
|
"$EXTERNAL_COMMS_EVALUATOR_ID" "$SURFACE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$SURFACE" "$VERDICT_PREFIX" "$EXTERNAL_COMMS_ASSESS_SKILL")
|
|
249
266
|
deny_with_reason "$REASON"
|
|
250
267
|
exit 0
|
|
@@ -1,44 +1,101 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Shared helper: derive the external-comms marker key from an agent's
|
|
3
3
|
# tool_input.prompt by extracting the structured `SURFACE: <name>` line
|
|
4
|
-
# and `<draft>...</draft>` block, then computing
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# and `<draft>...</draft>` block, then computing the canonical key via
|
|
5
|
+
# compute_external_comms_key — the same key shape the gate computes at
|
|
6
|
+
# PreToolUse time (external-comms-gate.sh).
|
|
7
7
|
#
|
|
8
8
|
# P166 + ADR-028 amended 2026-05-16: the PostToolUse:Agent mark hook
|
|
9
9
|
# derives the marker key from observed runtime state instead of trusting
|
|
10
10
|
# an agent-emitted EXTERNAL_COMMS_<EVAL>_KEY line. Removes the
|
|
11
11
|
# double-invocation cost class — single fire per gate cycle suffices.
|
|
12
12
|
#
|
|
13
|
+
# P010 + ADR-028 amended 2026-05-25: the marker key is computed via the
|
|
14
|
+
# SINGLE canonical normalization in compute_external_comms_key, shared
|
|
15
|
+
# byte-for-byte between the gate (PreToolUse) and this mark-hook helper
|
|
16
|
+
# (PostToolUse). The normalization strips the changeset YAML frontmatter
|
|
17
|
+
# block before hashing (the gate sees the FULL Write content incl.
|
|
18
|
+
# frontmatter; the agent wraps only the body in <draft>) and applies a
|
|
19
|
+
# single canonical trailing-whitespace strip so the two sides cannot
|
|
20
|
+
# diverge. Fixes the deny-after-PASS marker-key mismatch (P198 / #149).
|
|
21
|
+
#
|
|
13
22
|
# Canonical source: packages/shared/hooks/lib/external-comms-key.sh
|
|
14
23
|
# Synced byte-identically into each consumer plugin's hooks/lib/ via
|
|
15
24
|
# scripts/sync-external-comms-gate.sh (ADR-017 duplicate-script pattern).
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# compute_external_comms_key <draft> <surface>
|
|
16
28
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
29
|
+
# THE single source of truth for the external-comms marker key. Both the
|
|
30
|
+
# PreToolUse gate and the PostToolUse mark hook compute the key through
|
|
31
|
+
# this function so they hash byte-identical input (ADR-028 amended
|
|
32
|
+
# 2026-05-25). Echoes the 64-char lowercase sha256 hex on stdout.
|
|
33
|
+
#
|
|
34
|
+
# normalize(draft, surface):
|
|
35
|
+
# - changeset-author surface: strip the leading YAML frontmatter block
|
|
36
|
+
# (`---\n...\n---\n` plus the blank line after it). Changeset files
|
|
37
|
+
# carry `---\n"@windyroad/x": minor\n---\n\n<body>`; the gate sees the
|
|
38
|
+
# whole thing via tool_input.content while the agent wraps only the
|
|
39
|
+
# <body> in <draft>. Stripping frontmatter makes the two inputs equal.
|
|
40
|
+
# All other surfaces (gh-*, npm-publish) are already body-only via the
|
|
41
|
+
# gate's --body / --field extraction, so they are left unchanged.
|
|
42
|
+
# - all surfaces: rstrip ALL trailing whitespace. This single canonical
|
|
43
|
+
# newline normalization subsumes both the gate's `$()` trailing-newline
|
|
44
|
+
# strip and this helper's `<draft>` regex single-newline strip, so the
|
|
45
|
+
# two sides are provably symmetric on trailing whitespace.
|
|
46
|
+
# key = sha256(normalize(draft, surface) + '\n' + surface)
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
compute_external_comms_key() {
|
|
49
|
+
local draft="$1" surface="$2"
|
|
50
|
+
EXTCOMMS_DRAFT="$draft" EXTCOMMS_SURFACE="$surface" python3 -c "
|
|
51
|
+
import os, re, hashlib
|
|
52
|
+
draft = os.environ.get('EXTCOMMS_DRAFT', '')
|
|
53
|
+
surface = os.environ.get('EXTCOMMS_SURFACE', '')
|
|
54
|
+
# changeset-author: strip the leading YAML frontmatter block + blank line.
|
|
55
|
+
if surface == 'changeset-author':
|
|
56
|
+
draft = re.sub(r'^---\n.*?\n---\n\n?', '', draft, count=1, flags=re.DOTALL)
|
|
57
|
+
# Single canonical newline normalization: strip all trailing whitespace.
|
|
58
|
+
draft = draft.rstrip()
|
|
59
|
+
print(hashlib.sha256((draft + '\n' + surface).encode('utf-8')).hexdigest())
|
|
60
|
+
" 2>/dev/null
|
|
61
|
+
}
|
|
21
62
|
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# derive_external_comms_key_from_prompt <prompt>
|
|
65
|
+
#
|
|
66
|
+
# Extracts the (SURFACE, draft-body) pair from an agent prompt's structured
|
|
67
|
+
# `SURFACE: <name>` line + `<draft>...</draft>` block, then delegates to
|
|
68
|
+
# compute_external_comms_key so the normalization lives in exactly one place.
|
|
69
|
+
#
|
|
70
|
+
# Returns the 64-char hex sha256 on stdout when both markers are present in
|
|
71
|
+
# the prompt. Returns empty string when either marker is absent — the caller
|
|
72
|
+
# falls back to the agent-emitted KEY for backward compatibility with cached
|
|
73
|
+
# old SKILL.md / agent prompts.
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
22
75
|
derive_external_comms_key_from_prompt() {
|
|
23
76
|
local prompt="$1"
|
|
24
77
|
[ -n "$prompt" ] || { echo ""; return 0; }
|
|
25
|
-
|
|
26
|
-
|
|
78
|
+
# Extract SURFACE + <draft> body in one pass. The two fields are emitted
|
|
79
|
+
# \x1f-separated (ASCII unit separator) so a body containing newlines — or
|
|
80
|
+
# an empty body — round-trips through command substitution intact (only
|
|
81
|
+
# trailing newlines are dropped, which compute_external_comms_key rstrips
|
|
82
|
+
# anyway). Empty output when either marker is absent → empty key.
|
|
83
|
+
local extracted
|
|
84
|
+
extracted=$(printf '%s' "$prompt" | python3 -c "
|
|
85
|
+
import sys, re
|
|
27
86
|
text = sys.stdin.read()
|
|
28
|
-
# DRAFT
|
|
29
|
-
#
|
|
30
|
-
# so the body content does not capture wrapping newlines.
|
|
87
|
+
# DRAFT: non-greedy match between <draft>...</draft>, tolerating an optional
|
|
88
|
+
# newline immediately after <draft> and before </draft>.
|
|
31
89
|
draft_match = re.search(r'<draft>\n?(.*?)\n?</draft>', text, re.DOTALL)
|
|
32
|
-
# SURFACE
|
|
33
|
-
#
|
|
34
|
-
# token: letter + word/hyphen chars.
|
|
90
|
+
# SURFACE: anchored to line start (MULTILINE) so prose like 'context says
|
|
91
|
+
# SURFACE: x' does not match. Surface name is a single letter+word/hyphen token.
|
|
35
92
|
surface_match = re.search(r'^SURFACE:\s*([A-Za-z][\w-]*)', text, re.MULTILINE)
|
|
36
93
|
if not draft_match or not surface_match:
|
|
37
|
-
print('')
|
|
38
94
|
sys.exit(0)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
95
|
+
sys.stdout.write(surface_match.group(1) + '\x1f' + draft_match.group(1))
|
|
96
|
+
" 2>/dev/null) || extracted=""
|
|
97
|
+
[ -n "$extracted" ] || { echo ""; return 0; }
|
|
98
|
+
local surface="${extracted%%$'\x1f'*}"
|
|
99
|
+
local body="${extracted#*$'\x1f'}"
|
|
100
|
+
compute_external_comms_key "$body" "$surface"
|
|
44
101
|
}
|
|
@@ -198,3 +198,24 @@ run_hook() {
|
|
|
198
198
|
[ "$status" -eq 0 ]
|
|
199
199
|
[[ "$output" == *"deny"* ]]
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# P010 / ADR-028 amended 2026-05-25 — deny-after-PASS regression (voice-tone).
|
|
204
|
+
# Mirror of the risk-scorer regression: the gate sees the FULL changeset
|
|
205
|
+
# content (YAML frontmatter + body) but the mark hook keys the marker on the
|
|
206
|
+
# <draft> body. After the fix the gate strips frontmatter before hashing, so
|
|
207
|
+
# a body-keyed voice-tone PASS marker permits the changeset Write.
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
@test "P010: changeset Write permits when the voice-tone PASS marker is keyed on the <draft> body (frontmatter stripped before hash)" {
|
|
211
|
+
BODY="external-comms gate strips changeset frontmatter before key hash"
|
|
212
|
+
SURFACE="changeset-author"
|
|
213
|
+
KEY=$(printf '%s\n%s' "$BODY" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
|
|
214
|
+
touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}"
|
|
215
|
+
|
|
216
|
+
CONTENT=$'---\n"@windyroad/voice-tone": patch\n---\n\n'"$BODY"
|
|
217
|
+
INPUT=$(build_write_input ".changeset/p010-fix.md" "$CONTENT")
|
|
218
|
+
run_hook "$INPUT"
|
|
219
|
+
[ "$status" -eq 0 ]
|
|
220
|
+
[ -z "$output" ]
|
|
221
|
+
}
|