@event4u/agent-config 9.30.0 → 9.31.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +31 -314
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-2WCLVDWC.js} +1 -1
- package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
- package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
- package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
- package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
- package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
- package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
- package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +98 -36
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +2 -55
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +347 -174
- package/dist/install/install.mjs +121 -16
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +310 -138
- package/dist/scripts/_lib/agent_settings.js +111 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/package.json +1 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_cli/explain_last/inputs.ts +13 -0
- package/src/scripts/_lib/agent_settings.ts +119 -2
- package/src/scripts/_lib/carrier_divergence.ts +47 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
- package/src/scripts/language_mirror_hook.ts +69 -16
- package/src/scripts/measure_turn_end_gate.ts +129 -51
- package/src/scripts/report_carrier_divergence.ts +72 -24
- package/src/scripts/report_conformance_funnel.ts +11 -4
- package/src/server/schemas/settings.ts +2 -2
|
@@ -81,6 +81,53 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
|
|
|
81
81
|
return 'body-diff';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Drop a leading YAML frontmatter block, returning the prose the host actually
|
|
86
|
+
* delivers.
|
|
87
|
+
*
|
|
88
|
+
* A file with no `---` fence, or an unterminated one, is returned unchanged —
|
|
89
|
+
* the same tolerance `report_carrier_divergence`'s own `type:` reader applies,
|
|
90
|
+
* because a malformed fence must not silently swallow a rule's whole text.
|
|
91
|
+
*/
|
|
92
|
+
export function stripFrontmatter(text: string): string {
|
|
93
|
+
if (!text.startsWith('---')) return text;
|
|
94
|
+
const end = text.indexOf('\n---', 3);
|
|
95
|
+
if (end === -1) return text;
|
|
96
|
+
const afterFence = text.indexOf('\n', end + 1);
|
|
97
|
+
return afterFence === -1 ? '' : text.slice(afterFence + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Do the two copies carry the same governed PROSE, ignoring frontmatter?
|
|
102
|
+
*
|
|
103
|
+
* WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
|
|
104
|
+
* --------------------------------------------------------------
|
|
105
|
+
* `comparePair` answers *can a byte-identity dedup skip this pair* — it is the
|
|
106
|
+
* dedup predicate, and a frontmatter difference is a real byte difference, so
|
|
107
|
+
* folding this in would relax the predicate that
|
|
108
|
+
* `agents/settings/contexts/dedup-reachability-refusal.md` deliberately keeps
|
|
109
|
+
* strict. `measure_scope_dedup` asks exactly that question and its arithmetic
|
|
110
|
+
* stays untouched.
|
|
111
|
+
*
|
|
112
|
+
* This answers the different, reader-facing question `report_carrier_divergence`
|
|
113
|
+
* asks: *must a human act on this pair*. Measured 2026-08-10 over the live
|
|
114
|
+
* carriers at commit `a5b2f4cb7`: 109 shared rules, **0** byte-identical, **109**
|
|
115
|
+
* classified `body-diff` — and after this strip, **0** of the 109 differ in
|
|
116
|
+
* prose. The whole divergence was the metadata block. Reporting that as body
|
|
117
|
+
* divergence manufactures the one class the report tells a reader to act on,
|
|
118
|
+
* which is precisely the failure `compareCarriers` already refuses to commit for
|
|
119
|
+
* an unreadable copy.
|
|
120
|
+
*
|
|
121
|
+
* Edge trimming only, on purpose: the frontmatter fence leaves the two sides one
|
|
122
|
+
* leading newline apart, and that is not a content difference. Internal
|
|
123
|
+
* whitespace and line endings are NOT normalized — a CRLF-vs-LF body stays a
|
|
124
|
+
* prose difference, so this stays no softer than the dedup predicate on the text
|
|
125
|
+
* the host reads.
|
|
126
|
+
*/
|
|
127
|
+
export function proseEqual(a: string, b: string): boolean {
|
|
128
|
+
return stripFrontmatter(a).trim() === stripFrontmatter(b).trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
export interface RuleDirCensus {
|
|
85
132
|
files: number;
|
|
86
133
|
/**
|
|
@@ -27,16 +27,30 @@
|
|
|
27
27
|
* Nothing else in `src/` reads this field today; it is the host's own
|
|
28
28
|
* answer to the question above.
|
|
29
29
|
*
|
|
30
|
-
* 2. A state
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
30
|
+
* 2. A per-session state file holding the ORDINAL of the last refused turn —
|
|
31
|
+
* never the prompt's text, and never the reply. One file per session, not
|
|
32
|
+
* one per refused turn.
|
|
33
|
+
*
|
|
34
|
+
* The first version keyed on sha256(session_id + last user text) with a
|
|
35
|
+
* file per turn, and R2 found that wrong in three directions at once: a
|
|
36
|
+
* REPEATED prompt ("weiter", "ok", "1") collided with the earlier refused
|
|
37
|
+
* turn and was allowed unconditionally; the key drifted WITHIN a turn
|
|
38
|
+
* because a compaction summary, a `<system-reminder>` and a sidechain
|
|
39
|
+
* prompt all arrive in the user role; and the files accumulated with no
|
|
40
|
+
* TTL. The ordinal — a count of `isSyntheticPrompt`-filtered,
|
|
41
|
+
* non-sidechain user entries — is distinct for every real turn whatever
|
|
42
|
+
* the user typed, and stable across harness injections.
|
|
43
|
+
*
|
|
44
|
+
* Still true and still worth stating: per-session files are bounded per
|
|
45
|
+
* session but not pruned, so a long-lived workspace accumulates one small
|
|
46
|
+
* file per session. No TTL ships here.
|
|
36
47
|
*
|
|
37
48
|
* The failure this ordering prevents is not a loop but a wedge: a turn that
|
|
38
49
|
* can never end. Layer 2 also covers the host that does not send
|
|
39
|
-
* `stop_hook_active` at all
|
|
50
|
+
* `stop_hook_active` at all — with one honest limit: a host that sends no
|
|
51
|
+
* `session_id` either falls into a shared bucket where an unrelated session's
|
|
52
|
+
* matching ordinal reads as already-refused, so on such a host layer 2
|
|
53
|
+
* degrades to "may under-refuse" and layer 1 is the real guard.
|
|
40
54
|
*
|
|
41
55
|
* ## Default OFF — and why, given the roadmap says otherwise
|
|
42
56
|
*
|
|
@@ -49,7 +63,7 @@
|
|
|
49
63
|
* (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
|
|
50
64
|
* detectors are default ON. A maintainer who opts in gets both without
|
|
51
65
|
* further configuration; until then this concern is a no-op that costs one
|
|
52
|
-
* settings read.
|
|
66
|
+
* settings-cascade read per turn-end.
|
|
53
67
|
*
|
|
54
68
|
* CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
|
|
55
69
|
* otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
|
|
@@ -63,7 +77,13 @@ import * as fs from 'node:fs';
|
|
|
63
77
|
import * as path from 'node:path';
|
|
64
78
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
65
79
|
|
|
66
|
-
import {
|
|
80
|
+
import { load_agent_settings } from '../_lib/agent_settings.js';
|
|
81
|
+
import {
|
|
82
|
+
classify,
|
|
83
|
+
isSyntheticPrompt,
|
|
84
|
+
STATE_FILE as LANGUAGE_STATE_FILE,
|
|
85
|
+
type Verdict,
|
|
86
|
+
} from '../language_mirror_hook.js';
|
|
67
87
|
import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
|
|
68
88
|
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
69
89
|
import { readHookStdin } from './hook_stdin.js';
|
|
@@ -73,6 +93,16 @@ import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
|
73
93
|
const EXIT_BLOCK = 1;
|
|
74
94
|
const EXIT_ALLOW = 0;
|
|
75
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Transcript-read ceiling for this hook, passed at the call site in `main()`.
|
|
98
|
+
* Deliberately well under `isSafeTranscriptPath`'s own 50 MB refusal: the
|
|
99
|
+
* ordinal is a count over every entry, so the whole file is walked once per
|
|
100
|
+
* turn-end, and a session file grows for the life of the session. Past this the
|
|
101
|
+
* gate lets the turn end rather than pay an unbounded read — fail-open, like
|
|
102
|
+
* every other unreadable-transcript case here.
|
|
103
|
+
*/
|
|
104
|
+
export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
|
105
|
+
|
|
76
106
|
/** Detector identity, used in the state marker and the refusal text. */
|
|
77
107
|
export type DetectorId = 'promissory' | 'language';
|
|
78
108
|
|
|
@@ -97,12 +127,56 @@ export interface Finding {
|
|
|
97
127
|
* under-stripping costs a false refusal, and a blocking guard with a false
|
|
98
128
|
* positive rate teaches users to bypass it.
|
|
99
129
|
*/
|
|
130
|
+
/**
|
|
131
|
+
* Remove fenced code blocks, line by line, tracking open/close state.
|
|
132
|
+
*
|
|
133
|
+
* This is deliberately NOT a regex, and the reason is a measured regression.
|
|
134
|
+
* Two regex attempts each dropped a reply's whole tail on a different valid
|
|
135
|
+
* shape: a `\1` backreference misses CommonMark's longer closing fence (R2
|
|
136
|
+
* round 1, finding 5), and the character-matching replacement that fixed THAT
|
|
137
|
+
* stopped at the first closer-shaped line — so on a `~~~` block containing a
|
|
138
|
+
* ``` block, the inner line was taken for the closer, the replacer declined it
|
|
139
|
+
* on the character mismatch, and the greedy tail-drop then deleted everything
|
|
140
|
+
* from the opener onward (R2 round 2, finding 1). That mixed-character nesting
|
|
141
|
+
* is the shape `markdown-safe-codeblocks` prescribes as its DEFAULT, so the
|
|
142
|
+
* second attempt was worse than the first on the more common input.
|
|
143
|
+
*
|
|
144
|
+
* A scanner has the state a regex lacks. CommonMark, applied here:
|
|
145
|
+
* · an opener may carry an info string; a closer may not;
|
|
146
|
+
* · only a fence of the SAME character and at least the opener's length
|
|
147
|
+
* closes the block — anything else inside it is content;
|
|
148
|
+
* · an unterminated opener runs to end of input, so a truncated reply loses
|
|
149
|
+
* its tail, which is the correct reading of a truncated reply.
|
|
150
|
+
*/
|
|
151
|
+
export function stripFencedBlocks(text: string): string {
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
let open: { ch: string; len: number } | null = null;
|
|
154
|
+
for (const line of text.split('\n')) {
|
|
155
|
+
const m = /^[ \t]{0,3}([`~]{3,})(.*)$/.exec(line);
|
|
156
|
+
if (open === null) {
|
|
157
|
+
if (m) {
|
|
158
|
+
open = { ch: m[1]![0]!, len: m[1]!.length };
|
|
159
|
+
out.push(' ');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out.push(line);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
m &&
|
|
167
|
+
m[1]![0] === open.ch &&
|
|
168
|
+
m[1]!.length >= open.len &&
|
|
169
|
+
(m[2] ?? '').trim() === ''
|
|
170
|
+
) {
|
|
171
|
+
open = null;
|
|
172
|
+
}
|
|
173
|
+
// Everything between opener and closer is dropped, closer included.
|
|
174
|
+
}
|
|
175
|
+
return out.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
100
178
|
export function visibleProse(reply: string): string {
|
|
101
|
-
let text = reply;
|
|
102
|
-
// Fenced blocks, both ``` and ~~~ (markdown-safe-codeblocks ships both).
|
|
103
|
-
text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, ' ');
|
|
104
|
-
// An unterminated fence at the end of a reply — drop the tail.
|
|
105
|
-
text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*$/m, ' ');
|
|
179
|
+
let text = stripFencedBlocks(reply);
|
|
106
180
|
// Inline code.
|
|
107
181
|
text = text.replace(/`[^`\n]*`/g, ' ');
|
|
108
182
|
// Block quotes — the shape quoted tool output takes.
|
|
@@ -146,10 +220,28 @@ const PROMISSORY = [
|
|
|
146
220
|
/\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
|
|
147
221
|
// German puts the infinitive at the END of the clause ("ich werde jetzt
|
|
148
222
|
// die Tests schreiben"), so the verb cannot be matched adjacent to
|
|
149
|
-
// "werde" — it has to be looked for across the rest of the sentence.
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
223
|
+
// "werde" — it has to be looked for across the rest of the sentence.
|
|
224
|
+
//
|
|
225
|
+
// The lookahead excludes a stated REFUSAL to act: declining to do
|
|
226
|
+
// something is not a promise to do it. R2 finding 4 reproduced four live
|
|
227
|
+
// false refusals against the old `\bnicht\b`-only version — "Ich werde
|
|
228
|
+
// nichts anfassen" (no word boundary after `nicht`), "keine Tests
|
|
229
|
+
// schreiben", "niemals raten", and the passive non-promise "Ich werde
|
|
230
|
+
// gefragt, ob …". Each was a false refusal on a BLOCKING guard, which is
|
|
231
|
+
// the precision failure the council warning is about.
|
|
232
|
+
// `\p{L}` with the `u` flag, NOT `\w`: R2 round 2, finding 15 verified that
|
|
233
|
+
// `\b\w{3,}en\b` cannot reach an infinitive whose post-umlaut fragment is
|
|
234
|
+
// short, because `ü`/`ö`/`ä` are not `\w` and create a word boundary — so
|
|
235
|
+
// "prüfen" and "lösen" were silent while the comment above claimed the
|
|
236
|
+
// verb-final construction was handled.
|
|
237
|
+
//
|
|
238
|
+
// Honest about what this proxy matches: any word of 3+ letters ending in
|
|
239
|
+
// "en" within the clause, which is the infinitive in practice but also hits
|
|
240
|
+
// a plural noun ("die Zeilen zählen" matches on "Zeilen"). That is
|
|
241
|
+
// acceptable here — the clause already requires "ich werde" and excludes
|
|
242
|
+
// negations and passives, so a forward commitment is what remains — but it
|
|
243
|
+
// is a proxy for the infinitive, not a parse of one.
|
|
244
|
+
/\bich werde\b(?![^.!?\n]*\b(?:nicht|nichts|kein(?:e|en|em|er|es)?|niemals|nie)\b)(?![^.!?\n]*\b(?:gefragt|gebeten|informiert|benachrichtigt)\b)[^.!?\n]*(?<!\p{L})\p{L}{3,}en(?!\p{L})/iu,
|
|
153
245
|
/\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
|
|
154
246
|
/\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
|
|
155
247
|
/\bnext,? I(?:'| wi)ll\b/i,
|
|
@@ -182,8 +274,13 @@ export function detectPromissory(reply: string): Finding | null {
|
|
|
182
274
|
const tail = finalParagraph(reply);
|
|
183
275
|
if (!tail) return null;
|
|
184
276
|
|
|
185
|
-
// A blocking question IS the stop condition — never refuse one.
|
|
186
|
-
|
|
277
|
+
// A blocking question IS the stop condition — never refuse one. But only a
|
|
278
|
+
// question that ENDS the paragraph is that: R2 round 2, finding 16 called
|
|
279
|
+
// `includes('?')` a one-character, trivially learnable bypass, since a
|
|
280
|
+
// rhetorical or quoted question anywhere alongside a promise disabled a
|
|
281
|
+
// blocking guard. The hand-back list below still covers the phrasings that
|
|
282
|
+
// yield without a question mark.
|
|
283
|
+
if (/\?["'’)\]]*\s*$/.test(tail)) return null;
|
|
187
284
|
if (HANDBACK.some((re) => re.test(tail))) return null;
|
|
188
285
|
|
|
189
286
|
for (const re of PROMISSORY) {
|
|
@@ -228,6 +325,23 @@ export function readLanguagePin(workspaceRoot: string): Verdict {
|
|
|
228
325
|
* Fires only when the classifier is CONFIDENT the prose is the other
|
|
229
326
|
* language. `classify` returns `und` below its marker floor, and `und`
|
|
230
327
|
* never fires — a short reply is not evidence of drift.
|
|
328
|
+
*
|
|
329
|
+
* WHAT IS ACTUALLY CLASSIFIED, stated accurately because R2 finding 9 caught
|
|
330
|
+
* this file claiming otherwise in three places. `visibleProse` strips code,
|
|
331
|
+
* quotes, tables, URLs and paths from the whole reply — but `classify` then
|
|
332
|
+
* applies `instructionText` (which drops output-shaped lines and their
|
|
333
|
+
* followers) and `humanAuthoredLead`, and RETURNS ON THE LEAD ALONE when the
|
|
334
|
+
* lead is determined. So in the common case the verdict comes from the reply's
|
|
335
|
+
* opening chunk, not from all of it, and indented prose is discarded before
|
|
336
|
+
* scoring.
|
|
337
|
+
*
|
|
338
|
+
* That is deliberately left as-is rather than swapped for a full-text scorer:
|
|
339
|
+
* sharing one classifier with `language_mirror_hook` is what stops the pin and
|
|
340
|
+
* the detector disagreeing about what language a turn is in, and changing the
|
|
341
|
+
* scoring surface would change the measured rate, which needs its own
|
|
342
|
+
* measurement rather than a quiet edit. What is fixed here is the CLAIM —
|
|
343
|
+
* including the one this function used to put in its own refusal text, where an
|
|
344
|
+
* inflated description of the evidence is worst.
|
|
231
345
|
*/
|
|
232
346
|
export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
|
|
233
347
|
if (pinned !== 'de' && pinned !== 'en') return null;
|
|
@@ -239,9 +353,10 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
|
|
|
239
353
|
detector: 'language',
|
|
240
354
|
evidence: prose.trim().slice(0, 120),
|
|
241
355
|
reason:
|
|
242
|
-
`the pinned reply language is "${pinned}" but the
|
|
243
|
-
`"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers
|
|
244
|
-
'code, quotes,
|
|
356
|
+
`the pinned reply language is "${pinned}" but the reply classifies as ` +
|
|
357
|
+
`"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers; ` +
|
|
358
|
+
'code, quotes, tables, URLs and paths excluded, then scored lead-first ' +
|
|
359
|
+
'by the same classifier that sets the pin)',
|
|
245
360
|
};
|
|
246
361
|
}
|
|
247
362
|
|
|
@@ -253,35 +368,75 @@ function stateDir(workspaceRoot: string): string {
|
|
|
253
368
|
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
|
|
254
369
|
}
|
|
255
370
|
|
|
256
|
-
function stateFile(workspaceRoot: string, turnKey: string): string {
|
|
257
|
-
return path.join(stateDir(workspaceRoot), `${turnKey}.json`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
371
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
372
|
+
* ONE file per session, not one per refused turn. R2 round 1, finding 17:
|
|
373
|
+
* per-turn files accumulated without bound and without a TTL, because "re-arms
|
|
374
|
+
* itself with no cleanup" described the key rotating, not the files being
|
|
375
|
+
* removed.
|
|
376
|
+
*
|
|
377
|
+
* HALF of that is fixed and half is not, stated rather than implied (R2 round 2,
|
|
378
|
+
* finding 12): the per-turn MULTIPLICITY is gone, so a session no longer leaves
|
|
379
|
+
* one file per refused turn. There is still no TTL and no pruning, so a
|
|
380
|
+
* long-lived workspace accumulates one small file per session that ever had a
|
|
381
|
+
* turn refused. Adding expiry is new behaviour and belongs with its own test.
|
|
263
382
|
*/
|
|
264
|
-
|
|
265
|
-
return
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
383
|
+
function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
384
|
+
return path.join(stateDir(workspaceRoot), `${sessionKey}.json`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function deriveSessionKey(sessionId: string): string {
|
|
388
|
+
return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
|
|
270
389
|
}
|
|
271
390
|
|
|
272
|
-
|
|
391
|
+
/**
|
|
392
|
+
* A turn's identity is its ORDINAL — how many genuine user prompts the
|
|
393
|
+
* transcript has carried — never the prompt's text.
|
|
394
|
+
*
|
|
395
|
+
* R2 findings 2 and 3 killed the text-keyed version, in both directions:
|
|
396
|
+
*
|
|
397
|
+
* · keying on sha256(session + last user text) made every REPEAT of a prompt
|
|
398
|
+
* collide with the earlier refused turn, so the second "weiter" / "ok" /
|
|
399
|
+
* "1" was allowed unconditionally. Repeated short continuations are the
|
|
400
|
+
* dominant prompt shape in the corpus this gate was built from, so the
|
|
401
|
+
* gate disabled itself exactly where it was needed;
|
|
402
|
+
* · taking the last user-role entry made the key drift WITHIN one turn,
|
|
403
|
+
* because a compaction summary and a `<system-reminder>` both arrive in the
|
|
404
|
+
* user role. A new key mid-turn re-arms the marker, which is the wedge
|
|
405
|
+
* layer 2 exists to prevent on hosts that send no `stop_hook_active`.
|
|
406
|
+
*
|
|
407
|
+
* An ordinal over `isSyntheticPrompt`-filtered entries fixes both at once: it
|
|
408
|
+
* is distinct for every real turn regardless of what the user typed, and it
|
|
409
|
+
* does not move when the harness injects a user-role entry.
|
|
410
|
+
*/
|
|
411
|
+
export function alreadyRefusedTurn(
|
|
412
|
+
workspaceRoot: string,
|
|
413
|
+
sessionKey: string,
|
|
414
|
+
turnOrdinal: number,
|
|
415
|
+
): boolean {
|
|
273
416
|
try {
|
|
274
|
-
|
|
417
|
+
const raw = fs.readFileSync(sessionStateFile(workspaceRoot, sessionKey), 'utf-8');
|
|
418
|
+
const decoded: unknown = JSON.parse(raw);
|
|
419
|
+
if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
|
|
420
|
+
return (decoded as Record<string, unknown>)['refused_turn'] === turnOrdinal;
|
|
421
|
+
}
|
|
275
422
|
} catch {
|
|
276
|
-
|
|
423
|
+
// absent, unreadable, or malformed state means "not refused yet" —
|
|
424
|
+
// fail-open, per this hook's contract.
|
|
277
425
|
}
|
|
426
|
+
return false;
|
|
278
427
|
}
|
|
279
428
|
|
|
280
|
-
function
|
|
429
|
+
function markRefusedTurn(
|
|
430
|
+
workspaceRoot: string,
|
|
431
|
+
sessionKey: string,
|
|
432
|
+
turnOrdinal: number,
|
|
433
|
+
detector: DetectorId,
|
|
434
|
+
): void {
|
|
281
435
|
if (is_replay_mode()) return;
|
|
282
436
|
try {
|
|
283
|
-
atomic_write_json(
|
|
437
|
+
atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
|
|
284
438
|
refused_at: new Date().toISOString(),
|
|
439
|
+
refused_turn: turnOrdinal,
|
|
285
440
|
detector,
|
|
286
441
|
});
|
|
287
442
|
} catch {
|
|
@@ -297,28 +452,48 @@ function markRefusedThisTurn(workspaceRoot: string, turnKey: string, detector: D
|
|
|
297
452
|
|
|
298
453
|
export interface TranscriptTail {
|
|
299
454
|
lastAssistant: string;
|
|
300
|
-
|
|
455
|
+
/**
|
|
456
|
+
* How many GENUINE user prompts the transcript carries — harness-injected
|
|
457
|
+
* user-role entries excluded via `isSyntheticPrompt`. This is the turn's
|
|
458
|
+
* identity; see `alreadyRefusedTurn` for why the prompt's text is not.
|
|
459
|
+
*/
|
|
460
|
+
turnOrdinal: number;
|
|
301
461
|
}
|
|
302
462
|
|
|
303
463
|
/**
|
|
304
|
-
*
|
|
305
|
-
* Mirrors `chat_history._extract_claude_transcript_response` for
|
|
306
|
-
*
|
|
464
|
+
* The last assistant text, plus the turn ordinal, from a Claude JSONL
|
|
465
|
+
* transcript. Mirrors `chat_history._extract_claude_transcript_response` for
|
|
466
|
+
* the assistant half.
|
|
467
|
+
*
|
|
468
|
+
* `isSyntheticPrompt` is applied to every user-role entry — the same filter
|
|
469
|
+
* `language_mirror_hook` uses, and for the same reason it was added there
|
|
470
|
+
* (round-5 § 6.5): a background-task notification and a `<system-reminder>`
|
|
471
|
+
* both occupy the user role without being chat messages. Counting them would
|
|
472
|
+
* move the turn ordinal mid-turn, which is R2 finding 3.
|
|
307
473
|
*/
|
|
308
474
|
export function readTranscriptTail(
|
|
309
475
|
transcriptPath: string,
|
|
310
476
|
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
311
477
|
): TranscriptTail {
|
|
312
|
-
const empty: TranscriptTail = { lastAssistant: '',
|
|
478
|
+
const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
|
|
313
479
|
if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
|
|
314
480
|
let lines: string[];
|
|
315
481
|
try {
|
|
482
|
+
// R2 finding 12: `maxBytes` was accepted in the options type and never
|
|
483
|
+
// used, so the declared cap was decoration. It is enforced here as well
|
|
484
|
+
// as inside `isSafeTranscriptPath`, because this is the read it bounds.
|
|
485
|
+
// The whole file still has to be walked — the turn ordinal is a count
|
|
486
|
+
// over all entries, not something a tail can answer — so the cap is the
|
|
487
|
+
// guard, not an optimisation.
|
|
488
|
+
if (opts.maxBytes !== undefined && fs.statSync(transcriptPath).size > opts.maxBytes) {
|
|
489
|
+
return empty;
|
|
490
|
+
}
|
|
316
491
|
lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
317
492
|
} catch {
|
|
318
493
|
return empty;
|
|
319
494
|
}
|
|
320
495
|
let lastAssistant = '';
|
|
321
|
-
let
|
|
496
|
+
let turnOrdinal = 0;
|
|
322
497
|
for (const rawLine of lines) {
|
|
323
498
|
const line = rawLine.trim();
|
|
324
499
|
if (!line) continue;
|
|
@@ -330,16 +505,24 @@ export function readTranscriptTail(
|
|
|
330
505
|
}
|
|
331
506
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
|
|
332
507
|
const entry = obj as Record<string, unknown>;
|
|
508
|
+
// Sidechain entries are a SUBAGENT's conversation recorded in the same
|
|
509
|
+
// JSONL. A subagent prompt is a genuine-looking user-role text entry
|
|
510
|
+
// appended mid-turn, so counting it moves the ordinal within the turn —
|
|
511
|
+
// finding 3's failure class in a new shape (R2 round 2, finding 3).
|
|
512
|
+
if (entry['isSidechain'] === true) continue;
|
|
333
513
|
const role = entry['type'];
|
|
334
514
|
if (role !== 'assistant' && role !== 'user') continue;
|
|
335
515
|
const msg = entry['message'];
|
|
336
516
|
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
|
|
337
517
|
const text = _messageText((msg as Record<string, unknown>)['content']);
|
|
338
518
|
if (text === null) continue;
|
|
339
|
-
if (role === 'assistant')
|
|
340
|
-
|
|
519
|
+
if (role === 'assistant') {
|
|
520
|
+
lastAssistant = text;
|
|
521
|
+
} else if (!isSyntheticPrompt(text)) {
|
|
522
|
+
turnOrdinal += 1;
|
|
523
|
+
}
|
|
341
524
|
}
|
|
342
|
-
return { lastAssistant: lastAssistant.trim(),
|
|
525
|
+
return { lastAssistant: lastAssistant.trim(), turnOrdinal };
|
|
343
526
|
}
|
|
344
527
|
|
|
345
528
|
function _messageText(content: unknown): string | null {
|
|
@@ -367,39 +550,52 @@ export interface GateSettings {
|
|
|
367
550
|
}
|
|
368
551
|
|
|
369
552
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
553
|
+
* YAML spellings a human writes for "on". The `yaml` package parses to the 1.2
|
|
554
|
+
* core schema, where `yes` is the STRING "yes" rather than a boolean — so a
|
|
555
|
+
* maintainer who writes `enabled: yes` means it, and a reader that silently
|
|
556
|
+
* treats it as off is the R2 finding-6b defect wearing a parser instead of a
|
|
557
|
+
* regex. Anything not in this set is off; there is no truthiness guessing.
|
|
558
|
+
*/
|
|
559
|
+
function _isOn(v: unknown): boolean | null {
|
|
560
|
+
if (typeof v === 'boolean') return v;
|
|
561
|
+
if (typeof v !== 'string') return null;
|
|
562
|
+
const s = v.trim().toLowerCase();
|
|
563
|
+
if (s === 'true' || s === 'yes' || s === 'on') return true;
|
|
564
|
+
if (s === 'false' || s === 'no' || s === 'off') return false;
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Read the three switches through the real settings cascade.
|
|
570
|
+
*
|
|
571
|
+
* The previous version hand-walked `<workspaceRoot>/.agent-settings.yml` for
|
|
572
|
+
* speed, and R2 finding 6 found three defects in that trade: it ignored the
|
|
573
|
+
* user-global layer entirely (so a maintainer who opts in globally got no gate
|
|
574
|
+
* AND no diagnostic), its end-of-line-anchored regex read
|
|
575
|
+
* `enabled: true # soak opt-in` as FALSE, and it accepted a `turn_end_gate:`
|
|
576
|
+
* block under ANY parent as ON because a text walker has no notion of a parent.
|
|
577
|
+
*
|
|
578
|
+
* The stated reason for skipping the cascade does not survive contact with the
|
|
579
|
+
* sibling concerns: `delegation-nudge` calls `load_agent_settings` on
|
|
580
|
+
* `user_prompt_submit`, i.e. every turn. Correctness on a gate that can refuse
|
|
581
|
+
* a turn-end outranks one settings read per turn-end.
|
|
373
582
|
*/
|
|
374
583
|
export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
375
584
|
const off: GateSettings = { enabled: false, promissory: true, language: true };
|
|
376
|
-
let
|
|
585
|
+
let settings: Record<string, unknown>;
|
|
377
586
|
try {
|
|
378
|
-
|
|
587
|
+
settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
|
|
379
588
|
} catch {
|
|
380
589
|
return off;
|
|
381
590
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
if (headerIdx === -1) return off;
|
|
388
|
-
const headerIndent = (lines[headerIdx]!.match(/^\s*/) ?? [''])[0].length;
|
|
389
|
-
const body: string[] = [];
|
|
390
|
-
for (const line of lines.slice(headerIdx + 1)) {
|
|
391
|
-
if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
|
|
392
|
-
const indent = (line.match(/^\s*/) ?? [''])[0].length;
|
|
393
|
-
if (indent <= headerIndent) break;
|
|
394
|
-
body.push(line);
|
|
395
|
-
}
|
|
591
|
+
const hooks = settings['hooks'];
|
|
592
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return off;
|
|
593
|
+
const block = (hooks as Record<string, unknown>)['turn_end_gate'];
|
|
594
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) return off;
|
|
595
|
+
const gate = block as Record<string, unknown>;
|
|
396
596
|
const flag = (name: string, dflt: boolean): boolean => {
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
const m = line.match(re);
|
|
400
|
-
if (m) return m[1] === 'true';
|
|
401
|
-
}
|
|
402
|
-
return dflt;
|
|
597
|
+
const v = _isOn(gate[name]);
|
|
598
|
+
return v === null ? dflt : v;
|
|
403
599
|
};
|
|
404
600
|
return {
|
|
405
601
|
enabled: flag('enabled', false),
|
|
@@ -436,23 +632,35 @@ export function main(): number {
|
|
|
436
632
|
const transcriptPath = str(
|
|
437
633
|
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
438
634
|
);
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
)
|
|
635
|
+
// No override of the home-confinement check on the production path. R2
|
|
636
|
+
// finding 13: the previous `AGENT_CONFIG_TRANSCRIPT_HOME` widened the one
|
|
637
|
+
// security-relevant predicate in this hook, and an env var that relaxes a
|
|
638
|
+
// path-confinement check IS a bypass of it, whatever the comment says. The
|
|
639
|
+
// tests set `HOME` on the spawned process instead — `os.homedir()` honours
|
|
640
|
+
// it — so the branch is still exercised with no product-code seam.
|
|
641
|
+
// The cap is passed HERE, at the only production call site. R2 round 2,
|
|
642
|
+
// finding 7: enforcing it inside the function while `main()` called
|
|
643
|
+
// `readTranscriptTail(transcriptPath)` with no options left the guard dead
|
|
644
|
+
// in production while its comment claimed it was enforced — the same
|
|
645
|
+
// fixed-the-definition-not-the-caller shape as two other findings in that
|
|
646
|
+
// round, which is why this line exists rather than a default parameter.
|
|
647
|
+
const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
|
|
648
|
+
maxBytes: TRANSCRIPT_READ_MAX_BYTES,
|
|
649
|
+
});
|
|
448
650
|
if (!lastAssistant) return EXIT_ALLOW;
|
|
449
651
|
|
|
450
|
-
// Layer 2 — the turn
|
|
451
|
-
|
|
652
|
+
// Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
|
|
653
|
+
// A host that sends no `session_id` shares one bucket AND one small-integer
|
|
654
|
+
// ordinal namespace, so an unrelated session whose ordinal matches a stored
|
|
655
|
+
// `refused_turn` reads as already-refused and goes unguarded. R2 round 2,
|
|
656
|
+
// finding 16: the sibling hook documents its own bucket as degrading rather
|
|
657
|
+
// than colliding, and this one collides. It is named here rather than
|
|
658
|
+
// papered over — the degradation is toward UNDER-refusing, which is the safe
|
|
659
|
+
// direction, and layer 1 still covers the host that sends the flag.
|
|
660
|
+
const sessionKey = deriveSessionKey(
|
|
452
661
|
str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
|
|
453
|
-
lastUser,
|
|
454
662
|
);
|
|
455
|
-
if (
|
|
663
|
+
if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
|
|
456
664
|
|
|
457
665
|
const findings: Finding[] = [];
|
|
458
666
|
if (settings.promissory) {
|
|
@@ -465,7 +673,7 @@ export function main(): number {
|
|
|
465
673
|
}
|
|
466
674
|
if (findings.length === 0) return EXIT_ALLOW;
|
|
467
675
|
|
|
468
|
-
|
|
676
|
+
markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
|
|
469
677
|
|
|
470
678
|
const lines = findings.map(
|
|
471
679
|
(f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
|