@cratylus/canon 0.12.2 → 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.
- package/dist/guard-shell.d.ts +43 -0
- package/dist/guard-shell.js +85 -0
- package/dist/guard-shell.js.map +1 -0
- package/dist/hooks/purview-guardrail.js +63 -175
- package/dist/hooks/purview-guardrail.js.map +1 -1
- package/dist/hooks/stance-guardrail-pre.js +46 -146
- package/dist/hooks/stance-guardrail-pre.js.map +1 -1
- package/dist/hooks/stance-guardrail.d.ts +0 -2
- package/dist/hooks/stance-guardrail.js +90 -612
- package/dist/hooks/stance-guardrail.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 {
|
|
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,
|
|
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.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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 ∧
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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' "$
|
|
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
|
|
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
|
-
#
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
185
|
-
[ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true
|
|
112
|
+
cd "$(field '.cwd // empty')" 2>/dev/null || true
|
|
186
113
|
|
|
187
|
-
#
|
|
188
|
-
|
|
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="$(
|
|
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
|
-
|
|
201
|
-
[ -n "$
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
${
|
|
132
|
+
${judgeClipShell}
|
|
133
|
+
GUARD_ACT="$(field '.tool_name // empty')"
|
|
134
|
+
[ -n "$GUARD_ACT" ] || open "the payload names no tool"
|
|
217
135
|
|
|
218
|
-
|
|
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="$(
|
|
225
|
-
target="$(
|
|
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="$(
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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: $
|
|
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
|
|
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,
|
|
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 "
|
|
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
|
-
|
|
277
|
-
case "$
|
|
278
|
-
PASS) allow ;;
|
|
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
|
|
183
|
+
*) open "the judge's verdict was unparseable" ;;
|
|
281
184
|
esac
|
|
282
185
|
|
|
283
|
-
reason="$(
|
|
284
|
-
[ -n "$reason" ] || reason="This act
|
|
186
|
+
reason="$(said REASON)"
|
|
187
|
+
[ -n "$reason" ] || reason="This act is outside the arrow your role contract declares."
|
|
285
188
|
|
|
286
|
-
|
|
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
|
-
|
|
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="
|
|
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":"
|
|
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"}
|