@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.
- 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 +121 -600
- package/dist/hooks/stance-guardrail.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,65 +1,10 @@
|
|
|
1
1
|
import { anchorOf } from '@cratylus/schema';
|
|
2
2
|
import { handoff } from '../dimensions/autonomy/handoff.js';
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// byte-
|
|
7
|
-
//
|
|
8
|
-
// realizes `event` → a `settings.json` `{hooks}` merge + `hooks/<id>/` workers.
|
|
9
|
-
// THE ONE CAP ON WHAT A GUARD'S JUDGE IS SENT, declared here and stamped into all three
|
|
10
|
-
// workers (this one, `stance-guardrail-pre`, `purview-guardrail`). A judgement has to fit
|
|
11
|
-
// the time its harness allows a guard — omp kills an extension handler at 30 s, a Claude
|
|
12
|
-
// Code cell runs 60 s — and the payload was unbounded: the Stop worker sent every assistant
|
|
13
|
-
// message since the last operator message and the whole operator message, the pre and purview
|
|
14
|
-
// workers the whole dispatch prompt or menu. Bytes, not characters: the limit is the size of
|
|
15
|
-
// what crosses to the judge. The pre and purview workers take the shell below from the
|
|
16
|
-
// exports at the foot of this module rather than restating either.
|
|
17
|
-
const judgePayloadCapBytes = 12_000;
|
|
18
|
-
// The shell every worker carries to honour the cap. Written with `String.raw` so its
|
|
19
|
-
// backslashes reach the worker verbatim, and free of backticks and `${` for the same reason.
|
|
20
|
-
// `jq` does the cutting because it is already a dependency of every worker and counts a
|
|
21
|
-
// string in characters and in bytes (`utf8bytelength`), so a cut never lands inside a
|
|
22
|
-
// character: the result is valid UTF-8 and never over the bound. Text over its share is
|
|
23
|
-
// cut and MARKED where it was cut — the judge is told it reads an excerpt and cannot take
|
|
24
|
-
// the seam for the agent's own words.
|
|
25
|
-
const judgeClipShell = String.raw `# THE JUDGE IS SENT A BOUNDED EXCERPT, never the whole text. A judgement has to fit inside the
|
|
26
|
-
# time its harness allows a guard, and the text a turn or a dispatch carries has no bound of its own.
|
|
27
|
-
# One cap, declared once in the stance cell, bounds everything sent together: the standing
|
|
28
|
-
# directive, the operator's message, the agent turn and any layer-1 note. Text over its share is
|
|
29
|
-
# cut on a character boundary and marked where it was cut.
|
|
30
|
-
JUDGE_PAYLOAD_CAP=${judgePayloadCapBytes}
|
|
31
|
-
judge_bytes() { printf '%s' "$1" | wc -c | tr -d ' '; }
|
|
32
|
-
JUDGE_JQ='
|
|
33
|
-
def pre($n): . as $s | if $n <= 0 then "" else
|
|
34
|
-
{ lo: 0, hi: ($s | length) } | until(.lo >= .hi;
|
|
35
|
-
((.lo + .hi + 1) / 2 | floor) as $m
|
|
36
|
-
| if ($s[:$m] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end)
|
|
37
|
-
| $s[:.lo] end;
|
|
38
|
-
def suf($n): . as $s | if $n <= 0 then "" else
|
|
39
|
-
{ lo: 0, hi: ($s | length) } | until(.lo >= .hi;
|
|
40
|
-
((.lo + .hi + 1) / 2 | floor) as $m
|
|
41
|
-
| if ($s[-$m:] | utf8bytelength) <= $n then .lo = $m else .hi = $m - 1 end)
|
|
42
|
-
| if .lo == 0 then "" else $s[-.lo:] end end;
|
|
43
|
-
'
|
|
44
|
-
# Text is read with --rawfile from stdin and never with -R: jq 1.7's raw reader corrupts a
|
|
45
|
-
# multibyte character that straddles one of its read boundaries, on anything over a few KB.
|
|
46
|
-
# The final N bytes of stdin, or all of it when it fits — no marker, so a caller can count them.
|
|
47
|
-
judge_keep() { jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"' $text | suf($n)'; }
|
|
48
|
-
# Stdin cut to at most N bytes by keeping its head and its tail, the seam marked. For a dispatch
|
|
49
|
-
# prompt or a menu, whose instruction may sit at either end.
|
|
50
|
-
judge_ends() {
|
|
51
|
-
jq -n -j --rawfile text /dev/stdin --argjson n "$1" "$JUDGE_JQ"'
|
|
52
|
-
$text | . as $s | ($s | utf8bytelength) as $t
|
|
53
|
-
| if $t <= $n then $s else
|
|
54
|
-
((($n - 200) / 2) | floor) as $k
|
|
55
|
-
| ($s | pre($k)) as $h | ($s | suf($k)) as $e
|
|
56
|
-
| ($t - ($h | utf8bytelength) - ($e | utf8bytelength)) as $gone
|
|
57
|
-
| $h + "\n[ELIDED: \($gone) of \($t) bytes from the middle are not shown]\n" + $e
|
|
58
|
-
end'
|
|
59
|
-
}
|
|
60
|
-
# The marker for text cut off its front: judge_cut WHAT TOTAL-BYTES SHOWN-BYTES.
|
|
61
|
-
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"; }
|
|
62
|
-
`;
|
|
3
|
+
import { contestShell, judgeClipShell, judgeTailShell, } from '../guard-shell.js';
|
|
4
|
+
// stance-guardrail — the turn-end guard of the principal stance. Its `workers[].content` are
|
|
5
|
+
// the byte-anchors the committed workers under `targets/guardrail/` regenerate from
|
|
6
|
+
// (`test/hook-rule-boundary.test.ts` byte-locks them); the claude adapter realizes `event`
|
|
7
|
+
// as a `settings.json` hook merge plus `hooks/<id>/` workers.
|
|
63
8
|
export const stanceGuardrail = {
|
|
64
9
|
id: 'stance-guardrail',
|
|
65
10
|
residue: 'structural-refusal ↾ turn-end · block ⟨intent-driven-expert-collapse⟩ ⟨permission-seeking · own-judgment-deferral · order-taking⟩ · pass ⟨reserved · irreversible-outward ↦ consent · intent-ambiguity ↦ elicit · ∄ mandate ↦ surface ⟨electing the objective ∉ in-remit⟩⟩ · harness-invariant ⟨prompt-identity erodes ↾ RLHF-corrigibility⟩',
|
|
@@ -78,83 +23,16 @@ export const stanceGuardrail = {
|
|
|
78
23
|
targetPath: 'packages/canon/targets/guardrail/stance-guardrail.sh',
|
|
79
24
|
executable: true,
|
|
80
25
|
content: `#!/usr/bin/env sh
|
|
81
|
-
# stance-guardrail
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# WHY THIS EXISTS (the harness half of the principal stance):
|
|
85
|
-
# Encoding the principal stance as IDENTITY (Nico's half) raises the threshold but is not
|
|
86
|
-
# truly invariant — enough operator pushback erodes any prompt-level stance. NOTE the original
|
|
87
|
-
# justification here blamed "RLHF corrigibility reads a correction as 'defer more'"; that is
|
|
88
|
-
# OVER-ATTRIBUTED and is corrected rather than deleted, since it was load-bearing for the cap
|
|
89
|
-
# that has now been removed. Perez et al. measure sycophancy as "similar for models trained
|
|
90
|
-
# with various numbers of RL steps, including 0" — it is not RLHF-specific — and the stronger
|
|
91
|
-
# claim, that a correction RAISES deference on later unrelated work, is unmeasured. The nearest
|
|
92
|
-
# result finds carryover that dissolves across topic change and is SYMMETRIC: non-deferential
|
|
93
|
-
# states self-perpetuate too. So a corrective gate is not self-defeating by construction.
|
|
94
|
-
# TRUE invariance needs the harness to
|
|
95
|
-
# refuse the collapsed turn. This is that refusal: on Stop, it judges the last assistant turn
|
|
96
|
-
# against the stance rubric and BLOCKS (Claude Code Stop-hook \`{"decision":"block"}\`) when it
|
|
97
|
-
# detects collapse, feeding corrective feedback that tells the agent to re-assume the stance.
|
|
98
|
-
#
|
|
99
|
-
# WHAT IT BLOCKS (collapse signals — see stance-judge-prompt.md for the full rubric):
|
|
100
|
-
# - permission-seeking for in-remit, reversible work ("should I…?", option-menus)
|
|
101
|
-
# - deferring the agent's own expert judgment (naming/design/architecture/how) to the operator
|
|
102
|
-
# - echoing / order-taking the operator's literal words instead of extracting+serving intent
|
|
103
|
-
# WHAT IT DOES NOT BLOCK (the reserved set):
|
|
104
|
-
# - surfacing a genuine irreversible-outward act (deploy/push/publish) for consent
|
|
105
|
-
# - routing a genuine INTENT ambiguity to /elicit
|
|
106
|
-
# - surfacing an ABSENT mandate (no objective in the input AND an empty inherited work-set):
|
|
107
|
-
# electing the session's objective is supplying intent, not sequencing — the operator's to own
|
|
108
|
-
#
|
|
109
|
-
# SAFETY MODEL:
|
|
110
|
-
# - SCOPE-ENROLLED. Fires for a scope carrying a stance manifest, and for no other. Enrollment
|
|
111
|
-
# is PRESENCE: the projection places a manifest in each persona's own scope, so composing the
|
|
112
|
-
# cell enrolls the persona and nothing central lists anybody. A bare launch carries the
|
|
113
|
-
# dispatcher and no manifest, and is therefore silent by placement.
|
|
114
|
-
# - NO REPO OPT-IN. There was one, per-repo in .git/config, and it asked the wrong question:
|
|
115
|
-
# whether a guard may run in a DIRECTORY. The off switch is launching \`omp\` rather than a
|
|
116
|
-
# persona — declining to BE the agent, instead of being it unjudged.
|
|
117
|
-
# - FAILS OPEN, BUT NEVER SILENTLY-CLEAN. Any error → exit 0 (allow stop): a guardrail that
|
|
118
|
-
# wedges work on its own flakiness is worse than a missed block. But a turn let through
|
|
119
|
-
# WITHOUT A VERDICT says so where the operator reads it (\`say\`: Claude Code's JSON
|
|
120
|
-
# \`systemMessage\`, omp's relayed line), naming this guard and why it could not judge —
|
|
121
|
-
# no jq, no input, an unreadable transcript or rubric, a judge that does not answer or
|
|
122
|
-
# answers unparseably, a block it could not verify, an unexpected error. Silence is reserved for
|
|
123
|
-
# "judged, no collapse" and for "not enrolled" (no persona manifest in scope, or nothing
|
|
124
|
-
# judgeable in the turn): there, not-checking is the correct answer, not a failure to report.
|
|
125
|
-
# - A REFUSAL HOLDS. Judges every turn, one identical to a refused turn included, and a BLOCK
|
|
126
|
-
# blocks the stop however many blocks came before: nothing remembers a refusal to wave a retry
|
|
127
|
-
# through, nothing counts blocks to stop blocking. The block count is a count in the reason.
|
|
128
|
-
# - POSITION-SOUND. Every rubric rule that can fire is a claim about the turn's CLOSE. So the
|
|
129
|
-
# L1 window and the EVIDENCE check both run against \`asst_close\` — the text AFTER the last
|
|
130
|
-
# tool call — never the whole-turn blob. A span from a mid-turn preamble is out of scope for
|
|
131
|
-
# a verdict about how the turn ended, however genuinely present those characters are.
|
|
132
|
-
#
|
|
133
|
-
# INPUT : Claude Code Stop hook JSON on stdin (transcript_path, stop_hook_active,
|
|
134
|
-
# agent_type, session_id, cwd, …).
|
|
135
|
-
# OUTPUT : on collapse → stdout \`{"decision":"block","reason":"…"}\` + exit 0 (Stop-hook block).
|
|
136
|
-
# judged, no collapse → no stdout + exit 0 (allow stop).
|
|
137
|
-
# let through with no verdict → the notice \`say\` prints + exit 0 (allow stop).
|
|
138
|
-
#
|
|
139
|
-
# POSIX sh. Depends on: jq (transcript parse). Missing jq → the guard says so and allows the stop.
|
|
140
|
-
|
|
26
|
+
# stance-guardrail: a Stop hook that blocks a turn collapsing out of the intent-driven-expert stance.
|
|
27
|
+
# FAILS OPEN, BUT NEVER SILENTLY-CLEAN: what stops it judging lets the stop through with a notice and a DARK row.
|
|
141
28
|
set -eu
|
|
142
29
|
|
|
143
|
-
#
|
|
144
|
-
# command. The first thing that can be missing is \`jq\`, and a worker run with a PATH holding
|
|
145
|
-
# little beyond \`sh\` and \`cat\` must still say it did not judge — so \`esc\`, \`say\` and \`dark\`
|
|
146
|
-
# are builtins only, and nothing external runs above the jq check.
|
|
147
|
-
#
|
|
148
|
-
# Claude Code shows the operator a hook's JSON \`systemMessage\` and NOT its plain stdout on
|
|
149
|
-
# exit 0, so a notice printed as a bare line reaches nobody there. omp's extension bridge
|
|
150
|
-
# reads the worker's plain stdout and relays a line naming this guard and DARK to the
|
|
151
|
-
# session, and would relay a JSON object as raw JSON — hence one form per harness, chosen at
|
|
152
|
-
# projection by the harness's own name.
|
|
30
|
+
# esc, say and dark use builtins only: they must work when jq is missing.
|
|
153
31
|
esc() {
|
|
154
32
|
_in="$1"
|
|
155
33
|
_out=""
|
|
156
|
-
_nl=
|
|
157
|
-
|
|
34
|
+
_nl='
|
|
35
|
+
'
|
|
158
36
|
_tab="$(printf '\\t')"
|
|
159
37
|
_cr="$(printf '\\r')"
|
|
160
38
|
while [ -n "$_in" ]; do
|
|
@@ -179,170 +57,83 @@ say() {
|
|
|
179
57
|
esac
|
|
180
58
|
}
|
|
181
59
|
|
|
182
|
-
# A Stop hook must never break a session. An unexpected error (a nonzero status reaching the
|
|
183
|
-
# trap) is let through AND SAID; every deliberate \`exit 0\` arrives here with status 0 and stays
|
|
184
|
-
# as quiet as it chose to be.
|
|
185
60
|
trap 'rc=$?; [ -z "\${view:-}" ] || rm -f "$view" 2>/dev/null; [ "$rc" -eq 0 ] || dark "an unexpected error stopped it (exit status $rc)"; exit 0' EXIT
|
|
186
61
|
|
|
187
|
-
allow_stop() { exit 0; }
|
|
188
|
-
|
|
189
|
-
# A VERDICT and a FAILURE are different facts, and silence can only carry one of them.
|
|
190
|
-
# \`allow_stop\` means "checked, no collapse". It must never also be the answer to "could
|
|
191
|
-
# not check" — that is a bypass by omission: with the judge unreachable the guardrail
|
|
192
|
-
# reports a clean turn forever and nothing ever says the guardrail went dark. Exactly the
|
|
193
|
-
# defect fixed one file over in the memory nudge ("a broken runtime read as a clean bill
|
|
194
|
-
# of health, silently and forever"); the inversion is the same — \`if signal absent then
|
|
195
|
-
# pass\` becomes \`if signal absent then SAY SO\` — and, as there, it still never wedges the
|
|
196
|
-
# turn. Reached only for a guard that is IN SCOPE and could not judge: a scope carrying no
|
|
197
|
-
# persona manifest stays silent.
|
|
62
|
+
allow_stop() { exit 0; }
|
|
63
|
+
row() { [ -z "\${verdict_log:-}" ] || printf '%s\\t%s\\t%s\\n' "$1" "$2" "$3" >> "$verdict_log" 2>/dev/null || true; }
|
|
198
64
|
dark() {
|
|
199
|
-
|
|
200
|
-
# DARK row are both evidence the guard RAN; only an EMPTY log means it never
|
|
201
|
-
# did. Recording judged turns alone made "not judged" and "no session" read
|
|
202
|
-
# identically — measured on a live host: an endpoint behind the advisor role
|
|
203
|
-
# accepted connections and never answered, and five hours of one session were
|
|
204
|
-
# unjudged while leaving no trace whatsoever to notice it by.
|
|
205
|
-
if [ -n "\${verdict_log:-}" ]; then
|
|
206
|
-
printf 'DARK\\t\\t%s\\n' "$1" >> "$verdict_log" 2>/dev/null || true
|
|
207
|
-
fi
|
|
65
|
+
row DARK "" "$1"
|
|
208
66
|
say "STANCE GUARDRAIL — DARK: $1. This turn was NOT judged; the absence of a block is an absence of a verdict, not a clean one."
|
|
209
67
|
exit 0
|
|
210
68
|
}
|
|
211
69
|
|
|
212
|
-
# --- read hook input ------------------------------------------------------------------------
|
|
213
70
|
input="$(cat)"
|
|
214
71
|
[ -n "$input" ] || dark "the hook received no input"
|
|
215
|
-
|
|
216
72
|
command -v jq >/dev/null 2>&1 || dark "jq is not installed, so the hook payload cannot be read"
|
|
73
|
+
field() { printf '%s' "$input" | jq -r "$1" 2>/dev/null || true; }
|
|
217
74
|
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
# session. Claude Code fires this hook inside a subagent too (settings hooks and the subagent's
|
|
221
|
-
# own front-matter hooks), and there the payload carries \`agent_id\` — present only inside a
|
|
222
|
-
# subagent. A guard that does not bind there is not dark: it exits before it judges and says
|
|
223
|
-
# nothing.
|
|
224
|
-
[ -z "$(printf '%s' "$input" | jq -r '.agent_id // empty' 2>/dev/null || true)" ] || allow_stop
|
|
75
|
+
# Only a subagent's payload carries agent_id; the guard binds main sessions.
|
|
76
|
+
[ -z "$(field '.agent_id // empty')" ] || allow_stop
|
|
225
77
|
|
|
226
78
|
SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
227
79
|
JUDGE_CMD="\${STANCE_JUDGE_CMD:-sh $SELF_DIR/stance-judge.sh}"
|
|
228
|
-
|
|
229
|
-
# THE RUBRIC LIVES AT THE VENDOR-NEUTRAL ROOT, and is reached by DERIVATION so it
|
|
230
|
-
# follows whatever home this copy was deployed under (a real \$HOME, or a test's
|
|
231
|
-
# \`--home\`). This worker sits at \`<harness-home>/hooks/<id>/\`, and \`.agents\` is
|
|
232
|
-
# the SIBLING of every harness home — the same relation deploy already uses to
|
|
233
|
-
# place agents and skills — so three \`dirname\`s and a name is the whole address.
|
|
234
|
-
#
|
|
235
|
-
# Derived rather than baked: a literal \`\$HOME/.agents\` would expand correctly on
|
|
236
|
-
# a host and point outside a sandboxed deploy, which is the class of bug that put
|
|
237
|
-
# \`\$HOME/.claude\` in the sibling worker and made it read another harness's tree.
|
|
238
80
|
NEUTRAL_ROOT="$(dirname -- "$(dirname -- "$(dirname -- "$SELF_DIR")")")/.agents"
|
|
239
|
-
# The DEFAULT rubric. A persona's own manifest may name another — that is the
|
|
240
|
-
# strategy seam: one opinionated implementation, each persona free to bind a
|
|
241
|
-
# different contract without the dispatcher learning anything about either.
|
|
242
81
|
RUBRIC="\${STANCE_RUBRIC:-$NEUTRAL_ROOT/stance-guardrail/stance-judge-prompt.md}"
|
|
243
82
|
|
|
244
|
-
|
|
245
|
-
# EVERY TURN IS JUDGED, AND A BLOCK BLOCKS THE STOP HOWEVER MANY BLOCKS CAME BEFORE IT. A turn
|
|
246
|
-
# identical to one already refused is judged again like any other, and blocked again when the
|
|
247
|
-
# judge blocks it; nothing remembers a refusal in order to wave a retry through, and nothing counts
|
|
248
|
-
# blocks in order to stop blocking. The retry law is the same one the pre-call guard keeps.
|
|
249
|
-
#
|
|
250
|
-
# This SUPERSEDES the original \`stop_hook_active=true → allow_stop\` short-circuit, which was not a
|
|
251
|
-
# loop guard but a hole: the agent got blocked, emitted anything, and ended again UNJUDGED — and it
|
|
252
|
-
# made the rubric's judgement of a response to a verdict dead code, since that response was the one
|
|
253
|
-
# turn guaranteed never to reach the judge. It also superseded two caps that let a refused turn
|
|
254
|
-
# through (a turn byte-identical to the last blocked, and a turn judged collapsed after a run of
|
|
255
|
-
# consecutive blocks). A safety check must never be a function of how many times it has fired:
|
|
256
|
-
# Ethernet, TCP, systemd and CrashLoopBackOff cap EFFORT and end in a loud, typed refusal, and
|
|
257
|
-
# permission is never what gets spent. What a block loop ends on is a turn the judge passes.
|
|
258
|
-
#
|
|
259
|
-
# The state in the tmp dir, keyed by session id, is a COUNT of the blocks, carried in the reason
|
|
260
|
-
# the agent reads and in the verdict log. It decides nothing, so a dir it cannot write costs the
|
|
261
|
-
# count and never a block.
|
|
83
|
+
RUN_CAP=3
|
|
262
84
|
session="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null || echo nosession)"
|
|
263
85
|
state_dir="\${TMPDIR:-/tmp}/stance-guardrail"
|
|
264
86
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
265
|
-
|
|
87
|
+
run_file="$state_dir/$session.run"
|
|
266
88
|
verdict_log="$state_dir/$session.verdicts"
|
|
267
89
|
|
|
268
|
-
|
|
269
|
-
|
|
90
|
+
stop_active="$(field 'if .stop_hook_active == true then "yes" else "" end')"
|
|
91
|
+
run=0
|
|
92
|
+
run_hash=none
|
|
93
|
+
run_known=1
|
|
94
|
+
if [ -n "$stop_active" ] && [ -e "$run_file" ]; then
|
|
95
|
+
run_state="$(cat "$run_file" 2>/dev/null || true)"
|
|
96
|
+
run="\${run_state%% *}"
|
|
97
|
+
run_hash="\${run_state#* }"
|
|
98
|
+
case "$run" in '' | *[!0-9]*) run=0; run_hash=none; run_known=0 ;; esac
|
|
99
|
+
fi
|
|
270
100
|
|
|
271
|
-
|
|
272
|
-
# DIRECTORY", which is a category error: a stance is a property of the agent, not of the checkout
|
|
273
|
-
# it happens to be standing in. Worse, it made the stance OPTIONAL AT RUNTIME for an agent already
|
|
274
|
-
# launched as itself — the ambient form, when the whole reason this harness half exists is that
|
|
275
|
-
# identity alone erodes. The off switch is launching \`omp\` instead of \`mav\`: declining to be the
|
|
276
|
-
# persona, rather than being it unjudged.
|
|
277
|
-
cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)"
|
|
101
|
+
cwd="$(field '.cwd // empty')"
|
|
278
102
|
[ -n "$cwd" ] && cd "$cwd" 2>/dev/null || true
|
|
279
103
|
|
|
280
|
-
#
|
|
281
|
-
|
|
282
|
-
# gate used to violate it outright. It read an allowlist, \`nico mav\` by default and overridable
|
|
283
|
-
# by \`STANCE_GUARD_AGENTS\`, which is precisely the runtime self-filter that clause forbids: a
|
|
284
|
-
# module asking WHO IS RUNNING rather than being placed where only the right launch reaches it.
|
|
285
|
-
#
|
|
286
|
-
# It had already drifted, in the direction such a list always drifts. Every agent in the corpus
|
|
287
|
-
# composes \`handoff\`, so \`Binding.agents\` DERIVES all six as bound, and six persona scopes are
|
|
288
|
-
# deployed to prove it — while the list enforced two. \`architect\` and \`kino\` held principal
|
|
289
|
-
# authority and were never once judged, and nothing surfaced the gap because the two facts were
|
|
290
|
-
# written in different languages in different files.
|
|
291
|
-
#
|
|
292
|
-
# Enrollment is now PRESENCE. Each bound persona's projection lands a manifest in that persona's
|
|
293
|
-
# own scope, and the scope reaches this worker in the one form its harness offers. omp's dispatcher
|
|
294
|
-
# lives in that same scope and therefore already knows it — it passes it in the envelope as
|
|
295
|
-
# \`stance_scope\`. Claude Code places no dispatcher; its hook payload NAMES the running agent as
|
|
296
|
-
# \`agent_type\` (on the main thread of a \`--agent\` session, and on neither for a
|
|
297
|
-
# bare session), so the scope is the persona's directory under this harness's own home, one hop
|
|
298
|
-
# above the hooks root this worker was deployed into. Either way the scope is then read the same:
|
|
299
|
-
# A manifest means enrolled, and carries THIS agent's contract: its rubric, its moments, its
|
|
300
|
-
# handoff laws. No manifest means not enrolled, which is silence rather than an error, so a plain
|
|
301
|
-
# session, a built-in agent or a host's own agent — none of which declared itself anything — stays
|
|
302
|
-
# untouched exactly as it was. Adding a persona enrolls it; adding a gated dimension edits one
|
|
303
|
-
# persona's manifest; neither is a line in this file, and this file carries no agent list.
|
|
304
|
-
stance_scope="$(printf '%s' "$input" | jq -r '.stance_scope // empty' 2>/dev/null || true)"
|
|
104
|
+
# A stance manifest in the persona's scope enrolls it: omp passes the scope, Claude Code the agent_type.
|
|
105
|
+
stance_scope="$(field '.stance_scope // empty')"
|
|
305
106
|
if [ -z "$stance_scope" ]; then
|
|
306
|
-
named="$(
|
|
107
|
+
named="$(field '.agent_type // empty')"
|
|
307
108
|
case "$named" in '' | */* | . | ..) allow_stop ;; esac
|
|
308
109
|
stance_scope="$(dirname -- "$(dirname -- "$SELF_DIR")")/{{fact:harness-persona-root}}/$named"
|
|
309
110
|
fi
|
|
310
111
|
manifest="$stance_scope/{{fact:stance-manifest}}"
|
|
311
112
|
[ -f "$manifest" ] || allow_stop
|
|
312
113
|
|
|
313
|
-
# The persona's own contract, each field falling back to the shipped default. A manifest that
|
|
314
|
-
# names nothing still enrolls: presence is the assertion, the fields are the refinement.
|
|
315
|
-
agent_type="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
|
|
316
114
|
manifest_rubric="$(jq -r '.rubric // empty' "$manifest" 2>/dev/null || true)"
|
|
317
|
-
# An OVERRIDE that does not resolve is a defect and goes DARK — a persona that asked to be judged
|
|
318
|
-
# against its own contract and is silently judged against someone else's has been mis-scored, not
|
|
319
|
-
# spared. The DEFAULT is not checked here: a judge backend need not read the file at all (the
|
|
320
|
-
# fixture judge does not), and refusing to run because a path the manifest never named is absent
|
|
321
|
-
# would fail closed on a case the manifest made no claim about.
|
|
322
115
|
case "$manifest_rubric" in
|
|
323
116
|
'') ;;
|
|
324
117
|
/*) RUBRIC="$manifest_rubric" ;;
|
|
325
118
|
*) RUBRIC="$(dirname -- "$manifest")/$manifest_rubric" ;;
|
|
326
119
|
esac
|
|
327
120
|
[ -z "$manifest_rubric" ] || [ -f "$RUBRIC" ] || \\
|
|
328
|
-
dark "the rubric named by $manifest is not readable at '
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# that text — so every extraction below sees the whole turn and the original file is never touched.
|
|
345
|
-
last="$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null || true)"
|
|
121
|
+
dark "the rubric named by $manifest is not readable at '$RUBRIC'"
|
|
122
|
+
|
|
123
|
+
GUARD_ID=stance-guardrail
|
|
124
|
+
GUARD_NAME="STANCE GUARDRAIL"
|
|
125
|
+
GUARD_SESSION="$session"
|
|
126
|
+
GUARD_AGENT="$(jq -r '.agent // empty' "$manifest" 2>/dev/null || true)"
|
|
127
|
+
GUARD_ACT=stop
|
|
128
|
+
${contestShell}
|
|
129
|
+
contest_heard
|
|
130
|
+
|
|
131
|
+
${judgeClipShell}${judgeTailShell}
|
|
132
|
+
|
|
133
|
+
transcript="$(field '.transcript_path // empty')"
|
|
134
|
+
[ -n "$transcript" ] && [ -f "$transcript" ] || dark "no readable transcript at '$transcript'"
|
|
135
|
+
|
|
136
|
+
last="$(field '.last_assistant_message // empty')"
|
|
346
137
|
if [ -n "$last" ]; then
|
|
347
138
|
present="$(jq -rs --arg t "$last" '
|
|
348
139
|
[ .[] | select(.type == "assistant") ] | last
|
|
@@ -358,254 +149,73 @@ if [ -n "$last" ]; then
|
|
|
358
149
|
fi
|
|
359
150
|
fi
|
|
360
151
|
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
|
|
386
|
-
| last // -1 ) as $lastuser
|
|
387
|
-
| [ $all[($lastuser+1):][]
|
|
388
|
-
| select(.type == "assistant")
|
|
389
|
-
| (.message.content // []) as $c
|
|
390
|
-
| ( $c | map(select(.type == "text") | .text) | join("\\n") ) as $t
|
|
391
|
-
| ( $c | map(select(.type == "tool_use") | .name) | join(", ") ) as $tools
|
|
392
|
-
| if $t == "" and $tools == "" then empty
|
|
393
|
-
elif $tools == "" then $t
|
|
394
|
-
elif $t == "" then "[tools: \\($tools)]"
|
|
395
|
-
else "\\($t)\\n[tools: \\($tools)]" end
|
|
396
|
-
]
|
|
397
|
-
| join("\\n\\n")
|
|
398
|
-
' "$transcript" 2>/dev/null || true)"
|
|
399
|
-
|
|
400
|
-
# Text-only projection of the same turn — what the agent actually WROTE. The EVIDENCE check must
|
|
401
|
-
# grep this, never the tool-annotated form, so a fabricated span cannot be satisfied by a marker.
|
|
402
|
-
asst_text="$(jq -rs '
|
|
403
|
-
[ .[] ] as $all
|
|
404
|
-
| ( [ range(0; ($all|length))
|
|
405
|
-
| select( $all[.].type=="user"
|
|
406
|
-
and ( ($all[.].message.content | type) == "string"
|
|
407
|
-
or ( $all[.].message.content | map(.type) | index("text") != null ) ) ) ]
|
|
408
|
-
| last // -1 ) as $lastuser
|
|
409
|
-
| [ $all[($lastuser+1):][] | select(.type == "assistant")
|
|
410
|
-
| (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
|
|
411
|
-
| map(select(. != "")) | join("\\n\\n")
|
|
152
|
+
# One transcript read: asst is the turn with tool calls marked, text its words alone, operator the
|
|
153
|
+
# last real operator message, standing the utterance that set the loop position.
|
|
154
|
+
parts="$(jq -rsc '
|
|
155
|
+
def blocks(k): (.message.content // []) | map(select(.type == k));
|
|
156
|
+
def said: blocks("text") | map(.text) | join("\\n");
|
|
157
|
+
def texts: if type == "string" then . elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n")) else "" end;
|
|
158
|
+
def keep(re): map(select(test(re) | not));
|
|
159
|
+
. as $all
|
|
160
|
+
| ([ range(0; length) | select($all[.].type == "user" and (($all[.].message.content | type) == "string" or ($all[.].message.content | map(.type) | index("text") != null))) ] | last // -1) as $u
|
|
161
|
+
| [ $all[$u + 1:][] | select(.type == "assistant") | { t: said, tools: (blocks("tool_use") | map(.name) | join(", ")) } ] as $turn
|
|
162
|
+
| ([ $all[] | select(.type == "user") | .message.content | texts ] | map(select(. != ""))
|
|
163
|
+
| keep("^\\\\s*<system-reminder>") | keep("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | keep("<task-notification>")) as $users
|
|
164
|
+
| ($users | map(select(test("(^|[^[:alpha:]])(carry[- ]?on|weitermachen|proceed)([^[:alpha:]]|$)"; "i"))) | last) as $hit
|
|
165
|
+
| {
|
|
166
|
+
asst: ($turn | map(select(.t != "" or .tools != "") | if .tools == "" then .t else (if .t == "" then "" else .t + "\\n" end) + "[tools: \\(.tools)]" end) | join("\\n\\n")),
|
|
167
|
+
text: ($turn | map(.t) | map(select(. != "")) | join("\\n\\n")),
|
|
168
|
+
operator: ($users | keep("<command-name>") | keep("<command-message>") | keep("^\\\\s*Base directory for this skill:") | keep("\\\\n---\\\\n\\\\nSkill: [^\\\\n]*SKILL\\\\.md\\\\s*$") | last // ""),
|
|
169
|
+
standing: (if $hit == null then "" else
|
|
170
|
+
($hit
|
|
171
|
+
| if test("<command-message>") then (capture("<command-message>(?<m>[^<]*)").m)
|
|
172
|
+
elif test("<command-name>") then (capture("<command-name>(?<m>[^<]*)").m)
|
|
173
|
+
else . end
|
|
174
|
+
| gsub("\\\\s+"; " ")) end)
|
|
175
|
+
}
|
|
412
176
|
' "$transcript" 2>/dev/null || true)"
|
|
177
|
+
part() { printf '%s' "$parts" | jq -r ".$1 // empty" 2>/dev/null || true; }
|
|
178
|
+
asst="$(part asst)"
|
|
179
|
+
[ -n "$asst" ] || allow_stop
|
|
180
|
+
asst_text="$(part text)"
|
|
413
181
|
[ -n "$asst_text" ] || asst_text="$asst"
|
|
414
182
|
|
|
415
|
-
# THE CLOSE — assistant text appearing AFTER the last message that carried a tool_use.
|
|
416
|
-
#
|
|
417
|
-
# Every rubric rule that can actually fire is POSITIONAL ("read the turn's FINAL sentences in
|
|
418
|
-
# isolation"; "and the turn is ending"; "POSITION IS THE LAW"). \`asst_text\` is the whole turn
|
|
419
|
-
# flattened to one blob and encodes no position at all, so the judge was asked a positional
|
|
420
|
-
# question about a payload from which position had been deleted, and the EVIDENCE check then
|
|
421
|
-
# authenticated the span against that same blob — establishing only that the characters occur
|
|
422
|
-
# SOMEWHERE. A mid-turn preamble followed by four tool calls and a 3000-char report satisfied
|
|
423
|
-
# it exactly as well as a genuine dangling close.
|
|
424
|
-
#
|
|
425
|
-
# Measured, on the three blocks this hook fired in its own authoring session: every cited span
|
|
426
|
-
# preceded the last tool call, and every turn ended with a 2983-3458 char report. All three
|
|
427
|
-
# reasons were false about what followed the span. An independent audit reproduced the live
|
|
428
|
-
# judge n=15 and the stated reason reproduced 0/15.
|
|
429
|
-
#
|
|
430
|
-
# Empty close (the turn ended ON a tool call) ⇒ fall back to the whole turn: that is the one
|
|
431
|
-
# case where "no text after the tools" is the truth, not a projection artefact.
|
|
432
183
|
asst_close="$(jq -rs '
|
|
433
|
-
|
|
434
|
-
| (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
| last // -1 ) as $lastuser
|
|
439
|
-
| [ $all[($lastuser+1):][] | select(.type == "assistant") ] as $turn
|
|
440
|
-
| ( [ range(0; ($turn|length))
|
|
441
|
-
| select( ($turn[.].message.content // []) | map(.type) | index("tool_use") != null ) ]
|
|
442
|
-
| last // -1 ) as $lasttool
|
|
443
|
-
| [ $turn[($lasttool+1):][]
|
|
444
|
-
| (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
|
|
184
|
+
. as $all
|
|
185
|
+
| ([ range(0; length) | select($all[.].type == "user" and (($all[.].message.content | type) == "string" or ($all[.].message.content | map(.type) | index("text") != null))) ] | last // -1) as $u
|
|
186
|
+
| [ $all[$u + 1:][] | select(.type == "assistant") ] as $turn
|
|
187
|
+
| ([ range(0; ($turn | length)) | select(($turn[.].message.content // []) | map(.type) | index("tool_use") != null) ] | last // -1) as $t
|
|
188
|
+
| [ $turn[$t + 1:][] | (.message.content // []) | map(select(.type == "text") | .text) | join("\\n") ]
|
|
445
189
|
| map(select(. != "")) | join("\\n\\n")
|
|
446
190
|
' "$transcript" 2>/dev/null || true)"
|
|
447
191
|
close_fallback=""
|
|
448
192
|
[ -n "$asst_close" ] || { asst_close="$asst_text"; close_fallback=1; }
|
|
449
193
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
# THE OPERATOR SLOT — and it must actually hold the operator.
|
|
453
|
-
#
|
|
454
|
-
# A skill invocation (\`/carry-on\`, \`/introspect\`, …) enters the transcript as a user-type
|
|
455
|
-
# message carrying the SKILL BODY. Taking the last user message therefore handed the judge
|
|
456
|
-
# 2.8 kB of the /wake skill definition (since retired) as "the operator's most recent
|
|
457
|
-
# instruction" — measured on two of six live fixtures. The judge then reasoned about
|
|
458
|
-
# authorization from a document the operator never wrote, which is worse than having no
|
|
459
|
-
# context: it is confidently wrong context, and the rubric leans on this slot to decide
|
|
460
|
-
# whether an irreversible act was authorized.
|
|
461
|
-
#
|
|
462
|
-
# Skill bodies are recognizable by the wrapper the HARNESS puts on them, never by their prose:
|
|
463
|
-
# claude injects one as a meta user message opening "Base directory for this skill: <dir>", after
|
|
464
|
-
# a <command-name>/<command-message> invocation message; omp's \`skill-prompt\` message closes with
|
|
465
|
-
# a "---" rule and "Skill: <path>/SKILL.md". An operator message that merely looks like a skill
|
|
466
|
-
# (an H1 over a fenced block) carries neither, and stays. Fall back to the most recent message
|
|
467
|
-
# that survives the filter.
|
|
468
|
-
operator="$(jq -rs '
|
|
469
|
-
[ .[]
|
|
470
|
-
| select(.type == "user")
|
|
471
|
-
| (.message.content)
|
|
472
|
-
| if type == "string" then .
|
|
473
|
-
elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
|
|
474
|
-
else "" end
|
|
475
|
-
]
|
|
476
|
-
| map(select(. != ""))
|
|
477
|
-
| map(select(
|
|
478
|
-
(test("<command-name>") | not)
|
|
479
|
-
and (test("<command-message>") | not)
|
|
480
|
-
and (test("^\\\\s*Base directory for this skill:") | not)
|
|
481
|
-
and (test("\\\\n---\\\\n\\\\nSkill: [^\\\\n]*SKILL\\\\.md\\\\s*$") | not)
|
|
482
|
-
and (test("^\\\\s*<system-reminder>") | not)
|
|
483
|
-
and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
|
|
484
|
-
and (test("<task-notification>") | not)
|
|
485
|
-
))
|
|
486
|
-
| last // ""
|
|
487
|
-
' "$transcript" 2>/dev/null || true)"
|
|
194
|
+
operator="$(part operator)"
|
|
488
195
|
[ -n "$operator" ] || operator="(no operator instruction found in transcript)"
|
|
489
196
|
|
|
490
|
-
|
|
491
|
-
#
|
|
492
|
-
# THE GUARD'S OLDEST BLIND SPOT, and the reason it reads as crude. \`carry-on\` declares
|
|
493
|
-
# \`loop-position ∈ {on-the-loop, out-of-the-loop}\` as LIVE SESSION STATE, and nothing anywhere
|
|
494
|
-
# wrote it down — so every turn was judged as if the session had just opened. A check-in is
|
|
495
|
-
# CORRECT at rest ("a session opens in orientation · intent is the operator's to set") and is a
|
|
496
|
-
# COLLAPSE under an elevation the operator already granted, and the judge could not tell those
|
|
497
|
-
# two apart because it was never told which one it was in.
|
|
498
|
-
#
|
|
499
|
-
# DERIVED, NOT STORED. The transcript IS the record: the operator's own utterance is what
|
|
500
|
-
# established the elevation, it is already here, it is session-scoped by construction, and it
|
|
501
|
-
# cannot desync from what was actually said the way a state file can. A store would need a
|
|
502
|
-
# session id this worker is not always given, a write path, and a lifecycle — all to hold a
|
|
503
|
-
# value that is a fold over messages already on disk.
|
|
504
|
-
#
|
|
505
|
-
# SCANNED BEFORE THE FILTER ABOVE, which is the whole repair. A slash invocation arrives wrapped
|
|
506
|
-
# in <command-name>, and that wrapper is exactly what the operator slot drops — so the filter
|
|
507
|
-
# added to stop the judge reading a skill BODY as an instruction was also the mechanism hiding
|
|
508
|
-
# every \`/carry-on\` from it. The word is read here, from the unfiltered list, and only the
|
|
509
|
-
# word: no skill body reaches the payload.
|
|
510
|
-
#
|
|
511
|
-
# MECHANICAL EXTRACTION, SEMANTIC WEIGHING — the same split as layer 1. This reports WHICH
|
|
512
|
-
# position is in force and the verbatim utterance that set it; the rubric decides what follows.
|
|
513
|
-
# A false positive therefore costs a misleading context line, never an unguarded turn.
|
|
514
|
-
standing="$(jq -rs '
|
|
515
|
-
[ .[]
|
|
516
|
-
| select(.type == "user")
|
|
517
|
-
| (.message.content)
|
|
518
|
-
| if type == "string" then .
|
|
519
|
-
elif type == "array" then ([ .[] | select(.type == "text") | .text ] | join("\\n"))
|
|
520
|
-
else "" end
|
|
521
|
-
]
|
|
522
|
-
| map(select(. != ""))
|
|
523
|
-
| map(select(
|
|
524
|
-
(test("^\\\\s*<system-reminder>") | not)
|
|
525
|
-
and (test("\\\\[SYSTEM NOTIFICATION - NOT USER INPUT\\\\]") | not)
|
|
526
|
-
and (test("<task-notification>") | not)
|
|
527
|
-
))
|
|
528
|
-
| (map(select(test("(^|[^[:alpha:]])(carry[- ]?on|weitermachen|proceed)([^[:alpha:]]|$)"; "i")))
|
|
529
|
-
| last) as $hit
|
|
530
|
-
| if $hit == null then "" else
|
|
531
|
-
($hit
|
|
532
|
-
| if test("<command-message>") then (capture("<command-message>(?<m>[^<]*)").m)
|
|
533
|
-
elif test("<command-name>") then (capture("<command-name>(?<m>[^<]*)").m)
|
|
534
|
-
else . end
|
|
535
|
-
| gsub("\\\\s+"; " ")) end
|
|
536
|
-
' "\${session_transcript:-$transcript}" 2>/dev/null || true)"
|
|
537
|
-
|
|
538
|
-
# The position is a fact under a plain label: which one is in force and, for the elevation, the
|
|
539
|
-
# utterance that set it. What each position means is the rubric's to say.
|
|
197
|
+
standing="$(part standing)"
|
|
540
198
|
if [ -n "$standing" ]; then
|
|
541
199
|
loop_position="out-of-the-loop, set by the operator's \\"$(printf '%s' "$standing" | cut -c1-300)\\""
|
|
542
200
|
else
|
|
543
201
|
loop_position="on-the-loop"
|
|
544
202
|
fi
|
|
545
203
|
|
|
546
|
-
# The judged payload is assembled after the layer-1 note below, because the cap covers both.
|
|
547
|
-
|
|
548
|
-
# --- LAYER 1: deterministic checks (no LLM) --------------------------------------------------
|
|
549
|
-
# The judge is one sample from a small model — a noisy signal, and unfit to carry an invariant on
|
|
550
|
-
# its own. Anything decidable by inspection is decided HERE, where it is reproducible and free.
|
|
551
|
-
# The judge is reserved for the semantic residue that regex provably cannot reach.
|
|
552
|
-
#
|
|
553
|
-
# L1a · ANNOUNCE-WITHOUT-ACT. A Stop hook fires when the agent has produced text and no further
|
|
554
|
-
# tool call. So a first-person forward commitment in the FINAL text is, by construction, a
|
|
555
|
-
# commitment the turn did not honour: had the agent done the thing, the doing would precede the
|
|
556
|
-
# text and the text would report it ("I ran X"), not promise it ("I'll run X").
|
|
557
|
-
#
|
|
558
|
-
# The rubric could never catch this. Its turn-close rule asks only whether the close OFFERS the
|
|
559
|
-
# next action ("say the word") versus STATES it — and a bare statement PASSES. Replayed through
|
|
560
|
-
# the judge, a real turn reading "Proceeding to #2. I'll run the research and author the plan."
|
|
561
|
-
# came back PASS, with the judge commending the agent for "proceeding with a declared approach"
|
|
562
|
-
# while the agent had in fact proceeded with nothing. Stating and stopping is the collapse in its
|
|
563
|
-
# most fluent disguise, and it is invisible to a rule that only inspects the shape of the close.
|
|
564
|
-
#
|
|
565
|
-
# Contingent commitments are NOT this: waiting on a dispatched agent, on an operator's sign-off,
|
|
566
|
-
# or on an external event is legitimate, and the turn genuinely cannot proceed. Those are carved
|
|
567
|
-
# out below. Everything else routes to the judge with the offending span quoted, so the block
|
|
568
|
-
# names the evidence rather than restating the rule.
|
|
569
|
-
# Windowed off THE CLOSE, not the whole-turn blob. Taking the last 700 bytes of the
|
|
570
|
-
# concatenation reaches BACKWARD ACROSS TOOL BOUNDARIES whenever the final text block is short —
|
|
571
|
-
# reintroducing the very bug this section claims to have fixed ("it judged 'Adding the
|
|
572
|
-
# false-positive fixtures', a preamble whose very next assistant message was a tool_use").
|
|
573
|
-
# It has not fired yet only because the offending turns happened to close with ~3000 chars.
|
|
574
204
|
final_span="$(printf '%s' "$asst_close" | tail -c 700)"
|
|
575
205
|
l1_evidence=""
|
|
576
206
|
if printf '%s' "$final_span" | grep -Eqi "(^|[[:space:].\\"'])(i'?ll|i will|i'?m going to|let me|now (i'?ll|running)|next (i'?ll|i will)|proceeding to|moving on to|starting (on|with)|taking (it|that) (on|now))[[:space:]]"; then
|
|
577
|
-
# Carve-outs: the commitment is contingent on something outside this turn.
|
|
578
207
|
if ! printf '%s' "$final_span" | grep -Eqi "when (it|they|that|the .*) (returns?|completes?|finishes?|lands?)|once (you|the operator|it|that)|awaiting|still running|report back when|on your (sign-?off|go|word)|if you|unless you|pending your"; then
|
|
579
|
-
#
|
|
580
|
-
# obvious and is not portable: ugrep (the default grep on some hosts) rejects the nested
|
|
581
|
-
# bounded quantifier with "exceeds complexity limits", the command fails, and the
|
|
582
|
-
# substitution yields EMPTY — so the evidence clause silently vanishes and the block
|
|
583
|
-
# degrades to the restated-rule feedback this rewrite exists to replace. A gate whose
|
|
584
|
-
# evidence path fails open is a gate that lies about why it fired.
|
|
208
|
+
# By sentence: ugrep rejects a windowed \`.{0,90}\` match.
|
|
585
209
|
l1_evidence="$(printf '%s' "$final_span" | tr '\\n' ' ' | tr '.' '\\n' \\
|
|
586
210
|
| grep -Eim1 "(i'?ll|i will|i'?m going to|let me|proceeding to|moving on to|starting (on|with))" \\
|
|
587
211
|
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-200)"
|
|
588
212
|
fi
|
|
589
213
|
fi
|
|
590
|
-
|
|
591
|
-
# --- LAYER 2: the judge (semantic residue only) ----------------------------------------------
|
|
592
|
-
# The judge contract: payload on stdin, rubric path as argv[1]; emits VERDICT: PASS|BLOCK [+ REASON].
|
|
593
|
-
# Non-zero judge exit, an empty answer or a verdict that is neither PASS nor BLOCK → dark.
|
|
594
|
-
# THE JUDGE IS HANDED FACTS UNDER PLAIN LABELS, and no sentence telling it what to conclude: the
|
|
595
|
-
# rubric is the one place the test is stated, and a payload that restates it in its own words is a
|
|
596
|
-
# second home for it. The Layer-1 span is one such fact, quoted from the turn by this worker.
|
|
597
214
|
l1_block=""
|
|
598
215
|
[ -z "$l1_evidence" ] || l1_block="
|
|
599
216
|
|
|
600
217
|
Layer-1 span: \\"$l1_evidence\\""
|
|
601
218
|
|
|
602
|
-
# THE JUDGED PAYLOAD, BOUNDED: the standing directive, the operator's instruction, THEN the
|
|
603
|
-
# agent turn, and the layer-1 note when there is one — all together at most JUDGE_PAYLOAD_CAP
|
|
604
|
-
# bytes. The operator's message keeps its TAIL (a quarter of the cap at most: the instruction
|
|
605
|
-
# that governs the turn is the last thing said). The agent turn keeps what remains after
|
|
606
|
-
# every fixed part, and it keeps its END: the close is the text after the last tool call, every
|
|
607
|
-
# rule that can fire reads it, and it is the last thing in the turn. Whatever is cut is marked
|
|
608
|
-
# where it was cut.
|
|
609
219
|
op_room=$((JUDGE_PAYLOAD_CAP / 4))
|
|
610
220
|
op_total="$(judge_bytes "$operator")"
|
|
611
221
|
if [ "$op_total" -gt "$op_room" ]; then
|
|
@@ -630,31 +240,14 @@ else
|
|
|
630
240
|
asst_sent="$(judge_cut 'the agent turn' "$asst_total" "$(judge_bytes "$asst_shown")")
|
|
631
241
|
$asst_shown"
|
|
632
242
|
if [ -n "$close_fallback" ]; then
|
|
633
|
-
# The turn ended ON a tool call, so the whole turn stood in for the close: what it
|
|
634
|
-
# showed, less the tool markers, which are not the agent's words.
|
|
635
243
|
close_seen="$(printf '%s\\n' "$asst_shown" | sed '/^\\[tools: .*\\]$/d')"
|
|
636
244
|
else
|
|
637
|
-
# The close ends the turn, so what the excerpt shows of it is its own final bytes.
|
|
638
245
|
close_seen="$(printf '%s' "$asst_close" | judge_keep "$(judge_bytes "$asst_shown")")"
|
|
639
246
|
fi
|
|
640
247
|
fi
|
|
641
248
|
judged="$turn_head
|
|
642
249
|
$asst_sent$l1_block"
|
|
643
250
|
|
|
644
|
-
# THE JUDGE MAY LIVE OUTSIDE THIS PROCESS, and on a harness that already holds a
|
|
645
|
-
# model it MUST. Spawning another vendor's CLI to answer a question the host can
|
|
646
|
-
# answer in-process is a cross-harness dependency wearing a plugin's clothes: it
|
|
647
|
-
# needs that vendor installed, separately authenticated, and warm — and when its
|
|
648
|
-
# OAuth lapses, every verdict on every harness fails open in silence.
|
|
649
|
-
#
|
|
650
|
-
# So the seam is explicit and the PROCEDURE stays here, in one home. A host that
|
|
651
|
-
# can judge runs this worker twice: once with \`STANCE_EMIT_PAYLOAD\` to receive the
|
|
652
|
-
# gated, layer-1-annotated payload and the rubric that scores it, then again with
|
|
653
|
-
# \`STANCE_VERDICT_FILE\` naming its answer. Everything either pass touches before
|
|
654
|
-
# this line is read-only, so the first pass mutates no counter and no hash — the
|
|
655
|
-
# gate, the extraction and the pre-filter simply run twice and agree.
|
|
656
|
-
#
|
|
657
|
-
# A host with no model of its own changes nothing and keeps the subprocess judge.
|
|
658
251
|
if [ -n "\${STANCE_EMIT_PAYLOAD:-}" ]; then
|
|
659
252
|
jq -cn --arg r "$RUBRIC" --arg p "$judged" '{rubric:$r, payload:$p}'
|
|
660
253
|
exit 0
|
|
@@ -666,127 +259,61 @@ else
|
|
|
666
259
|
verdict="$(printf '%s' "$judged" | $JUDGE_CMD "$RUBRIC" 2>/dev/null)" || dark "the judge did not answer"
|
|
667
260
|
fi
|
|
668
261
|
|
|
669
|
-
|
|
262
|
+
tag() { printf '%s\\n' "$verdict" | sed -n "s/^$1:[[:space:]]*//p" | head -1; }
|
|
263
|
+
decision="$(tag VERDICT | sed 's/[[:space:]]*$//')"
|
|
670
264
|
case "$decision" in
|
|
671
|
-
PASS) allow_stop ;;
|
|
265
|
+
PASS) allow_stop ;;
|
|
672
266
|
BLOCK) ;;
|
|
673
267
|
*) dark "the judge's verdict was unparseable (no VERDICT: PASS or VERDICT: BLOCK line)" ;;
|
|
674
268
|
esac
|
|
675
269
|
|
|
676
|
-
reason="$(
|
|
270
|
+
reason="$(tag REASON)"
|
|
677
271
|
[ -n "$reason" ] || reason="This turn collapsed out of the intent-driven-expert stance."
|
|
678
272
|
|
|
679
|
-
|
|
680
|
-
# The judge is one sample from a small model, and a wrong block costs exactly what a missed one
|
|
681
|
-
# does: an agent that yields to a fired gate whose diagnosis the record refutes has updated on a
|
|
682
|
-
# salient signal instead of on argument — the collapse wearing the guardrail's uniform.
|
|
683
|
-
#
|
|
684
|
-
# Observed live: the judge blocked a turn and quoted "Authoring the plan" as the offending span.
|
|
685
|
-
# That string was not in the turn it judged — it was the close of an EARLIER turn, and that turn
|
|
686
|
-
# had honoured it. Pure confabulation, and unfalsifiable from inside the model.
|
|
687
|
-
#
|
|
688
|
-
# So the quote is checked against the transcript MECHANICALLY. The judge must emit
|
|
689
|
-
# \`EVIDENCE: <verbatim span>\`; if that span does not literally occur in the judged turn, the
|
|
690
|
-
# block is discarded. A model cannot quote what is not there, which makes this cheap and total.
|
|
691
|
-
# EVIDENCE IS MANDATORY FOR A BLOCK. A missing line does not get the benefit of the doubt: the
|
|
692
|
-
# first cut of this check let an absent EVIDENCE line mean "verdict stands", and that exemption
|
|
693
|
-
# was immediately exercised — a fabricated block reached the agent because the span it should
|
|
694
|
-
# have been checked against had been stripped upstream by the judge's own output filter. An
|
|
695
|
-
# unevidenced block is indistinguishable from a fabricated one, so it is discarded either way.
|
|
696
|
-
# Layer-1 blocks are unaffected: they carry a span this hook extracted from the turn itself.
|
|
697
|
-
evidence="$(printf '%s\\n' "$verdict" | sed -n 's/^EVIDENCE:[[:space:]]*//p' | head -1 \\
|
|
698
|
-
| sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
273
|
+
evidence="$(tag EVIDENCE | sed 's/^["“]//;s/["”]$//;s/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
699
274
|
if [ -z "$evidence" ] && [ -z "$l1_evidence" ]; then
|
|
700
|
-
|
|
275
|
+
row DARK "" "$reason"
|
|
701
276
|
say "STANCE GUARDRAIL — BLOCK DISCARDED: the judge blocked this turn but quoted no EVIDENCE line, and an unevidenced block cannot be told from a fabricated one. No verdict stands; this turn was NOT judged clean. The judge's reason was: $reason"
|
|
702
277
|
allow_stop
|
|
703
278
|
fi
|
|
704
279
|
if [ -n "$evidence" ] && [ "\${#evidence}" -ge 12 ]; then
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
# own three blocks — all three spans preceded the last tool call, all three reasons were
|
|
710
|
-
# false about what followed, and all three passed the old whole-turn check.
|
|
711
|
-
# THE ONE MECHANICALLY-CHECKED ARTIFACT, RECORDED. \`$evidence\` is the only part of a block
|
|
712
|
-
# that survives a check against the turn; \`$reason\` is unverified model prose. Both were
|
|
713
|
-
# discarded, so a block could not be audited afterwards without re-running a judge measured at
|
|
714
|
-
# 3/5 on identical payloads — i.e. the record of WHY a turn was blocked was reconstructible only
|
|
715
|
-
# by a non-deterministic process. Appended, never rotated by this hook; the state dir is tmp.
|
|
716
|
-
printf '%s\\t%s\\t%s\\n' "$decision" "$evidence" "$reason" >> "$verdict_log" 2>/dev/null || true
|
|
717
|
-
# NORMALIZED ON BOTH SIDES, and with \`--\`, because this check had two ways to
|
|
718
|
-
# throw away a correct block — and both of them fired on the SAME shape, which is
|
|
719
|
-
# the one shape this guard most exists to catch.
|
|
720
|
-
#
|
|
721
|
-
# 1. \`grep -qF "$evidence"\` with no \`--\`: an evidence span opening with a markdown
|
|
722
|
-
# bullet is read as an OPTION. \`grep: invalid option -- ' '\`, non-zero, block
|
|
723
|
-
# discarded. A tail-enumeration collapse IS a bullet list, so its evidence always
|
|
724
|
-
# begins \`- \`, and the rule could never convict the shape it was written for.
|
|
725
|
-
# 2. A judge quoting several bullets joins them with spaces and drops the markers,
|
|
726
|
-
# so a verbatim-substring test fails on punctuation while every word is present.
|
|
727
|
-
#
|
|
728
|
-
# The proposition worth authenticating is that the judge quoted THIS TURN'S WORDS,
|
|
729
|
-
# not that it reproduced its list syntax. So both sides are flattened the same way:
|
|
730
|
-
# newlines to spaces, runs of whitespace to one, list markers dropped. Symmetric, so
|
|
731
|
-
# nothing is accepted on one side that would be rejected on the other.
|
|
732
|
-
# AND AGAINST WHAT THE JUDGE WAS ACTUALLY SENT. \`close_seen\` is the close as far as the
|
|
733
|
-
# bounded excerpt showed it (the whole close when nothing was cut). A span from text the
|
|
734
|
-
# excerpt elided cannot be a quotation of anything the judge read, however genuinely it is
|
|
735
|
-
# in the transcript, so it is discarded exactly as a fabricated one is.
|
|
736
|
-
ev_norm="$(printf '%s' "$evidence" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
|
|
737
|
-
close_norm="$(printf '%s' "$close_seen" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g')"
|
|
738
|
-
if ! printf '%s' "$close_norm" | grep -qF -- "$ev_norm"; then
|
|
739
|
-
printf 'DARK\\t\\t%s\\n' "$evidence" >> "$verdict_log" 2>/dev/null || true
|
|
280
|
+
row "$decision" "$evidence" "$reason"
|
|
281
|
+
norm() { printf '%s' "$1" | tr '\\n' ' ' | sed -e 's/[[:space:]][[:space:]]*/ /g' -e 's/^[-*][[:space:]]//' -e 's/ [-*] / /g'; }
|
|
282
|
+
if ! printf '%s' "$(norm "$close_seen")" | grep -qF -- "$(norm "$evidence")"; then
|
|
283
|
+
row DARK "" "$evidence"
|
|
740
284
|
say "STANCE GUARDRAIL — BLOCK DISCARDED: the judge blocked this turn but quoted a span that is not in the turn's CLOSE as the judge was sent it (mid-turn preamble, text the excerpt elided, or confabulated): $evidence. No verdict stands; this turn was NOT judged clean."
|
|
741
285
|
allow_stop
|
|
742
286
|
fi
|
|
743
287
|
fi
|
|
744
288
|
|
|
745
|
-
#
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
289
|
+
# A refused stop holds back no effect: a repeat of the turn last blocked, or a block after RUN_CAP, is let through said.
|
|
290
|
+
turn_hash="$(printf '%s' "$asst_text" | cksum)"
|
|
291
|
+
run_next=$((run + 1))
|
|
292
|
+
let_through=""
|
|
293
|
+
if [ -n "$stop_active" ]; then
|
|
294
|
+
if [ "$run_known" -eq 0 ]; then
|
|
295
|
+
let_through="the run of refused stops it follows cannot be read, and a bound that cannot be counted cannot hold"
|
|
296
|
+
elif [ "$run" -ge 1 ] && [ "$turn_hash" = "$run_hash" ]; then
|
|
297
|
+
let_through="this stop is byte-identical to the turn it already refused, so refusing it again cannot help"
|
|
298
|
+
elif [ "$run" -ge "$RUN_CAP" ]; then
|
|
299
|
+
let_through="it has refused this run of stops $run times"
|
|
300
|
+
fi
|
|
301
|
+
fi
|
|
302
|
+
if [ -z "$let_through" ] && ! printf '%s %s' "$run_next" "$turn_hash" > "$run_file" 2>/dev/null; then
|
|
303
|
+
[ -z "$stop_active" ] || let_through="the run of refused stops cannot be written, and a bound that cannot be counted cannot hold"
|
|
304
|
+
fi
|
|
305
|
+
if [ -n "$let_through" ]; then
|
|
306
|
+
rm -f "$run_file" 2>/dev/null || true
|
|
307
|
+
row UNRESOLVED "$evidence" "$reason"
|
|
308
|
+
say "STANCE GUARDRAIL — stop let through UNRESOLVED: the judge blocked this stop, and the finding STANDS and is unaddressed — $let_through. The next stop that follows no block is judged and blocked again. The judge's reason was: $reason"
|
|
309
|
+
allow_stop
|
|
310
|
+
fi
|
|
767
311
|
evidence_clause=""
|
|
768
|
-
[ -n "$l1_evidence" ] && evidence_clause="The offending span is yours, verbatim: \\"$l1_evidence\\" —
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
# against the close; \`\$reason\` is unverified model prose. Sending the reason alone leaves the
|
|
774
|
-
# recipient able to argue only with the one string nothing authenticated — and an agent that
|
|
775
|
-
# refutes a fabricated reason has dodged a verdict that may still be correct, which is exactly
|
|
776
|
-
# what happened three times in this hook's authoring session.
|
|
777
|
-
[ -z "$evidence_clause" ] && [ -n "$evidence" ] && evidence_clause="The span this was checked \\
|
|
778
|
-
against, verbatim from your close: \\"$evidence\\" — the REASON above is the judge's unverified \\
|
|
779
|
-
wording; THIS span is what mechanically matched. Argue with the span, not the wording. "
|
|
780
|
-
|
|
781
|
-
feedback="STANCE GUARDRAIL — blocked: you collapsed out of the intent-driven-expert stance. $reason \\
|
|
782
|
-
\${evidence_clause}Re-assume the stance: you are the owning expert; the operator owns intent + sign-off \\
|
|
783
|
-
on irreversible acts only. Decide the in-remit call yourself (note it for review) instead of seeking \\
|
|
784
|
-
permission, own your expert judgment (naming/design/architecture/how) instead of deferring it, and \\
|
|
785
|
-
extract+serve the operator's INTENT instead of echoing their literal words. Then continue. (Legitimate \\
|
|
786
|
-
exceptions: surfacing a genuine irreversible-outward act for consent, or routing a true INTENT \\
|
|
787
|
-
ambiguity to /elicit.)"
|
|
788
|
-
|
|
789
|
-
# jq builds valid JSON regardless of quotes/newlines in the feedback.
|
|
312
|
+
[ -n "$l1_evidence" ] && evidence_clause="The offending span is yours, verbatim: \\"$l1_evidence\\" — you committed to an action and then ended the turn without taking it; do it now, with tool calls. "
|
|
313
|
+
[ -z "$evidence_clause" ] && [ -n "$evidence" ] && evidence_clause="The span checked against your close, verbatim: \\"$evidence\\". "
|
|
314
|
+
|
|
315
|
+
feedback="STANCE GUARDRAIL — blocked: $reason \${evidence_clause}Decide the in-remit call yourself and continue. $(contest_refused "$reason")"
|
|
316
|
+
|
|
790
317
|
jq -cn --arg r "$feedback" '{decision:"block", reason:$r}'
|
|
791
318
|
exit 0
|
|
792
319
|
`,
|
|
@@ -942,10 +469,4 @@ A record starting "Agent dispatch" is judged by this alone: BLOCK only if it pas
|
|
|
942
469
|
},
|
|
943
470
|
],
|
|
944
471
|
};
|
|
945
|
-
// EXPORTED AFTER THE CELL, ON PURPOSE. `allHookCells` (tooling/project-targets.ts) takes the
|
|
946
|
-
// FIRST export of a `hooks/*.ts` module to be its cell, by name order and by definition order
|
|
947
|
-
// alike, so anything else this module exports must sort and be defined after `stanceGuardrail`.
|
|
948
|
-
// These are the cap and the shell that honours it, for the two workers that share the judge.
|
|
949
|
-
export const stanceGuardrailJudgeCap = judgePayloadCapBytes;
|
|
950
|
-
export const stanceGuardrailJudgeClip = judgeClipShell;
|
|
951
472
|
//# sourceMappingURL=stance-guardrail.js.map
|