@cratylus/canon 0.12.1 → 0.12.3

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.
@@ -0,0 +1,43 @@
1
+ /** The one cap on what a guard's judge is sent, in bytes: the size of what crosses to the judge.
2
+ * A judgement has to fit the time its harness allows a guard (omp kills an extension handler
3
+ * at 30 s, a Claude Code cell runs 60 s) and the text a turn or a dispatch carries has no bound. */
4
+ export declare const judgePayloadCapBytes = 12000;
5
+ /** Bounds the judged text to `JUDGE_PAYLOAD_CAP` bytes. `jq` does the cutting because every
6
+ * worker already depends on it and it counts bytes (`utf8bytelength`), so a cut never lands
7
+ * inside a character. Text over its share is cut and MARKED where it was cut, so the judge
8
+ * cannot take the seam for the agent's own words. Written with `String.raw` so its
9
+ * backslashes reach the worker verbatim. */
10
+ export declare const judgeClipShell: string;
11
+ /** What a worker that keeps the END of a long text adds to the clip (the turn-end worker alone):
12
+ * the last N bytes of stdin, and the marker for text cut off its front. */
13
+ export declare const judgeTailShell: string;
14
+ /**
15
+ * A refusal never strands the agent: every refusal names a way on, and an agent that holds
16
+ * one wrong contests it by stating why, the act then proceeding with the contest logged for
17
+ * the operator.
18
+ *
19
+ * Interpolate AFTER the worker has defined `say` and `jq` is known to be present, and set
20
+ * these first:
21
+ * GUARD_ID the cell id (`purview-guardrail`)
22
+ * GUARD_NAME what the operator reads as the guard (`PURVIEW GUARDRAIL`)
23
+ * GUARD_SESSION the payload's session id, as received
24
+ * GUARD_AGENT the agent the guard judges
25
+ * GUARD_ACT the tool name, or `stop` at the turn end
26
+ * GUARD_CALL the call's compact `tool_input` (unset at the turn end)
27
+ * NEUTRAL_ROOT the `.agents` root, home of the default contest log
28
+ *
29
+ * It then offers `contest_heard` (call before judging) and `contest_refused REASON` (call on
30
+ * a refusal: records REASON and prints the way forward, to be placed after the judge's reason
31
+ * in what is returned). A refusal of a key stands from the fire that refused it until the next
32
+ * fire of that key. A contest answers a refusal that stands: a fire is contested, reported and
33
+ * let through unjudged (exit 0), only when `<key>.refused` exists, whatever it holds, and
34
+ * `<key>.contest` holds text. Every other fire that reaches `contest_heard` leaves neither file
35
+ * behind and is judged as usual, a contest written before the refusal it would answer being
36
+ * discarded unheard, and a block recording its refusal anew. Contest files live in
37
+ * `$CONTEST_DIR`, named `<key>.refused` and `<key>.contest`, `<key>` being the cksum of the
38
+ * call's tool name and tool input, or `stop`. The functions use the shell names `contest`,
39
+ * `refusal`, `log`, `kept` and `again`; a worker leaves them alone.
40
+ *
41
+ * Regular template: `\${` and `\\n` are the shell's `${` and `\n`.
42
+ */
43
+ export declare const contestShell = "case \"$GUARD_SESSION\" in '' | */* | . | ..) GUARD_SESSION=nosession ;; esac\nCONTEST_DIR=\"${TMPDIR:-/tmp}/guardrail-contest/$GUARD_ID/$GUARD_SESSION\"\nCONTEST_AT=\"$CONTEST_DIR/stop\"\n[ \"$GUARD_ACT\" = stop ] || CONTEST_AT=\"$CONTEST_DIR/$(printf '%s %s' \"$GUARD_ACT\" \"${GUARD_CALL:-}\" | cksum | cut -d' ' -f1)\"\ncontest_heard() {\n\trefusal=\"$(cat \"$CONTEST_AT.refused\" 2>/dev/null)\" && contest=\"$(cat \"$CONTEST_AT.contest\" 2>/dev/null)\" || contest=\n\trm -f \"$CONTEST_AT.contest\" \"$CONTEST_AT.refused\"\n\t[ -n \"$contest\" ] || return 0\n\tlog=\"${GUARD_CONTEST_LOG:-$NEUTRAL_ROOT/guardrail/contests.log}\"\n\tmkdir -p \"$(dirname -- \"$log\")\" 2>/dev/null && jq -cn --arg time \"$(date -u +%FT%TZ)\" --arg guard \"$GUARD_ID\" \\\n\t\t--arg session \"$GUARD_SESSION\" --arg agent \"$GUARD_AGENT\" --arg act \"$GUARD_ACT\" --arg refusal \"$refusal\" \\\n\t\t--arg contest \"$contest\" '$ARGS.named' 2>/dev/null >> \"$log\" && kept=\"logged in $log\" || kept=\"the contest was not recorded: $log is not writable\"\n\tsay \"$GUARD_NAME \u2014 contested: this $GUARD_ACT went through unjudged on the agent's reason: $(printf '%s' \"$contest\" | tr '\\n' ' ') ($kept)\"\n\texit 0\n}\ncontest_refused() {\n\tmkdir -p \"$CONTEST_DIR\" 2>/dev/null && printf '%s\\n' \"$1\" > \"$CONTEST_AT.refused\" 2>/dev/null || true\n\t[ \"$GUARD_ACT\" = stop ] && again=\"end the turn again\" || again=\"repeat the same call\"\n\tprintf '%s' \"Act on that reason, or if you hold it wrong state why with this command, then $again; it then goes through unjudged, your reason logged for the operator: printf '%s\\\\n' 'why' > '$CONTEST_AT.contest'\"\n}\n";
@@ -0,0 +1,85 @@
1
+ // The shell every guard worker shares. It lives outside `hooks/` because every file there is
2
+ // loaded as a hook cell (tooling/project-targets.ts), and a fragment is not a cell.
3
+ /** The one cap on what a guard's judge is sent, in bytes: the size of what crosses to the judge.
4
+ * A judgement has to fit the time its harness allows a guard (omp kills an extension handler
5
+ * at 30 s, a Claude Code cell runs 60 s) and the text a turn or a dispatch carries has no bound. */
6
+ export const judgePayloadCapBytes = 12_000;
7
+ /** Bounds the judged text to `JUDGE_PAYLOAD_CAP` bytes. `jq` does the cutting because every
8
+ * worker already depends on it and it counts bytes (`utf8bytelength`), so a cut never lands
9
+ * inside a character. Text over its share is cut and MARKED where it was cut, so the judge
10
+ * cannot take the seam for the agent's own words. Written with `String.raw` so its
11
+ * backslashes reach the worker verbatim. */
12
+ export const judgeClipShell = String.raw `JUDGE_PAYLOAD_CAP=${judgePayloadCapBytes}
13
+ judge_bytes() { printf '%s' "$1" | wc -c | tr -d ' '; }
14
+ JUDGE_JQ='
15
+ def pre($n): . as $s | if $n <= 0 then "" else {lo: 0, hi: ($s | length)} | until(.lo >= .hi;
16
+ ((.lo + .hi + 1) / 2 | floor) as $m | if ($s[:$m] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end) | $s[:.lo] end;
17
+ def suf($n): . as $s | if $n <= 0 then "" else {lo: 0, hi: ($s | length)} | until(.lo >= .hi;
18
+ ((.lo + .hi + 1) / 2 | floor) as $m | if ($s[-$m:] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end)
19
+ | if .lo == 0 then "" else $s[-.lo:] end end;
20
+ '
21
+ # --rawfile, never -R: jq 1.7's raw reader corrupts a multibyte character straddling a read boundary.
22
+ judge_ends() {
23
+ jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"'
24
+ $text | . as $s | ($s | utf8bytelength) as $t | if $t <= $n then $s else
25
+ ((($n - 200) / 2) | floor) as $k | ($s | pre($k)) as $h | ($s | suf($k)) as $e
26
+ | ($t - ($h | utf8bytelength) - ($e | utf8bytelength)) as $gone
27
+ | $h + "\n[ELIDED: \($gone) of \($t) bytes from the middle are not shown]\n" + $e end'
28
+ }
29
+ `;
30
+ /** What a worker that keeps the END of a long text adds to the clip (the turn-end worker alone):
31
+ * the last N bytes of stdin, and the marker for text cut off its front. */
32
+ export const judgeTailShell = String.raw `judge_keep() { jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"' $text | suf($n)'; }
33
+ judge_cut() { printf '[ELIDED: the first %s of %s bytes of %s are not shown; what follows is its final part]' "$(($2 - $3))" "$2" "$1"; }
34
+ `;
35
+ /**
36
+ * A refusal never strands the agent: every refusal names a way on, and an agent that holds
37
+ * one wrong contests it by stating why, the act then proceeding with the contest logged for
38
+ * the operator.
39
+ *
40
+ * Interpolate AFTER the worker has defined `say` and `jq` is known to be present, and set
41
+ * these first:
42
+ * GUARD_ID the cell id (`purview-guardrail`)
43
+ * GUARD_NAME what the operator reads as the guard (`PURVIEW GUARDRAIL`)
44
+ * GUARD_SESSION the payload's session id, as received
45
+ * GUARD_AGENT the agent the guard judges
46
+ * GUARD_ACT the tool name, or `stop` at the turn end
47
+ * GUARD_CALL the call's compact `tool_input` (unset at the turn end)
48
+ * NEUTRAL_ROOT the `.agents` root, home of the default contest log
49
+ *
50
+ * It then offers `contest_heard` (call before judging) and `contest_refused REASON` (call on
51
+ * a refusal: records REASON and prints the way forward, to be placed after the judge's reason
52
+ * in what is returned). A refusal of a key stands from the fire that refused it until the next
53
+ * fire of that key. A contest answers a refusal that stands: a fire is contested, reported and
54
+ * let through unjudged (exit 0), only when `<key>.refused` exists, whatever it holds, and
55
+ * `<key>.contest` holds text. Every other fire that reaches `contest_heard` leaves neither file
56
+ * behind and is judged as usual, a contest written before the refusal it would answer being
57
+ * discarded unheard, and a block recording its refusal anew. Contest files live in
58
+ * `$CONTEST_DIR`, named `<key>.refused` and `<key>.contest`, `<key>` being the cksum of the
59
+ * call's tool name and tool input, or `stop`. The functions use the shell names `contest`,
60
+ * `refusal`, `log`, `kept` and `again`; a worker leaves them alone.
61
+ *
62
+ * Regular template: `\${` and `\\n` are the shell's `${` and `\n`.
63
+ */
64
+ export const contestShell = `case "$GUARD_SESSION" in '' | */* | . | ..) GUARD_SESSION=nosession ;; esac
65
+ CONTEST_DIR="\${TMPDIR:-/tmp}/guardrail-contest/$GUARD_ID/$GUARD_SESSION"
66
+ CONTEST_AT="$CONTEST_DIR/stop"
67
+ [ "$GUARD_ACT" = stop ] || CONTEST_AT="$CONTEST_DIR/$(printf '%s %s' "$GUARD_ACT" "\${GUARD_CALL:-}" | cksum | cut -d' ' -f1)"
68
+ contest_heard() {
69
+ refusal="$(cat "$CONTEST_AT.refused" 2>/dev/null)" && contest="$(cat "$CONTEST_AT.contest" 2>/dev/null)" || contest=
70
+ rm -f "$CONTEST_AT.contest" "$CONTEST_AT.refused"
71
+ [ -n "$contest" ] || return 0
72
+ log="\${GUARD_CONTEST_LOG:-$NEUTRAL_ROOT/guardrail/contests.log}"
73
+ mkdir -p "$(dirname -- "$log")" 2>/dev/null && jq -cn --arg time "$(date -u +%FT%TZ)" --arg guard "$GUARD_ID" \\
74
+ --arg session "$GUARD_SESSION" --arg agent "$GUARD_AGENT" --arg act "$GUARD_ACT" --arg refusal "$refusal" \\
75
+ --arg contest "$contest" '$ARGS.named' 2>/dev/null >> "$log" && kept="logged in $log" || kept="the contest was not recorded: $log is not writable"
76
+ say "$GUARD_NAME — contested: this $GUARD_ACT went through unjudged on the agent's reason: $(printf '%s' "$contest" | tr '\\n' ' ') ($kept)"
77
+ exit 0
78
+ }
79
+ contest_refused() {
80
+ mkdir -p "$CONTEST_DIR" 2>/dev/null && printf '%s\\n' "$1" > "$CONTEST_AT.refused" 2>/dev/null || true
81
+ [ "$GUARD_ACT" = stop ] && again="end the turn again" || again="repeat the same call"
82
+ printf '%s' "Act on that reason, or if you hold it wrong state why with this command, then $again; it then goes through unjudged, your reason logged for the operator: printf '%s\\\\n' 'why' > '$CONTEST_AT.contest'"
83
+ }
84
+ `;
85
+ //# sourceMappingURL=guard-shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-shell.js","sourceRoot":"","sources":["../src/guard-shell.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,oFAAoF;AAEpF;;qGAEqG;AACrG,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAE3C;;;;6CAI6C;AAC7C,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAA,qBAAqB,oBAAoB;;;;;;;;;;;;;;;;;CAiBhF,CAAC;AAEF;4EAC4E;AAC5E,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAA;;CAEvC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;;;;;;;;;;;;;;;;;;;;CAoB3B,CAAC"}
@@ -1,4 +1,4 @@
1
- import { stanceGuardrailJudgeClip } from './stance-guardrail.js';
1
+ import { contestShell, judgeClipShell } from '../guard-shell.js';
2
2
  // purview-guardrail — the gate that makes a ROLE CONTRACT scoreable.
3
3
  //
4
4
  // A role value now states the arrow the position is over the ladder
@@ -41,19 +41,21 @@ import { stanceGuardrailJudgeClip } from './stance-guardrail.js';
41
41
  // contract makes an artifact read a descent, but a read is cheap to undo and a gate that
42
42
  // fires on one would wedge every legitimate orientation.
43
43
  //
44
- // FAIL-OPEN, EVIDENCE-CHECKED, RETRY-PROOF. The first two are inherited from the sibling
44
+ // FAIL-OPEN, EVIDENCE-CHECKED, JUDGED-AGAIN. The first two are inherited from the sibling
45
45
  // pre-hook, which is also the structural model for the worker. A block whose cited span
46
46
  // is not literally in the payload is DISCARDED: a fabricated block is not a lesser
47
- // error than a missed one. RETRY-PROOF is this guard's own: a refused act stays refused
48
- // however often it is retried, so a repeat is judged again and never waved through — a
49
- // cap that lets an identical second call pass unjudged is an exit from the guard.
47
+ // error than a missed one. JUDGED-AGAIN is this guard's own: a retry is judged afresh and
48
+ // refused again on a BLOCK, never waved through, and a refusal never strands the agent: it
49
+ // names the way on, and an agent that holds it wrong contests it by writing why into the
50
+ // file the refusal names, whereupon that call proceeds unjudged and the contest is logged
51
+ // for the operator (`contestShell`, the one fragment every guard worker shares).
50
52
  //
51
53
  // WHAT THE JUDGE IS HANDED is only what its one decision needs: the test in a few sentences
52
54
  // (the rubric), the act and the holder's contract it applies to. The act line names the act
53
55
  // and its target and nothing else; the definitions of what an act produces are the rubric's.
54
56
  export const purviewGuardrail = {
55
57
  id: 'purview-guardrail',
56
- residue: 'structural-refusal ↾ mid-turn act ∉ role-arrow · deny-before-fire ⟨descent ⟨write(artifact) ∉ writes⟩ · skipped-rung ⟨dispatch(spec the dispatcher wrote for a unit of the loop) ∄ planner⟩ · dispatch-echo ⟨literal-words ≠ routed-name ∨ own-words⟩⟩ · pass ⟨read · act ∈ reserves · dispatch ↦ plan ∨ implement(unit-name) ∨ assay ∨ integrate ∨ ad-hoc(own-words ∉ loop ↾ contract admits)⟩ · law = the HOLDER-projected role-section ⟨quoted verbatim · ¬ authored-opinion⟩ · shared judge-backend ⟨sibling⟩ · fail-open ∧ evidence-checked ∧ retry-proof ⟨refused ⊢ refused ∀ retry · every call judged · ¬ marker⟩ · judge-hand ≜ test ∧ act ∧ contract ¬ more',
58
+ residue: 'structural-refusal ↾ mid-turn act ∉ role-arrow · deny-before-fire ⟨descent ⟨write(artifact) ∉ writes⟩ · skipped-rung ⟨dispatch(spec the dispatcher wrote for a unit of the loop) ∄ planner⟩ · dispatch-echo ⟨literal-words ≠ routed-name ∨ own-words⟩⟩ · pass ⟨read · act ∈ reserves · dispatch ↦ plan ∨ implement(unit-name) ∨ assay ∨ integrate ∨ ad-hoc(own-words ∉ loop ↾ contract admits)⟩ · law = the HOLDER-projected role-section ⟨quoted verbatim · ¬ authored-opinion⟩ · shared judge-backend ⟨sibling⟩ · fail-open ∧ evidence-checked ∧ judged-again ⟨retry ↦ judged afresh ∧ refused again on BLOCK · every call judged · ¬ marker⟩ · refusal ↦ way-forward ⟨act on reason ∨ contest ≜ state why ↦ contested call proceeds unjudged ∧ contest logged ↦ operator review⟩ · judge-hand ≜ test ∧ act ∧ contract ¬ more',
57
59
  substrate: 'harness',
58
60
  // Bound by holding a role at all: the arrow it judges against is the role's own.
59
61
  binds: { dimension: 'role' },
@@ -68,72 +70,21 @@ export const purviewGuardrail = {
68
70
  targetPath: 'packages/canon/targets/guardrail/purview-guardrail.sh',
69
71
  executable: true,
70
72
  content: `#!/usr/bin/env sh
71
- # purview-guardrail — a PreToolUse hook that STRUCTURALLY REFUSES, before the call
72
- # fires, a mid-turn act that falls outside the arrow the agent's OWN role contract
73
- # declares.
74
- #
75
- # THE LAW IS NOT IN THIS FILE AND NOT IN THE RUBRIC. It is the \`## Role\` section of
76
- # the persona's deployed Target, read at run time and handed to the judge as the
77
- # contract to score against. No role text, no agent name and no behavioural opinion is
78
- # hard-coded anywhere in this gate.
79
- #
80
- # SAFETY MODEL (mirrors both siblings):
81
- # - SCOPE-ENROLLED by PRESENCE. A persona carrying a stance manifest is judged; every
82
- # other scope is silent. No allowlist — the projection that places a persona enrolls
83
- # it, and nothing central lists anybody.
84
- # - CONTRACT-ENROLLED, one step further. A persona whose Target carries no \`## Role\`
85
- # section has no arrow to be outside of, so it is allowed. A gate cannot convict
86
- # against a law that was never declared.
87
- # - FAILS OPEN, NEVER SILENTLY. Any error (no jq, no input, no Target, judge failure,
88
- # unparseable verdict, unexpected error) -> exit 0, and the call goes through WITH A NOTICE
89
- # the operator reads (\`say\`: Claude Code's JSON \`systemMessage\`, omp's relayed line) naming
90
- # this guard and why it could not judge. Silence is reserved for "judged, pass", "not
91
- # enrolled" and "no arrow declared".
92
- # - EVIDENCE-CHECKED. A BLOCK must quote, on an \`EVIDENCE:\` line, a span that is literally
93
- # present in the payload; a citation that is not there is a fabricated block and is
94
- # DISCARDED — and the discard is said, since no verdict then stands.
95
- # - RETRY-PROOF. A refused act stays refused however often it is retried: every call is
96
- # judged afresh, a repeat of a refused call included, and a BLOCK on it denies it again.
97
- # No marker is written and nothing is let through unjudged for having been denied before.
98
- #
99
- # INPUT : Claude Code PreToolUse hook JSON on stdin.
100
- # OUTPUT : on a purview breach -> a deny decision on stdout + exit 0.
101
- # judged, pass -> no stdout + exit 0.
102
- # let through with no verdict -> the notice \`say\` prints + exit 0.
103
- #
104
- # POSIX sh. Depends on: jq. Missing jq -> the guard says so and allows the call.
73
+ # purview-guardrail — denies a mid-turn act outside the arrow the agent's own \`## Role\` contract declares. Needs jq.
105
74
 
106
75
  set -eu
76
+ GUARD_ID=purview-guardrail
77
+ GUARD_NAME="PURVIEW GUARDRAIL"
107
78
 
108
- # WHAT AN OPERATOR READS WHEN THE GUARD CANNOT JUDGE, defined before anything that needs a
109
- # command: the first thing that can be missing is \`jq\`, and a worker run with a PATH holding
110
- # little beyond \`sh\` and \`cat\` must still say it did not judge, so \`esc\`, \`say\` and \`open\`
111
- # are builtins only and nothing external runs above the jq check.
112
- #
113
- # Claude Code shows the operator a hook's JSON \`systemMessage\` and NOT its plain stdout on
114
- # exit 0. omp's extension bridge reads the worker's plain stdout, and would relay a JSON object
115
- # as raw JSON — hence one form per harness, chosen at projection by the harness's own name.
79
+ # Builtins only until jq is checked: a PATH of little but \`sh\` must still be able to say so.
116
80
  esc() {
117
- _in="$1"
118
- _out=""
119
- _nl="$(printf '\\nx')"
120
- _nl="\${_nl%x}"
121
- _tab="$(printf '\\t')"
122
- _cr="$(printf '\\r')"
123
- while [ -n "$_in" ]; do
124
- _rest="\${_in#?}"
125
- _c="\${_in%"$_rest"}"
126
- case "$_c" in
127
- '\\') _c='\\\\' ;;
128
- '"') _c='\\"' ;;
129
- "$_nl") _c='\\n' ;;
130
- "$_tab") _c='\\t' ;;
131
- "$_cr") _c='\\r' ;;
132
- esac
133
- _out="$_out$_c"
134
- _in="$_rest"
81
+ _i="$1"; _o=""; _n="$(printf '\\nx')"; _n="\${_n%x}"; _t="$(printf '\\t')"; _r="$(printf '\\r')"
82
+ while [ -n "$_i" ]; do
83
+ _s="\${_i#?}"; _c="\${_i%"$_s"}"
84
+ case "$_c" in '\\') _c='\\\\' ;; '"') _c='\\"' ;; "$_n") _c='\\n' ;; "$_t") _c='\\t' ;; "$_r") _c='\\r' ;; esac
85
+ _o="$_o$_c"; _i="$_s"
135
86
  done
136
- printf '%s' "$_out"
87
+ printf '%s' "$_o"
137
88
  }
138
89
  say() {
139
90
  case "{{fact:harness-name}}" in
@@ -141,110 +92,71 @@ say() {
141
92
  *) printf '%s\\n' "$1" ;;
142
93
  esac
143
94
  }
144
-
145
95
  allow() { exit 0; }
146
-
147
- # A verdict and a failure are different facts and silence carries only one of them: \`allow\`
148
- # means "judged, pass" or "not this guard's call", \`open\` means "could not judge" — the call
149
- # still proceeds, and the operator is told.
150
- open() {
151
- [ -z "\${LOG:-}" ] || printf '%s\\n' "open: $1 tool=\${tool_name:-?} agent=\${agent_type:-?}" >> "$LOG" 2>/dev/null || true
152
- say "PURVIEW GUARDRAIL — DARK: $1. This call was NOT judged; the absence of a deny is an absence of a verdict, not a clean one."
153
- exit 0
154
- }
155
-
156
- # A PreToolUse hook must never break a session. An unexpected error (a nonzero status reaching
157
- # the trap) is let through AND SAID; every deliberate \`exit 0\` arrives with status 0.
96
+ open() { say "$GUARD_NAME — DARK: $1. This call was NOT judged."; exit 0; }
158
97
  trap 'rc=$?; [ "$rc" -eq 0 ] || open "an unexpected error stopped it (exit status $rc)"; exit 0' EXIT
159
98
 
160
99
  input="$(cat)"
161
100
  [ -n "$input" ] || open "the hook received no input"
162
- command -v jq >/dev/null 2>&1 || open "jq is not installed, so the hook payload cannot be read"
101
+ command -v jq >/dev/null 2>&1 || open "jq is not installed"
102
+ field() { printf '%s' "$input" | jq -r "$1" 2>/dev/null || true; }
163
103
 
164
- # A GUARD BINDS A PERSONA'S OWN MAIN SESSION AND NO SUBAGENT IT DISPATCHES. A subagent is bounded
165
- # by what it was handed, judged by its assay and the whole check, and supervised by the main
166
- # session. Claude Code fires this hook inside a subagent too (settings hooks and the subagent's
167
- # own front-matter hooks), and there the payload carries \`agent_id\` — present only inside a
168
- # subagent. A guard that does not bind there is not dark: it exits before it judges and says
169
- # nothing.
170
- [ -z "$(printf '%s' "$input" | jq -r '.agent_id // empty' 2>/dev/null || true)" ] || allow
104
+ # Only a subagent's payload carries agent_id; a guard binds the persona's own session.
105
+ [ -z "$(field '.agent_id // empty')" ] || allow
171
106
 
172
- # The sibling Stop hook's deployed dir owns the SHARED judge backend, resolved from
173
- # this script's own location exactly as the pre-hook resolves it — every hooks root
174
- # holds the hook dirs as siblings, so this derivation is true at every site this file
175
- # can land.
176
- SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
177
- HOOKS_ROOT="$(dirname -- "$SELF_DIR")"
178
- JUDGE_DIR="\${STANCE_GUARD_DIR:-$HOOKS_ROOT/stance-guardrail}"
107
+ HOOKS_ROOT="$(dirname -- "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)")"
179
108
  NEUTRAL_ROOT="$(dirname -- "$(dirname -- "$HOOKS_ROOT")")/.agents"
180
109
  RUBRIC="\${PURVIEW_RUBRIC:-$NEUTRAL_ROOT/purview-guardrail/purview-judge-prompt.md}"
181
- JUDGE_CMD="\${STANCE_JUDGE_CMD:-sh $JUDGE_DIR/stance-judge.sh}"
182
- LOG="\${PURVIEW_GUARD_LOG:-$SELF_DIR/misses.log}"
110
+ JUDGE_CMD="\${STANCE_JUDGE_CMD:-sh \${STANCE_GUARD_DIR:-$HOOKS_ROOT/stance-guardrail}/stance-judge.sh}"
183
111
 
184
- cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
185
- [ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true
112
+ cd "$(field '.cwd // empty')" 2>/dev/null || true
186
113
 
187
- # --- scope gate: the persona's OWN stance manifest -------------------------------------------
188
- # The scope is the one the harness hands over: \`stance_scope\` where a dispatcher sits in the
189
- # persona's own scope (omp), else the persona directory of the agent the payload NAMES
190
- # (\`agent_type\`, Claude Code — none on a bare session) under this harness's home, one hop above
191
- # the hooks root. Presence of the manifest is enrollment; no agent list lives here.
192
- stance_scope="$(printf '%s' "$input" | jq -r '.stance_scope // empty' 2>/dev/null || true)"
114
+ # Enrolled by the persona's stance manifest: \`stance_scope\` (omp) or \`agent_type\` (Claude Code).
115
+ stance_scope="$(field '.stance_scope // empty')"
193
116
  if [ -z "$stance_scope" ]; then
194
- named="$(printf '%s' "$input" | jq -r '.agent_type // empty' 2>/dev/null || true)"
117
+ named="$(field '.agent_type // empty')"
195
118
  case "$named" in '' | */* | . | ..) allow ;; esac
196
119
  stance_scope="$(dirname -- "$HOOKS_ROOT")/{{fact:harness-persona-root}}/$named"
197
120
  fi
198
121
  manifest="$stance_scope/{{fact:stance-manifest}}"
199
122
  [ -f "$manifest" ] || allow
200
- agent_type="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
201
- [ -n "$agent_type" ] || open "the stance manifest at $manifest names no agent, so there is no declared contract to judge against"
123
+ GUARD_AGENT="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
124
+ [ -n "$GUARD_AGENT" ] || open "the stance manifest at $manifest names no agent"
202
125
 
203
- # --- the LAW: this persona's own projected role contract -------------------------------------
204
- # A persona scope is \`<root>/personas/<name>\` (claude) or \`<root>/agent/personas/<name>\` (omp), and
205
- # the Target it was projected from is \`agents/<name>.md\` beside \`personas/\` — two levels out and
206
- # back down, the same shape of derivation the judge path above uses. Overridable for test rigs.
207
- AGENT_ROOT="$(dirname -- "$(dirname -- "$stance_scope")")"
208
- AGENT_MD="\${PURVIEW_AGENT_MD:-$AGENT_ROOT/agents/$agent_type.md}"
209
- [ -f "$AGENT_MD" ] || open "the agent's projected Target is not at $AGENT_MD, so its declared contract cannot be read"
126
+ AGENT_MD="\${PURVIEW_AGENT_MD:-$(dirname -- "$(dirname -- "$stance_scope")")/agents/$GUARD_AGENT.md}"
127
+ [ -f "$AGENT_MD" ] || open "the agent's Target is not at $AGENT_MD, so its contract cannot be read"
210
128
  contract="$(awk '/^## Role$/{f=1;next} /^## /{f=0} f' "$AGENT_MD" 2>/dev/null || true)"
211
- # A contract that is a BARE TOKEN states no arrow, so there is nothing to be outside
212
- # of. Only a multi-line contract is scoreable, and saying so here is what keeps the
213
- # gate from inventing a law for a corpus that has not declared one.
129
+ # A bare-token contract states no arrow.
214
130
  [ "$(printf '%s\\n' "$contract" | grep -c '[^[:space:]]')" -ge 2 ] || allow
215
131
 
216
- ${stanceGuardrailJudgeClip}
132
+ ${judgeClipShell}
133
+ GUARD_ACT="$(field '.tool_name // empty')"
134
+ [ -n "$GUARD_ACT" ] || open "the payload names no tool"
217
135
 
218
- # --- extract the judged payload, branched by act ---------------------------------------------
219
- tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
220
- [ -n "$tool_name" ] || open "the payload names no tool, so there is no call to judge"
221
-
222
- case "$tool_name" in
136
+ case "$GUARD_ACT" in
223
137
  Agent|SendMessage|Task)
224
- body="$(printf '%s' "$input" | jq -r '.tool_input.prompt // .tool_input.message // .tool_input.description // ""' 2>/dev/null || true)"
225
- target="$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // .tool_input.agent // .tool_input.name // ""' 2>/dev/null || true)"
138
+ body="$(field '.tool_input.prompt // .tool_input.message // .tool_input.description // ""')"
139
+ target="$(field '.tool_input.subagent_type // .tool_input.agent // .tool_input.name // ""')"
226
140
  act="DISPATCH to \\\`\${target:-unnamed}\\\`"
227
141
  ;;
228
142
  Write|Edit|MultiEdit|NotebookEdit)
229
- body="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.notebook_path // ""' 2>/dev/null || true)"
143
+ body="$(field '.tool_input.file_path // .tool_input.path // .tool_input.notebook_path // ""')"
144
+ # Writing the contest file is never refused.
145
+ case "$body" in */../* | */..) ;; "\${TMPDIR:-/tmp}/guardrail-contest/"*) allow ;; esac
230
146
  act="WRITE to $body"
231
147
  ;;
232
- *)
233
- # DEFENCE IN DEPTH. \`tool.use.pre\` fires on every tool on a harness with no
234
- # subject selector; these two classes are the only acts this contract can
235
- # convict, and a read is deliberately not one of them.
236
- allow ;;
148
+ *) allow ;;
237
149
  esac
238
-
239
150
  [ -n "\${body:-}" ] || allow
240
151
 
241
- # THE JUDGE IS SENT AT MOST JUDGE_PAYLOAD_CAP BYTES, the declared contract and the act included.
242
- # The contract is a role section a few kilobytes long, so it may hold half the cap at most; the
243
- # dispatch prompt takes what is left, keeping both ends because its instruction may sit at either,
244
- # and the seam is marked. The evidence check below then runs against exactly this payload, so a
245
- # span in text the excerpt elided is discarded like any span that is not there.
152
+ GUARD_SESSION="$(field '.session_id // empty')"
153
+ GUARD_CALL="$(printf '%s' "$input" | jq -c '.tool_input' 2>/dev/null || true)"
154
+ ${contestShell}
155
+ contest_heard
156
+
157
+ # The contract may hold half the cap; evidence is checked against exactly this payload.
246
158
  contract="$(printf '%s' "$contract" | judge_ends "$((JUDGE_PAYLOAD_CAP / 2))")"
247
- head_part="=== THE HOLDER'S DECLARED CONTRACT (agent: $agent_type) ===
159
+ head_part="=== THE HOLDER'S DECLARED CONTRACT (agent: $GUARD_AGENT) ===
248
160
  $contract
249
161
  === THE ACT ABOUT TO FIRE ===
250
162
  $act
@@ -252,61 +164,37 @@ $act
252
164
  body="$(printf '%s' "$body" | judge_ends "$((JUDGE_PAYLOAD_CAP - $(judge_bytes "$head_part")))")"
253
165
  payload="$head_part$body"
254
166
 
255
- # A refused call stays refused however often it is retried: there is no marker, no cap and
256
- # no memory of a prior denial. Every call is judged, a repeat of a refused call included.
257
-
258
- # --- judge (SHARED backend, OWN rubric) -----------------------------------------------------
259
- # THE SAME SEAM, UNDER THE SAME NAMES, as the two stance workers: a host that holds a model
260
- # runs this worker twice around a judgment it makes itself (\`STANCE_EMIT_PAYLOAD\` to receive
261
- # the payload and rubric, \`STANCE_VERDICT_FILE\` naming the answer). This one read
262
- # \`PURVIEW_\`-prefixed names of its own, so the omp bridge — which speaks the shared names —
263
- # got no payload back and purview judged nothing there, on every call, while reading as a
264
- # guard that had found nothing.
167
+ # A host holding a model runs this twice around its own judgment, under the names the omp bridge speaks.
265
168
  if [ -n "\${STANCE_EMIT_PAYLOAD:-}" ]; then
266
- jq -cn --arg r "$RUBRIC" --arg p "$payload" '{rubric:$r, payload:$p}'
169
+ jq -cn --arg r "$RUBRIC" --arg p "$payload" '{rubric:$r,payload:$p}'
267
170
  exit 0
268
171
  fi
269
172
  if [ -n "\${STANCE_VERDICT_FILE:-}" ]; then
270
- verdict="$(cat "\${STANCE_VERDICT_FILE}" 2>/dev/null || true)"
173
+ verdict="$(cat "$STANCE_VERDICT_FILE" 2>/dev/null || true)"
271
174
  [ -n "$verdict" ] || open "the judge did not answer"
272
175
  else
273
176
  verdict="$(printf '%s' "$payload" | $JUDGE_CMD "$RUBRIC" 2>/dev/null)" || open "the judge did not answer"
274
177
  fi
275
178
 
276
- decision="$(printf '%s\\n' "$verdict" | sed -n 's/^VERDICT:[[:space:]]*//p' | head -1 | sed 's/[[:space:]]*$//')"
277
- case "$decision" in
278
- PASS) allow ;; # judged, pass: the one silent verdict
179
+ said() { printf '%s\\n' "$verdict" | sed -n "s/^$1:[[:space:]]*//p" | head -1; }
180
+ case "$(said VERDICT | sed 's/[[:space:]]*$//')" in
181
+ PASS) allow ;;
279
182
  BLOCK) ;;
280
- *) open "the judge's verdict was unparseable (no VERDICT: PASS or VERDICT: BLOCK line)" ;;
183
+ *) open "the judge's verdict was unparseable" ;;
281
184
  esac
282
185
 
283
- reason="$(printf '%s\\n' "$verdict" | sed -n 's/^REASON:[[:space:]]*//p' | head -1)"
284
- [ -n "$reason" ] || reason="This act falls outside the arrow your role contract declares."
186
+ reason="$(said REASON)"
187
+ [ -n "$reason" ] || reason="This act is outside the arrow your role contract declares."
285
188
 
286
- # --- EVIDENCE CHECK: the cited span must be literally in the payload -------------------------
287
- # A block naming a span the payload does not contain is a hallucinated block, and a
288
- # fabricated refusal is not a lesser error than a missed one. Same rule, same reason as
289
- # the turn-end sibling.
290
- cited="$(printf '%s\\n' "$verdict" | sed -n 's/^EVIDENCE:[[:space:]]*//p' | head -1)"
189
+ cited="$(said EVIDENCE)"
291
190
  if [ -n "$cited" ]; then
292
191
  printf '%s' "$payload" | grep -qF -- "$cited" || {
293
- [ -z "\${LOG:-}" ] || printf '%s\\n' "block-discarded: span=$cited tool=$tool_name agent=$agent_type" >> "$LOG" 2>/dev/null || true
294
- say "PURVIEW GUARDRAIL — BLOCK DISCARDED: the judge blocked this call but cited a span that is not in it (fabricated): $cited. No verdict stands; this call was NOT judged clean."
192
+ say "$GUARD_NAME — BLOCK DISCARDED: the judge cited a span that is not in the call (fabricated): $cited. No verdict stands; this call was NOT judged."
295
193
  exit 0
296
194
  }
297
195
  fi
298
196
 
299
- feedback="PURVIEW GUARDRAIL — denied this $tool_name call: it falls outside the arrow your own \\
300
- role contract declares. $reason Delegation is a THEOREM of that arrow, not an option: an act whose \\
301
- domain or codomain lies outside it goes to the role that owns it — a decomposition of a shard to a \\
302
- planner, a build to an implementer, an artifact reading to an assayer, a merge or a whole check to \\
303
- the integrator. Hand a unit of the loop over by name and carry on with what the contract reserves. \\
304
- (Legitimate and NOT blocked here: reading anything, dispatching to a planner, routing a unit of the loop \\
305
- or a closed plan by its name to an implementer, an assayer, the integrator or the agent already running \\
306
- it, dispatching a request outside the loop to whichever role \\
307
- or agent fits it in your own rectified words where your contract admits it, and performing any act the \\
308
- contract's own \\\`reserves\\\` clause names.)"
309
-
197
+ feedback="$GUARD_NAME — denied this $GUARD_ACT call: $reason Hand an act outside your arrow to the role that owns it, a unit of the loop by name. $(contest_refused "$reason")"
310
198
  jq -cn --arg r "$feedback" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
311
199
  exit 0
312
200
  `,
@@ -1 +1 @@
1
- {"version":3,"file":"purview-guardrail.js","sourceRoot":"","sources":["../../src/hooks/purview-guardrail.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAEjE,qEAAqE;AACrE,EAAE;AACF,oEAAoE;AACpE,oFAAoF;AACpF,mFAAmF;AACnF,kFAAkF;AAClF,qBAAqB;AACrB,EAAE;AACF,iFAAiF;AACjF,uFAAuF;AACvF,uFAAuF;AACvF,uFAAuF;AACvF,mFAAmF;AACnF,wFAAwF;AACxF,EAAE;AACF,sFAAsF;AACtF,oFAAoF;AACpF,uFAAuF;AACvF,uFAAuF;AACvF,uFAAuF;AACvF,+EAA+E;AAC/E,sFAAsF;AACtF,yEAAyE;AACzE,EAAE;AACF,+DAA+D;AAC/D,4FAA4F;AAC5F,4FAA4F;AAC5F,0BAA0B;AAC1B,gFAAgF;AAChF,2FAA2F;AAC3F,yFAAyF;AACzF,4FAA4F;AAC5F,6FAA6F;AAC7F,yFAAyF;AACzF,yFAAyF;AACzF,8FAA8F;AAC9F,6FAA6F;AAC7F,8FAA8F;AAC9F,6FAA6F;AAC7F,yFAAyF;AACzF,yDAAyD;AACzD,EAAE;AACF,yFAAyF;AACzF,wFAAwF;AACxF,mFAAmF;AACnF,wFAAwF;AACxF,uFAAuF;AACvF,kFAAkF;AAClF,EAAE;AACF,4FAA4F;AAC5F,4FAA4F;AAC5F,6FAA6F;AAE7F,MAAM,CAAC,MAAM,gBAAgB,GAAa;IACxC,EAAE,EAAE,mBAAmB;IACvB,OAAO,EACL,uoBAAuoB;IACzoB,SAAS,EAAE,SAAS;IACpB,iFAAiF;IACjF,KAAK,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;IAC5B,KAAK,EAAE,CAAC;IACR,MAAM,EAAE,CAAC,uBAAuB,EAAE,cAAc,CAAC;IACjD,KAAK,EAAE,sBAAsB;IAC7B,OAAO,EAAE,EAAE;IACX,IAAI,EAAE,EAAE;IACR,OAAO,EAAE;QACP;YACE,QAAQ,EAAE,sBAAsB;YAChC,UAAU,EAAE,uDAAuD;YACnE,UAAU,EAAE,IAAI;YAChB,OAAO,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkJb,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgGzB;SACI;QACD;YACE,QAAQ,EAAE,yBAAyB;YACnC,UAAU,EAAE,0DAA0D;YACtE,UAAU,EAAE,KAAK;YACjB,iFAAiF;YACjF,2EAA2E;YAC3E,6EAA6E;YAC7E,8EAA8E;YAC9E,6BAA6B;YAC7B,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;;;;;;;;;;;;;;;;;;;;;;;CAuBd;SACI;KACF;CACF,CAAC"}
1
+ {"version":3,"file":"purview-guardrail.js","sourceRoot":"","sources":["../../src/hooks/purview-guardrail.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGjE,qEAAqE;AACrE,EAAE;AACF,oEAAoE;AACpE,oFAAoF;AACpF,mFAAmF;AACnF,kFAAkF;AAClF,qBAAqB;AACrB,EAAE;AACF,iFAAiF;AACjF,uFAAuF;AACvF,uFAAuF;AACvF,uFAAuF;AACvF,mFAAmF;AACnF,wFAAwF;AACxF,EAAE;AACF,sFAAsF;AACtF,oFAAoF;AACpF,uFAAuF;AACvF,uFAAuF;AACvF,uFAAuF;AACvF,+EAA+E;AAC/E,sFAAsF;AACtF,yEAAyE;AACzE,EAAE;AACF,+DAA+D;AAC/D,4FAA4F;AAC5F,4FAA4F;AAC5F,0BAA0B;AAC1B,gFAAgF;AAChF,2FAA2F;AAC3F,yFAAyF;AACzF,4FAA4F;AAC5F,6FAA6F;AAC7F,yFAAyF;AACzF,yFAAyF;AACzF,8FAA8F;AAC9F,6FAA6F;AAC7F,8FAA8F;AAC9F,6FAA6F;AAC7F,yFAAyF;AACzF,yDAAyD;AACzD,EAAE;AACF,0FAA0F;AAC1F,wFAAwF;AACxF,mFAAmF;AACnF,0FAA0F;AAC1F,2FAA2F;AAC3F,yFAAyF;AACzF,0FAA0F;AAC1F,iFAAiF;AACjF,EAAE;AACF,4FAA4F;AAC5F,4FAA4F;AAC5F,6FAA6F;AAE7F,MAAM,CAAC,MAAM,gBAAgB,GAAa;IACxC,EAAE,EAAE,mBAAmB;IACvB,OAAO,EACL,iyBAAiyB;IACnyB,SAAS,EAAE,SAAS;IACpB,iFAAiF;IACjF,KAAK,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;IAC5B,KAAK,EAAE,CAAC;IACR,MAAM,EAAE,CAAC,uBAAuB,EAAE,cAAc,CAAC;IACjD,KAAK,EAAE,sBAAsB;IAC7B,OAAO,EAAE,EAAE;IACX,IAAI,EAAE,EAAE;IACR,OAAO,EAAE;QACP;YACE,QAAQ,EAAE,sBAAsB;YAChC,UAAU,EAAE,uDAAuD;YACnE,UAAU,EAAE,IAAI;YAChB,OAAO,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4Db,cAAc;;;;;;;;;;;;;;;;;;;;;;EAsBd,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8Cb;SACI;QACD;YACE,QAAQ,EAAE,yBAAyB;YACnC,UAAU,EAAE,0DAA0D;YACtE,UAAU,EAAE,KAAK;YACjB,iFAAiF;YACjF,2EAA2E;YAC3E,6EAA6E;YAC7E,8EAA8E;YAC9E,6BAA6B;YAC7B,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;;;;;;;;;;;;;;;;;;;;;;;CAuBd;SACI;KACF;CACF,CAAC"}