@yagni-app/code-staging 1.1.1-staging.1359.1 → 1.1.1-staging.1361.1
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/extension/index.js +72 -0
- package/dist/extension/permission/approvedPrefixes.d.ts +28 -1
- package/dist/extension/permission/approvedPrefixes.js +92 -17
- package/dist/extension/permission/gate.d.ts +30 -0
- package/dist/extension/permission/gate.js +177 -23
- package/dist/extension/permission/prefixExtract.d.ts +171 -0
- package/dist/extension/permission/prefixExtract.js +342 -0
- package/dist/extension/permissionRules/shellRules.d.ts +25 -0
- package/dist/extension/permissionRules/shellRules.js +45 -2
- package/dist/extension/pipeline/personas.js +24 -0
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -580,6 +580,64 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
580
580
|
fields,
|
|
581
581
|
});
|
|
582
582
|
};
|
|
583
|
+
/**
|
|
584
|
+
* The prefix-consult failure sink (L2): fire-and-forget POST of one
|
|
585
|
+
* consult's outcome to the backend intake (log + hit-rate counters + the
|
|
586
|
+
* `Prefix Consult` Sentry area). A hit/none outcome is NOT a failure —
|
|
587
|
+
* its counter rides the same POST so the dashboard has both sides.
|
|
588
|
+
* Outcomes are a closed vocabulary, never command content. Fail-soft
|
|
589
|
+
* (the failure itself is sink-logged with the status or error class —
|
|
590
|
+
* never the thrown message, which can carry provider payloads);
|
|
591
|
+
* suppressed under test/eval so a CI run never phones prod.
|
|
592
|
+
*/
|
|
593
|
+
const makePrefixErrorSink = () => (ev) => {
|
|
594
|
+
if (evalMode || runningUnderTest(env))
|
|
595
|
+
return;
|
|
596
|
+
void (async () => {
|
|
597
|
+
try {
|
|
598
|
+
const body = {
|
|
599
|
+
outcome: ev.outcome,
|
|
600
|
+
...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
|
|
601
|
+
...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
|
|
602
|
+
...(ev.tier ? { tier: ev.tier } : {}),
|
|
603
|
+
};
|
|
604
|
+
const res = await resilientFetch(`${baseUrl}/api/yagni-code/prefix-error`, {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"content-type": "application/json",
|
|
608
|
+
authorization: `Bearer ${getTokenFn() ?? ""}`,
|
|
609
|
+
...attributionHeaders(deps.env),
|
|
610
|
+
},
|
|
611
|
+
body: JSON.stringify(body),
|
|
612
|
+
}, {
|
|
613
|
+
fetchImpl: authedFetch,
|
|
614
|
+
policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
|
|
615
|
+
});
|
|
616
|
+
if (!res.ok) {
|
|
617
|
+
logEvent({
|
|
618
|
+
source: "prefix",
|
|
619
|
+
level: "warn",
|
|
620
|
+
event: "prefix_error_post_failed",
|
|
621
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
622
|
+
fields: { status: res.status, outcome: ev.outcome },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
logEvent({
|
|
628
|
+
source: "prefix",
|
|
629
|
+
level: "warn",
|
|
630
|
+
event: "prefix_error_post_failed",
|
|
631
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
632
|
+
fields: {
|
|
633
|
+
kind: "network",
|
|
634
|
+
outcome: ev.outcome,
|
|
635
|
+
errorClass: err instanceof Error ? err.constructor.name : typeof err,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
})();
|
|
640
|
+
};
|
|
583
641
|
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
|
|
584
642
|
// at startup (grants added by other concurrent sessions appear next launch —
|
|
585
643
|
// the startup load is the trust boundary; live reload was reviewed and
|
|
@@ -700,6 +758,20 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
700
758
|
: {}),
|
|
701
759
|
guardianDisabled,
|
|
702
760
|
childUsage,
|
|
761
|
+
// L2 prefix consult: enabled for interactive sessions with a live model
|
|
762
|
+
// backend (headless /go children and eval lanes never render the dialogs
|
|
763
|
+
// whose seeds it refines — the consult there is pure spend). The sink
|
|
764
|
+
// line + the failure POST live in onPrefixConsult below.
|
|
765
|
+
// YAGNI_DISABLE_PREFIX_CONSULT=1 is the runtime kill switch — a consult
|
|
766
|
+
// regression (cost spike, latency, bad seeds) disables the layer with no
|
|
767
|
+
// redeploy. Registration-scoped (read once here, like every other env
|
|
768
|
+
// gate at activation): toggling it mid-session does nothing until the
|
|
769
|
+
// session restarts. When set, the gate seeds from the L1 ladder only,
|
|
770
|
+
// byte-identical to the pre-consult behavior.
|
|
771
|
+
...(evalMode || env.YAGNI_DISABLE_PREFIX_CONSULT === "1" || env.YAGNI_DISABLE_PREFIX_CONSULT === "true"
|
|
772
|
+
? {}
|
|
773
|
+
: { prefixConsult: {} }),
|
|
774
|
+
onPrefixConsult: makePrefixErrorSink(),
|
|
703
775
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
704
776
|
onGuardianReview: (ev) => {
|
|
705
777
|
guardianLogSink(ev);
|
|
@@ -51,7 +51,15 @@ export interface ApprovedPrefixFile {
|
|
|
51
51
|
version: 1;
|
|
52
52
|
grants: ApprovedPrefixGrant[];
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Tools whose second token is a subcommand worth capturing in a prefix.
|
|
56
|
+
* Kept ONLY as a fast-path marker (the flag-first fallback `pnpm --filter X
|
|
57
|
+
* test` → ["pnpm"]); the two-token rung itself is GENERIC — any clean command
|
|
58
|
+
* word + subcommand-shaped second token derives [tool, subcommand]
|
|
59
|
+
* (Claude's getCommandPrefix shape, not gated on a tool list), so
|
|
60
|
+
* `linear issue view` earns ["linear","issue"] without this repo ever
|
|
61
|
+
* having heard of `linear`.
|
|
62
|
+
*/
|
|
55
63
|
export declare const MULTI_SUBCOMMAND_TOOLS: Set<string>;
|
|
56
64
|
/**
|
|
57
65
|
* Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
|
|
@@ -173,6 +181,25 @@ export declare function validateGrantForEscape(command: string, policy: ExecPoli
|
|
|
173
181
|
seeds: ApprovedPrefixGrant[];
|
|
174
182
|
description: string;
|
|
175
183
|
} | null;
|
|
184
|
+
/**
|
|
185
|
+
* The GUARDIAN-ASK seed ladder (the ask-flow counterpart of
|
|
186
|
+
* {@link validateGrantForEscape}). One deliberate difference from the escape
|
|
187
|
+
* flow, a Claude mirror: READ-ONLY segments of a compound are skipped — a
|
|
188
|
+
* `cd src && npm test` ask suggests only `npm test`, because the cd/exec-allow
|
|
189
|
+
* segments are not the thing the user is being asked about (Claude's
|
|
190
|
+
* sandboxed-permission ask does the same). The escape flow keeps its
|
|
191
|
+
* stricter all-segments stance: the unit of consent THERE is running with
|
|
192
|
+
* full unsandboxed authority, so even read-only segments ride the consent.
|
|
193
|
+
*
|
|
194
|
+
* Same five rungs as the escape ladder otherwise (token patterns per
|
|
195
|
+
* uncovered segment → heredoc prefix → first line → full literal), all
|
|
196
|
+
* self-match-proved — a seed that cannot cover its own command is never
|
|
197
|
+
* offered.
|
|
198
|
+
*/
|
|
199
|
+
export declare function validateGrantForAsk(command: string, policy: ExecPolicy, repoKey: string): {
|
|
200
|
+
seeds: ApprovedPrefixGrant[];
|
|
201
|
+
description: string;
|
|
202
|
+
} | null;
|
|
176
203
|
/**
|
|
177
204
|
* Compound evaluation for the escape flow (Claude's subcommandResults
|
|
178
205
|
* analog): every segment must be covered — by a grant, by exec-policy
|
|
@@ -32,7 +32,15 @@ import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPol
|
|
|
32
32
|
import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
|
|
33
33
|
import { codeStateHome } from "../stateHome.js";
|
|
34
34
|
// --- Derivation ---
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Tools whose second token is a subcommand worth capturing in a prefix.
|
|
37
|
+
* Kept ONLY as a fast-path marker (the flag-first fallback `pnpm --filter X
|
|
38
|
+
* test` → ["pnpm"]); the two-token rung itself is GENERIC — any clean command
|
|
39
|
+
* word + subcommand-shaped second token derives [tool, subcommand]
|
|
40
|
+
* (Claude's getCommandPrefix shape, not gated on a tool list), so
|
|
41
|
+
* `linear issue view` earns ["linear","issue"] without this repo ever
|
|
42
|
+
* having heard of `linear`.
|
|
43
|
+
*/
|
|
36
44
|
export const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
37
45
|
"git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
|
|
38
46
|
]);
|
|
@@ -163,21 +171,24 @@ export function derivePrefix(command) {
|
|
|
163
171
|
return null;
|
|
164
172
|
if (BANNED_PREFIXES.has(first))
|
|
165
173
|
return null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return [first];
|
|
176
|
-
}
|
|
177
|
-
// first-word shape check (Claude's regex): rejects paths, flags, numbers,
|
|
178
|
-
// filenames; only clean lowercase command words.
|
|
179
|
-
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first))
|
|
174
|
+
// GENERIC two-token rung (Claude parity): a known multi-subcommand tool
|
|
175
|
+
// short-circuits the word-shape check (its first tokens are known clean),
|
|
176
|
+
// but ANY first token passing the command-word regex earns the
|
|
177
|
+
// [tool, subcommand] pattern when the second token is subcommand-shaped —
|
|
178
|
+
// `linear issue view` → ["linear","issue"] from a tool list that does
|
|
179
|
+
// not contain `linear`.
|
|
180
|
+
const isCleanWord = MULTI_SUBCOMMAND_TOOLS.has(first)
|
|
181
|
+
|| /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first);
|
|
182
|
+
if (!isCleanWord)
|
|
180
183
|
return null;
|
|
184
|
+
const second = tokens[1];
|
|
185
|
+
if (second && !second.startsWith("-") && SAFE_SUBCOMMAND_RE.test(second)) {
|
|
186
|
+
return [first, second];
|
|
187
|
+
}
|
|
188
|
+
// flag-first shape (`pnpm --filter X test`, `linear --json issue view`):
|
|
189
|
+
// Claude's first-word fallback applies — the editable field is the
|
|
190
|
+
// narrowing control, and a bare tool grant is exactly what a human
|
|
191
|
+
// edits down from there.
|
|
181
192
|
return [first];
|
|
182
193
|
}
|
|
183
194
|
/**
|
|
@@ -196,7 +207,11 @@ export function storagePrefix(command) {
|
|
|
196
207
|
const slash = rawFirst.lastIndexOf("/");
|
|
197
208
|
const first = slash >= 0 ? rawFirst.slice(slash + 1) : rawFirst;
|
|
198
209
|
const second = tokens[1];
|
|
199
|
-
|
|
210
|
+
// LOCKSTEP with derivePrefix's generic two-token rung: any clean command
|
|
211
|
+
// word + subcommand-shaped second token labels as "tool subcommand".
|
|
212
|
+
const cleanWord = MULTI_SUBCOMMAND_TOOLS.has(first)
|
|
213
|
+
|| /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first);
|
|
214
|
+
if (cleanWord && second && SAFE_SUBCOMMAND_RE.test(second) && !second.startsWith("-")) {
|
|
200
215
|
return `${first} ${second}`;
|
|
201
216
|
}
|
|
202
217
|
return first;
|
|
@@ -595,6 +610,60 @@ export function validateGrantForEscape(command, policy, repoKey) {
|
|
|
595
610
|
// offered (null = no remember option at all).
|
|
596
611
|
return deriveRememberSeeds(command, [command]);
|
|
597
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* The GUARDIAN-ASK seed ladder (the ask-flow counterpart of
|
|
615
|
+
* {@link validateGrantForEscape}). One deliberate difference from the escape
|
|
616
|
+
* flow, a Claude mirror: READ-ONLY segments of a compound are skipped — a
|
|
617
|
+
* `cd src && npm test` ask suggests only `npm test`, because the cd/exec-allow
|
|
618
|
+
* segments are not the thing the user is being asked about (Claude's
|
|
619
|
+
* sandboxed-permission ask does the same). The escape flow keeps its
|
|
620
|
+
* stricter all-segments stance: the unit of consent THERE is running with
|
|
621
|
+
* full unsandboxed authority, so even read-only segments ride the consent.
|
|
622
|
+
*
|
|
623
|
+
* Same five rungs as the escape ladder otherwise (token patterns per
|
|
624
|
+
* uncovered segment → heredoc prefix → first line → full literal), all
|
|
625
|
+
* self-match-proved — a seed that cannot cover its own command is never
|
|
626
|
+
* offered.
|
|
627
|
+
*/
|
|
628
|
+
export function validateGrantForAsk(command, policy, repoKey) {
|
|
629
|
+
// Same defense-in-depth refusal as the escape ladder.
|
|
630
|
+
try {
|
|
631
|
+
if (classifyCommand(command, policy).decision === "forbidden")
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
// Compound: seed every segment that is neither covered by a grant,
|
|
638
|
+
// read-only (exec-policy allow — the Claude mirror skip), nor an env
|
|
639
|
+
// assignment no-op. `forbidden` was refused above; classify-error
|
|
640
|
+
// segments count as uncovered (fail closed at the seed level too).
|
|
641
|
+
const compound = evaluateCompoundForEscape(command, [], repoKey, policy);
|
|
642
|
+
const mutating = [];
|
|
643
|
+
for (const verdict of compound.segments) {
|
|
644
|
+
if (verdict.kind === "uncovered" || verdict.kind === "classify-error") {
|
|
645
|
+
mutating.push(verdict.segment);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (mutating.length > 0) {
|
|
649
|
+
const res = deriveRememberSeeds(command, mutating, repoKey);
|
|
650
|
+
if (res && res.seeds.length > 0)
|
|
651
|
+
return res;
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
// A compound whose every segment is covered/readonly/assignment, or a
|
|
655
|
+
// single command: the single-command rungs (token → literal ladder).
|
|
656
|
+
if (isSinglePlainCommand(command)) {
|
|
657
|
+
const pattern = derivePrefix(command);
|
|
658
|
+
if (pattern) {
|
|
659
|
+
const candidate = { pattern, repoKey, addedAt: new Date().toISOString(), cwd: "" };
|
|
660
|
+
if (matchesGrant(command, [candidate], repoKey)) {
|
|
661
|
+
return { seeds: [candidate], description: describePrefix(pattern) };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return deriveRememberSeeds(command, [command]);
|
|
666
|
+
}
|
|
598
667
|
/**
|
|
599
668
|
* Quote-preserving segment splitter for the ESCAPE flow (a local, faithful
|
|
600
669
|
* counterpart to shellRules' splitSubcommands — which rejoins tokens with
|
|
@@ -725,8 +794,14 @@ export function isDerivablePattern(pattern) {
|
|
|
725
794
|
if (BANNED_PREFIXES.has(first))
|
|
726
795
|
return false;
|
|
727
796
|
if (pattern.length === 2) {
|
|
797
|
+
// LOCKSTEP with derivePrefix's generic two-token rung: the first token
|
|
798
|
+
// only needs the command-word shape (the MULTI_SUBCOMMAND_TOOLS
|
|
799
|
+
// membership check was dropped when the rung went generic). Keeping the
|
|
800
|
+
// old set-membership check here would silently drop every
|
|
801
|
+
// generic-rung grant (["linear","issue"]) as "tampered" at next
|
|
802
|
+
// startup — the user's approval would evaporate.
|
|
728
803
|
const second = pattern[1];
|
|
729
|
-
if (!MULTI_SUBCOMMAND_TOOLS.has(first))
|
|
804
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first) && !MULTI_SUBCOMMAND_TOOLS.has(first))
|
|
730
805
|
return false;
|
|
731
806
|
if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
732
807
|
return false;
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
|
|
31
|
+
import { type PrefixDiagnosticEvent } from "./prefixExtract.js";
|
|
31
32
|
import type { HookRunner } from "../hooks.js";
|
|
32
33
|
import { type BlessStore } from "../bless.js";
|
|
33
34
|
import { type ExecPolicy } from "./execPolicy.js";
|
|
@@ -304,6 +305,35 @@ export interface RegisterPermissionDeps {
|
|
|
304
305
|
* and PermissionRequest hooks run before the confirm dialog.
|
|
305
306
|
*/
|
|
306
307
|
hookRunner?: HookRunner;
|
|
308
|
+
/**
|
|
309
|
+
* LLM prefix-consult configuration (layer 2 of the prefix-suggestion
|
|
310
|
+
* stack). When present AND the session has a UI, an ask dialog whose L1
|
|
311
|
+
* ladder could not derive a usable token seed fires one efficient-tier
|
|
312
|
+
* consult per uncovered segment (Claude's getCommandSubcommandPrefix
|
|
313
|
+
* shape) and folds validated candidates into the remember options after
|
|
314
|
+
* a bounded grace window. Absent = disabled: dialogs seed from L1 only,
|
|
315
|
+
* byte-identical to the pre-consult behavior.
|
|
316
|
+
*/
|
|
317
|
+
prefixConsult?: {
|
|
318
|
+
tier?: string;
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Injectable consult function (tests pass a stub; the default is
|
|
322
|
+
* consultPrefixMemoized — the wiring layer keeps ONE memo per session).
|
|
323
|
+
* Mirrors the guardianReview seam.
|
|
324
|
+
*/
|
|
325
|
+
prefixConsultFn?: (segment: string, deps: import("./prefixExtract.js").PrefixConsultDeps & {
|
|
326
|
+
onEvent?: (ev: PrefixDiagnosticEvent) => void;
|
|
327
|
+
}) => Promise<import("./prefixExtract.js").PrefixConsultResult & {
|
|
328
|
+
outcome: PrefixDiagnosticEvent["outcome"];
|
|
329
|
+
}>;
|
|
330
|
+
/**
|
|
331
|
+
* Called (fire-and-forget) after each prefix consult with a sanitized
|
|
332
|
+
* diagnostic event (the wiring layer posts client-only failures to the
|
|
333
|
+
* backend's prefix-error intake and bumps counters). Fail-soft; never
|
|
334
|
+
* blocks the gate.
|
|
335
|
+
*/
|
|
336
|
+
onPrefixConsult?: (event: PrefixDiagnosticEvent) => void;
|
|
307
337
|
}
|
|
308
338
|
/** The customType tag on injected mode-context messages (filterable later). */
|
|
309
339
|
export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* When the mode leaves plan, stale plan-context messages are filtered out of
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
|
-
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix,
|
|
29
|
+
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
30
|
+
import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
|
|
30
31
|
import { logEvent } from "../errorSink.js";
|
|
31
32
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
32
33
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
@@ -480,6 +481,112 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
480
481
|
approvedCommands.delete(oldest);
|
|
481
482
|
}
|
|
482
483
|
};
|
|
484
|
+
// L2 prefix-consult memo: one LRU per session (keyed by segment string —
|
|
485
|
+
// cwd-independent by design; a `pnpm test` prefix is the same answer in
|
|
486
|
+
// any directory, and repo scoping happens at GRANT time, not consult time).
|
|
487
|
+
const prefixMemo = createPrefixMemo();
|
|
488
|
+
const runPrefixConsult = deps.prefixConsultFn ?? consultPrefixMemoized;
|
|
489
|
+
/** Shared consult-call builder: routes through the injectable seam with
|
|
490
|
+
* the sink line + the onPrefixConsult hook — ONE place so the escape and
|
|
491
|
+
* Guardian-ask fan-outs cannot drift. */
|
|
492
|
+
const consultSegment = (seg, cwd, signal, tier) => runPrefixConsult(seg, {
|
|
493
|
+
cwd,
|
|
494
|
+
signal,
|
|
495
|
+
memo: prefixMemo,
|
|
496
|
+
...(tier ? { modelTier: tier } : {}),
|
|
497
|
+
onEvent: (ev) => {
|
|
498
|
+
try {
|
|
499
|
+
logEvent({
|
|
500
|
+
source: "prefix",
|
|
501
|
+
level: "info",
|
|
502
|
+
event: "prefix_consult",
|
|
503
|
+
fields: { outcome: ev.outcome, ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}) },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch { /* telemetry must never affect the gate */ }
|
|
507
|
+
try {
|
|
508
|
+
deps.onPrefixConsult?.(ev);
|
|
509
|
+
}
|
|
510
|
+
catch { /* fail-soft */ }
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
/** The uncovered MUTATING segments of a compound for the ask-flow consult
|
|
514
|
+
* fan-out — the same set validateGrantForAsk seeds, extracted so the
|
|
515
|
+
* consult does not re-derive them (one source of truth for the skip rule). */
|
|
516
|
+
const segmentsUncoveredForAsk = (command, policy) => {
|
|
517
|
+
const verdicts = evaluateCompoundForEscape(command, [], "", policy).segments;
|
|
518
|
+
return verdicts
|
|
519
|
+
.filter((v) => v.kind === "uncovered" || v.kind === "classify-error")
|
|
520
|
+
.map((v) => v.segment);
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* The SHARED L2 fold — ONE implementation for the escape and Guardian-ask
|
|
524
|
+
* fan-outs (extracted so the two cannot drift). Fans out over the raw
|
|
525
|
+
* uncovered segments (raw, not canonicalized: Claude consults the command
|
|
526
|
+
* as typed, the persona's spec covers env-prefixed shapes, and the memo
|
|
527
|
+
* key matches what a retry re-sends), skips segments an L1 TOKEN seed
|
|
528
|
+
* already covers, folds validated candidates into the seeds bounded by
|
|
529
|
+
* the grace window, and keeps the L1 LITERAL seed as a fallback whenever
|
|
530
|
+
* any consulted segment produced NO candidate — a partial fold that
|
|
531
|
+
* dropped the literal would cover fewer segments than the ladder did, and
|
|
532
|
+
* picking remember would then re-ask on retry (the fold-regression).
|
|
533
|
+
* L2 token candidates supersede the literal ONLY when every consulted
|
|
534
|
+
* segment yielded one; otherwise the literal keeps the coverage floor.
|
|
535
|
+
*/
|
|
536
|
+
const foldConsultSeeds = async (args) => {
|
|
537
|
+
const { l1Seeds } = args;
|
|
538
|
+
if (!deps.prefixConsult)
|
|
539
|
+
return l1Seeds;
|
|
540
|
+
const l1TokenPatterns = new Set((l1Seeds?.seeds ?? []).map((s) => s.pattern.join(" ")).filter((p) => p.length > 0));
|
|
541
|
+
const l1LiteralSeeds = (l1Seeds?.seeds ?? []).filter((s) => s.pattern.length === 0);
|
|
542
|
+
// RAW segments, deduped, capped at the seed cap.
|
|
543
|
+
const segments = [...new Set(args.uncoveredSegments.map((s) => s.trim()).filter((s) => s.length > 0))].slice(0, 5);
|
|
544
|
+
const toConsult = segments.filter((seg) => !(l1TokenPatterns.size > 0 && [...l1TokenPatterns].some((p) => seg.startsWith(p))));
|
|
545
|
+
if (toConsult.length === 0)
|
|
546
|
+
return l1Seeds;
|
|
547
|
+
let answeredSegments = 0;
|
|
548
|
+
const fold = Promise.allSettled(toConsult.map((seg) => consultSegment(seg, args.cwd, args.signal, args.tier))).then((results) => {
|
|
549
|
+
// Two INDEPENDENT passes — counting and seed-filtering are different
|
|
550
|
+
// questions. `answered` counts every yielded candidate (including one
|
|
551
|
+
// duplicating an L1 token: the consult DID answer that segment), in
|
|
552
|
+
// its own pass over the results. The second pass de-dupes L1-token
|
|
553
|
+
// lookalikes from the SEED list only — the duplicate is noise there,
|
|
554
|
+
// not an unanswered segment. Comparing the de-duped `extra` against
|
|
555
|
+
// `toConsult` would misread a fully-answered fold as partial whenever
|
|
556
|
+
// an answer coincided with an L1 token.
|
|
557
|
+
const answered = results
|
|
558
|
+
.map((r) => (r.status === "fulfilled" ? r.value.candidate : null))
|
|
559
|
+
.filter((c) => c !== null);
|
|
560
|
+
answeredSegments = answered.length;
|
|
561
|
+
return answered
|
|
562
|
+
.filter((c) => !l1TokenPatterns.has(c.pattern.join(" ")))
|
|
563
|
+
.map((c) => ({ pattern: c.pattern, repoKey: "", addedAt: "", cwd: "" }));
|
|
564
|
+
}, () => []);
|
|
565
|
+
const extra = await Promise.race([
|
|
566
|
+
fold,
|
|
567
|
+
new Promise((resolve) => {
|
|
568
|
+
const t = setTimeout(() => resolve([]), PREFIX_CONSULT_GRACE_MS);
|
|
569
|
+
t.unref?.();
|
|
570
|
+
}),
|
|
571
|
+
]);
|
|
572
|
+
if (extra.length === 0)
|
|
573
|
+
return l1Seeds;
|
|
574
|
+
// Did every consulted segment yield a candidate? Only then do the L2
|
|
575
|
+
// tokens supersede the literal; a partial fold keeps the literal as the
|
|
576
|
+
// coverage floor (token + literal, deduped, capped at 5).
|
|
577
|
+
const complete = answeredSegments >= toConsult.length;
|
|
578
|
+
const combined = [
|
|
579
|
+
...(l1Seeds?.seeds ?? []).filter((s) => s.pattern.length > 0),
|
|
580
|
+
...extra,
|
|
581
|
+
...(complete ? [] : l1LiteralSeeds),
|
|
582
|
+
].slice(0, 5);
|
|
583
|
+
return {
|
|
584
|
+
seeds: combined,
|
|
585
|
+
description: combined
|
|
586
|
+
.map((s) => (s.pattern.length > 0 ? describePrefix(s.pattern) : (s.literal ?? "")))
|
|
587
|
+
.join(", "),
|
|
588
|
+
};
|
|
589
|
+
};
|
|
483
590
|
// Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
|
|
484
591
|
// user prompt — NOT per LLM turn): genuine ask verdicts are uncapped (the
|
|
485
592
|
// user's patience is the bound); error-fallback asks are capped so
|
|
@@ -1095,20 +1202,45 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1095
1202
|
}
|
|
1096
1203
|
}
|
|
1097
1204
|
// Offer "don't ask again" only when the grant would actually
|
|
1098
|
-
// cover this command (grant-time validation).
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1205
|
+
// cover this command (grant-time validation). The ask-flow ladder
|
|
1206
|
+
// (validateGrantForAsk — the escape flow's shape, with the
|
|
1207
|
+
// read-only segment skip Claude's ask does) provides the L1
|
|
1208
|
+
// seeds: token patterns per uncovered mutating segment → literal
|
|
1209
|
+
// rungs. The L2 prefix consult folds validated candidates in over
|
|
1210
|
+
// the same grace window as the escape flow (auto-mode only — ask
|
|
1211
|
+
// grants are auto-mode-only, so a consult in review/plan mode
|
|
1212
|
+
// buys seeds that can never be used).
|
|
1213
|
+
const askSeeds = validateGrantForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
1214
|
+
// L2 prefix consult (auto mode only — the seeds it refines are
|
|
1215
|
+
// auto-mode grants): the SHARED fold, over the ask-ladder's
|
|
1216
|
+
// uncovered mutating segments; failures are invisible (the
|
|
1217
|
+
// dialog opens with L1 seeds).
|
|
1218
|
+
const seedsForAsk = modeAtEntry === "auto"
|
|
1219
|
+
? await foldConsultSeeds({
|
|
1220
|
+
l1Seeds: askSeeds,
|
|
1221
|
+
uncoveredSegments: askSeeds === null ? [] : segmentsUncoveredForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY),
|
|
1222
|
+
cwd,
|
|
1223
|
+
signal: ctx.signal,
|
|
1224
|
+
tier: deps.prefixConsult?.tier,
|
|
1225
|
+
})
|
|
1226
|
+
: askSeeds;
|
|
1227
|
+
// The settings-rule option rides TOKEN seeds ONLY: a literal
|
|
1228
|
+
// seed persisted as `Bash(<literal>:*)` is a prefix rule with no
|
|
1229
|
+
// inert-remainder fence (the grant-side literal rung HAS the
|
|
1230
|
+
// fence — approvedPrefixes) — anything starting with that
|
|
1231
|
+
// literal would be allowed in settings. Literal seeds keep the
|
|
1232
|
+
// grant path (repo-scoped, fenced).
|
|
1233
|
+
const grantCandidate = seedsForAsk && seedsForAsk.seeds.length === 1
|
|
1234
|
+
? { pattern: seedsForAsk.seeds[0].pattern, literal: seedsForAsk.seeds[0].literal }
|
|
1235
|
+
: null;
|
|
1236
|
+
const ruleCandidate = grantCandidate && grantCandidate.pattern.length > 0
|
|
1105
1237
|
? `Bash(${grantCandidate.pattern.join(" ")}:*)`
|
|
1106
1238
|
: null;
|
|
1107
|
-
const rememberLabel =
|
|
1108
|
-
? `Yes, and don't ask again for \`${
|
|
1239
|
+
const rememberLabel = seedsForAsk
|
|
1240
|
+
? `Yes, and don't ask again for \`${seedsForAsk.description}\` in this repo`
|
|
1109
1241
|
: null;
|
|
1110
|
-
const ruleLabel =
|
|
1111
|
-
? `Yes, and always allow \`${
|
|
1242
|
+
const ruleLabel = ruleCandidate && deps.persistUserRule
|
|
1243
|
+
? `Yes, and always allow \`${ruleCandidate.replace(/^Bash\(/, "").replace(/:\*\)$/, "")}\` in this project's local settings`
|
|
1112
1244
|
: null;
|
|
1113
1245
|
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel, "guardian_verdict", "bash");
|
|
1114
1246
|
if (resolution === "yes") {
|
|
@@ -1167,14 +1299,17 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1167
1299
|
});
|
|
1168
1300
|
return {};
|
|
1169
1301
|
}
|
|
1170
|
-
if (resolution === "remember" &&
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1302
|
+
if (resolution === "remember" && seedsForAsk) {
|
|
1303
|
+
for (const seed of seedsForAsk.seeds) {
|
|
1304
|
+
const grantRecord = {
|
|
1305
|
+
...seed,
|
|
1306
|
+
repoKey: resolveRepoKeyFor(cwd),
|
|
1307
|
+
cwd,
|
|
1308
|
+
addedAt: new Date().toISOString(),
|
|
1309
|
+
};
|
|
1310
|
+
grants.push(grantRecord);
|
|
1311
|
+
persistGrantFailSoft(grantRecord);
|
|
1312
|
+
}
|
|
1178
1313
|
emitGateEvent(slot, {
|
|
1179
1314
|
...eventBase,
|
|
1180
1315
|
outcome: "ask_approved_remembered",
|
|
@@ -1497,9 +1632,28 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1497
1632
|
}
|
|
1498
1633
|
}
|
|
1499
1634
|
// The remember seeds + dialog options. validateGrantForEscape owns the
|
|
1500
|
-
// full ladder (token patterns → heredoc prefix → first line → full
|
|
1501
|
-
// literal) with the fencing/forbidden proofs.
|
|
1502
|
-
|
|
1635
|
+
// full L1 ladder (token patterns → heredoc prefix → first line → full
|
|
1636
|
+
// literal) with the fencing/forbidden proofs. The L2 prefix consult
|
|
1637
|
+
// (layer 2) fans out over the command's uncovered segments in parallel
|
|
1638
|
+
// (Claude's getCommandSubcommandPrefix shape: full command + one consult
|
|
1639
|
+
// per segment, Promise.all): a segment the L1 token rung already covers
|
|
1640
|
+
// needs no consult (its seed is already as good as L2 can get); the
|
|
1641
|
+
// consults keep running past the grace window — whatever lands late
|
|
1642
|
+
// memoizes and serves a retry instantly, and the dialog opens after at
|
|
1643
|
+
// most PREFIX_CONSULT_GRACE_MS with the best seeds available at open
|
|
1644
|
+
// time. Consult failure is invisible: the dialog opens with L1 seeds.
|
|
1645
|
+
const l1Seeds = validateGrantForEscape(command, execPolicy, repoKey);
|
|
1646
|
+
// The shared L2 fold (see foldConsultSeeds): consult fan-out over the
|
|
1647
|
+
// uncovered segments, grace-bounded, literal-fallback preserved. Runs
|
|
1648
|
+
// only with a UI (the headless gate returned above), and consult failure
|
|
1649
|
+
// is invisible — the dialog opens with the L1 seeds.
|
|
1650
|
+
const seeds = await foldConsultSeeds({
|
|
1651
|
+
l1Seeds,
|
|
1652
|
+
uncoveredSegments: compound.uncovered,
|
|
1653
|
+
cwd,
|
|
1654
|
+
signal: ctx.signal,
|
|
1655
|
+
tier: deps.prefixConsult?.tier,
|
|
1656
|
+
});
|
|
1503
1657
|
const ASK_YES_ESC = "Yes, run it";
|
|
1504
1658
|
const ASK_NO_ESC = "No";
|
|
1505
1659
|
const rememberLabel = seeds
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix consult — LLM extraction of "don't ask again" prefix suggestions
|
|
3
|
+
* (Claude Code's createCommandPrefixExtractor, ported to our consult
|
|
4
|
+
* infrastructure). The second layer of the prefix-suggestion stack:
|
|
5
|
+
*
|
|
6
|
+
* Layer 1 — syntactic prefix ladder (approvedPrefixes.ts, pure, always first)
|
|
7
|
+
* Layer 2 — THIS module (LLM extraction, memoized, validated, optional)
|
|
8
|
+
* Layer 3 — the dialog (gate.ts; seeded from L1, refined by L2)
|
|
9
|
+
*
|
|
10
|
+
* Same pure/IO split as guardian.ts: the parser and the validation ladder are
|
|
11
|
+
* pure and exhaustively testable; the consult spawns a locked-down child pi
|
|
12
|
+
* on the efficient tier with no tools (the prefix persona answers from the
|
|
13
|
+
* command text alone).
|
|
14
|
+
*
|
|
15
|
+
* The LLM proposes, pure code disposes: every candidate must pass ALL of —
|
|
16
|
+
* 1. the first-line parser (prose/degenerate tokens → no candidate)
|
|
17
|
+
* 2. command_injection_detected → no candidate
|
|
18
|
+
* 3. the banned-shape check (BANNED_PREFIXES)
|
|
19
|
+
* 4. the word-shape check (clean command words, subcommand-shaped seconds)
|
|
20
|
+
* 5. the self-coverage proof (a seed that cannot cover THIS command via
|
|
21
|
+
* matchesGrant is dropped — a `git push` seed for `git push origin
|
|
22
|
+
* +main:main` never reaches the dialog)
|
|
23
|
+
* …and nothing saves until the user picks the option. The LLM's blast radius
|
|
24
|
+
* is capped at "wasted a suggestion".
|
|
25
|
+
*/
|
|
26
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
27
|
+
import type { PipelineStage } from "../pipeline/types.js";
|
|
28
|
+
export type PrefixConsultError = "timeout" | "malformed" | "network" | "empty" | "aborted";
|
|
29
|
+
/** A validated suggestion: token pattern + the label the dialog shows. */
|
|
30
|
+
export interface PrefixCandidate {
|
|
31
|
+
pattern: string[];
|
|
32
|
+
description: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PrefixConsultResult {
|
|
35
|
+
candidate: PrefixCandidate | null;
|
|
36
|
+
error?: PrefixConsultError;
|
|
37
|
+
cost: number;
|
|
38
|
+
/** True when the model answered command_injection_detected (the candidate
|
|
39
|
+
* is null but the outcome class is injection, not a plain decline). */
|
|
40
|
+
injection?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Scrubbed + capped copy of the unparseable model output (malformed shape),
|
|
43
|
+
* so the sink and Sentry can see the exact failure. Never the raw command.
|
|
44
|
+
*/
|
|
45
|
+
rawOutput?: string;
|
|
46
|
+
/** Error CLASS only (constructor name) when the consult threw — never the
|
|
47
|
+
* thrown message (it can carry command content or provider payloads). */
|
|
48
|
+
errorClass?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Cap on the malformed-output capture (same size discipline as the Guardian). */
|
|
51
|
+
export declare const PREFIX_RAW_OUTPUT_CAP = 1024;
|
|
52
|
+
/** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
|
|
53
|
+
export declare const PREFIX_MODEL_TIER = "efficient";
|
|
54
|
+
/**
|
|
55
|
+
* Grace window for the dialog open: the consult fires at interception and the
|
|
56
|
+
* dialog opens after at most this delay with whatever seeds have landed (p50
|
|
57
|
+
* ~1s measured on prod; the window trades a bounded open delay for a much
|
|
58
|
+
* better remember label). Fails open instantly on any consult error.
|
|
59
|
+
*/
|
|
60
|
+
export declare const PREFIX_CONSULT_GRACE_MS = 1500;
|
|
61
|
+
/** Consult deadline (the consult keeps running past the grace window — its
|
|
62
|
+
* result memoizes and serves any retry of the same segment). */
|
|
63
|
+
export declare const PREFIX_CONSULT_TIMEOUT_MS = 15000;
|
|
64
|
+
/**
|
|
65
|
+
* First-line parser: the first non-empty line of the model's output, with
|
|
66
|
+
* quotes, bullets, and markdown fences stripped, then SHAPE-CHECKED — the
|
|
67
|
+
* answer must look like 1-2 command words (the persona's contract). Prose
|
|
68
|
+
* sentences, degenerate tokens (`<|open|>`), and glue symbols parse to
|
|
69
|
+
* null: unparseable → no suggestion, nothing downstream sees it.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parsePrefixAnswer(raw: string): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Validate a parsed answer against a command. Returns a candidate the dialog
|
|
74
|
+
* may offer, or null (which is NOT an error — `none` and rejection-by-ladder
|
|
75
|
+
* are expected outcomes). `injectionDetected` is surfaced separately from
|
|
76
|
+
* null because the counters distinguish it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function validatePrefixAnswer(answer: string, command: string): {
|
|
79
|
+
kind: "candidate";
|
|
80
|
+
candidate: PrefixCandidate;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "injection";
|
|
83
|
+
} | {
|
|
84
|
+
kind: "reject";
|
|
85
|
+
};
|
|
86
|
+
/** A settled memo entry: successes AND settled rejections (both are stable
|
|
87
|
+
* answers — `none` must not re-consult forever, and a malformed answer is a
|
|
88
|
+
* stable property of the model+segment pair). The terminal OUTCOME CLASS is
|
|
89
|
+
* stored verbatim and replayed — a malformed answer must keep counting as
|
|
90
|
+
* `unparseable` on every memoized retry, not silently become `none`
|
|
91
|
+
* (the hit-rate dashboard undercounts the exact segments the parser chokes
|
|
92
|
+
* on otherwise). */
|
|
93
|
+
export interface PrefixMemoEntry {
|
|
94
|
+
candidate: PrefixCandidate | null;
|
|
95
|
+
injection: boolean;
|
|
96
|
+
/** The terminal outcome the consult settled to (hit/none/injection_rejected/
|
|
97
|
+
* unparseable) — replayed as-is on memoized hits. */
|
|
98
|
+
outcome: Exclude<PrefixDiagnosticEvent["outcome"], PrefixConsultError>;
|
|
99
|
+
}
|
|
100
|
+
/** The memo's stored value: either a settled entry or a shared in-flight promise. */
|
|
101
|
+
export type PrefixMemoValue = PrefixMemoEntry | Promise<PrefixConsultResult & {
|
|
102
|
+
outcome: PrefixDiagnosticEvent["outcome"];
|
|
103
|
+
}>;
|
|
104
|
+
/**
|
|
105
|
+
* LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
|
|
106
|
+
* consults keyed by the command segment string. In-flight promises are
|
|
107
|
+
* shared (the same key consults once, however many askers race); a
|
|
108
|
+
* thrown/rejected promise is evicted so a transient outage never poisons
|
|
109
|
+
* the key.
|
|
110
|
+
*/
|
|
111
|
+
export declare function createPrefixMemo(capacity?: number): {
|
|
112
|
+
get(key: string): PrefixMemoValue | undefined;
|
|
113
|
+
set(key: string, entry: PrefixMemoValue): void;
|
|
114
|
+
delete(key: string): void;
|
|
115
|
+
size(): number;
|
|
116
|
+
};
|
|
117
|
+
export interface PrefixConsultDeps {
|
|
118
|
+
runStage?: typeof defaultRunStage;
|
|
119
|
+
cwd: string;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
modelTier?: string;
|
|
122
|
+
timeoutMs?: number;
|
|
123
|
+
memo?: ReturnType<typeof createPrefixMemo>;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
|
|
127
|
+
* (same pattern as the guardian) so it doesn't ripple into feed/reducers.
|
|
128
|
+
* The `prefix` agent selects the persona; no tools — the persona answers
|
|
129
|
+
* from the command text alone.
|
|
130
|
+
*/
|
|
131
|
+
export declare function prefixStage(modelTier?: string): PipelineStage;
|
|
132
|
+
/** Sanitized per-consult diagnostic event for the sink (never the raw command). */
|
|
133
|
+
export interface PrefixDiagnosticEvent {
|
|
134
|
+
event: "prefix_consult";
|
|
135
|
+
outcome: "hit" | "none" | "injection_rejected" | "invalid" | "unparseable" | PrefixConsultError;
|
|
136
|
+
durationMs?: number;
|
|
137
|
+
tier?: string;
|
|
138
|
+
/** Debug-only: segment hash for correlation (never the segment text). */
|
|
139
|
+
segmentHash?: string;
|
|
140
|
+
/** Scrubbed + capped copy of the unparseable model output (malformed shape). */
|
|
141
|
+
rawOutput?: string;
|
|
142
|
+
/** Error CLASS only (constructor name) when the consult threw — never the
|
|
143
|
+
* thrown message (it can carry command content or provider payloads). */
|
|
144
|
+
errorClass?: string;
|
|
145
|
+
}
|
|
146
|
+
export declare function buildPrefixDiagnosticEvent(outcome: PrefixDiagnosticEvent["outcome"], opts: {
|
|
147
|
+
durationMs?: number;
|
|
148
|
+
tier?: string;
|
|
149
|
+
segmentHash?: string;
|
|
150
|
+
rawOutput?: string;
|
|
151
|
+
errorClass?: string;
|
|
152
|
+
debug?: boolean;
|
|
153
|
+
}): PrefixDiagnosticEvent;
|
|
154
|
+
/**
|
|
155
|
+
* Run ONE prefix consult on a single command segment. Pure on top of an
|
|
156
|
+
* injectable runStage; the caller (the gate) fans out per-segment and races
|
|
157
|
+
* the grace window. Memoized by the caller's memo (keyed by segment string).
|
|
158
|
+
*/
|
|
159
|
+
export declare function consultPrefix(segment: string, deps: PrefixConsultDeps): Promise<PrefixConsultResult>;
|
|
160
|
+
/**
|
|
161
|
+
* Memoized consult for one segment: consults at most once per settled key,
|
|
162
|
+
* shares in-flight promises across concurrent askers, evicts rejections so a
|
|
163
|
+
* transient outage never poisons the key, and retries once on the empty
|
|
164
|
+
* (502-shape) error — the backend's own error body invites a retry.
|
|
165
|
+
*/
|
|
166
|
+
export declare function consultPrefixMemoized(segment: string, deps: PrefixConsultDeps & {
|
|
167
|
+
onEvent?: (ev: PrefixDiagnosticEvent) => void;
|
|
168
|
+
}): Promise<PrefixConsultResult & {
|
|
169
|
+
outcome: PrefixDiagnosticEvent["outcome"];
|
|
170
|
+
}>;
|
|
171
|
+
//# sourceMappingURL=prefixExtract.d.ts.map
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix consult — LLM extraction of "don't ask again" prefix suggestions
|
|
3
|
+
* (Claude Code's createCommandPrefixExtractor, ported to our consult
|
|
4
|
+
* infrastructure). The second layer of the prefix-suggestion stack:
|
|
5
|
+
*
|
|
6
|
+
* Layer 1 — syntactic prefix ladder (approvedPrefixes.ts, pure, always first)
|
|
7
|
+
* Layer 2 — THIS module (LLM extraction, memoized, validated, optional)
|
|
8
|
+
* Layer 3 — the dialog (gate.ts; seeded from L1, refined by L2)
|
|
9
|
+
*
|
|
10
|
+
* Same pure/IO split as guardian.ts: the parser and the validation ladder are
|
|
11
|
+
* pure and exhaustively testable; the consult spawns a locked-down child pi
|
|
12
|
+
* on the efficient tier with no tools (the prefix persona answers from the
|
|
13
|
+
* command text alone).
|
|
14
|
+
*
|
|
15
|
+
* The LLM proposes, pure code disposes: every candidate must pass ALL of —
|
|
16
|
+
* 1. the first-line parser (prose/degenerate tokens → no candidate)
|
|
17
|
+
* 2. command_injection_detected → no candidate
|
|
18
|
+
* 3. the banned-shape check (BANNED_PREFIXES)
|
|
19
|
+
* 4. the word-shape check (clean command words, subcommand-shaped seconds)
|
|
20
|
+
* 5. the self-coverage proof (a seed that cannot cover THIS command via
|
|
21
|
+
* matchesGrant is dropped — a `git push` seed for `git push origin
|
|
22
|
+
* +main:main` never reaches the dialog)
|
|
23
|
+
* …and nothing saves until the user picks the option. The LLM's blast radius
|
|
24
|
+
* is capped at "wasted a suggestion".
|
|
25
|
+
*/
|
|
26
|
+
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
27
|
+
import { scrubSecrets } from "../pipeline/scrubSecrets.js";
|
|
28
|
+
import { BANNED_PREFIXES, canonicalizeForGrants, matchesGrant, } from "./approvedPrefixes.js";
|
|
29
|
+
/** Cap on the malformed-output capture (same size discipline as the Guardian). */
|
|
30
|
+
export const PREFIX_RAW_OUTPUT_CAP = 1024;
|
|
31
|
+
/** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
|
|
32
|
+
export const PREFIX_MODEL_TIER = "efficient";
|
|
33
|
+
/**
|
|
34
|
+
* Grace window for the dialog open: the consult fires at interception and the
|
|
35
|
+
* dialog opens after at most this delay with whatever seeds have landed (p50
|
|
36
|
+
* ~1s measured on prod; the window trades a bounded open delay for a much
|
|
37
|
+
* better remember label). Fails open instantly on any consult error.
|
|
38
|
+
*/
|
|
39
|
+
export const PREFIX_CONSULT_GRACE_MS = 1_500;
|
|
40
|
+
/** Consult deadline (the consult keeps running past the grace window — its
|
|
41
|
+
* result memoizes and serves any retry of the same segment). */
|
|
42
|
+
export const PREFIX_CONSULT_TIMEOUT_MS = 15_000;
|
|
43
|
+
// --- Pure half: parsing + validation ---
|
|
44
|
+
const INJECTION_TOKEN = "command_injection_detected";
|
|
45
|
+
const NONE_TOKEN = "none";
|
|
46
|
+
/** The command-word shape derivePrefix requires (Claude's first-word regex). */
|
|
47
|
+
const COMMAND_WORD_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
48
|
+
/** Second tokens must look like plain subcommand words (no URLs, no secrets). */
|
|
49
|
+
const SAFE_SUBCOMMAND_RE = /^[A-Za-z0-9:_-]+$/;
|
|
50
|
+
/**
|
|
51
|
+
* First-line parser: the first non-empty line of the model's output, with
|
|
52
|
+
* quotes, bullets, and markdown fences stripped, then SHAPE-CHECKED — the
|
|
53
|
+
* answer must look like 1-2 command words (the persona's contract). Prose
|
|
54
|
+
* sentences, degenerate tokens (`<|open|>`), and glue symbols parse to
|
|
55
|
+
* null: unparseable → no suggestion, nothing downstream sees it.
|
|
56
|
+
*/
|
|
57
|
+
export function parsePrefixAnswer(raw) {
|
|
58
|
+
const firstLine = raw
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((l) => l.trim())
|
|
61
|
+
// skip fence-only lines — the persona contract is one line, but the
|
|
62
|
+
// efficient tier loves wrapping it in a ``` block whose OPENING fence
|
|
63
|
+
// would otherwise be the "first line"
|
|
64
|
+
.find((l) => l.length > 0 && !/^```[a-z]*$/i.test(l));
|
|
65
|
+
if (!firstLine)
|
|
66
|
+
return null;
|
|
67
|
+
let s = firstLine;
|
|
68
|
+
// strip markdown fences and bullets the efficient tier likes to add. An
|
|
69
|
+
// inline fully-fenced token (```psql```) loses BOTH fences in one pass; a
|
|
70
|
+
// fence-opener prefix (```psql) loses the opening fence only (the closing
|
|
71
|
+
// fence, if any, was a later line the first-line scan never picked).
|
|
72
|
+
s = s.replace(/^```([a-z]*)\s*/i, "$1").replace(/```$/, "").trim();
|
|
73
|
+
s = s.replace(/^[-*•]\s*/, "").trim();
|
|
74
|
+
// strip wrapping quotes
|
|
75
|
+
s = s.replace(/^["'`]+/, "").replace(/["'`]+$/, "").trim();
|
|
76
|
+
if (s.length === 0 || s.length > 64)
|
|
77
|
+
return null;
|
|
78
|
+
// SHAPE CHECK — the answer must be 1-2 plain command words. Prose (spaces
|
|
79
|
+
// inside words, punctuation, sentences), degenerate special-token glue,
|
|
80
|
+
// and anything the word ladder could never consume parse to null here.
|
|
81
|
+
if (!/^[A-Za-z][A-Za-z0-9:_.-]*( [A-Za-z0-9:_.-]+)?$/.test(s))
|
|
82
|
+
return null;
|
|
83
|
+
if (/\s.{20,}/.test(s))
|
|
84
|
+
return null; // a second "word" 20+ chars is prose, not a subcommand
|
|
85
|
+
return s.toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validate a parsed answer against a command. Returns a candidate the dialog
|
|
89
|
+
* may offer, or null (which is NOT an error — `none` and rejection-by-ladder
|
|
90
|
+
* are expected outcomes). `injectionDetected` is surfaced separately from
|
|
91
|
+
* null because the counters distinguish it.
|
|
92
|
+
*/
|
|
93
|
+
export function validatePrefixAnswer(answer, command) {
|
|
94
|
+
if (answer === INJECTION_TOKEN)
|
|
95
|
+
return { kind: "injection" };
|
|
96
|
+
if (answer === NONE_TOKEN)
|
|
97
|
+
return { kind: "reject" };
|
|
98
|
+
const words = answer.split(/\s+/);
|
|
99
|
+
if (words.length < 1 || words.length > 2)
|
|
100
|
+
return { kind: "reject" };
|
|
101
|
+
const [first, second] = words;
|
|
102
|
+
if (BANNED_PREFIXES.has(first))
|
|
103
|
+
return { kind: "reject" };
|
|
104
|
+
if (!COMMAND_WORD_RE.test(first))
|
|
105
|
+
return { kind: "reject" };
|
|
106
|
+
if (second !== undefined) {
|
|
107
|
+
if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
108
|
+
return { kind: "reject" };
|
|
109
|
+
}
|
|
110
|
+
// The self-coverage proof: a candidate that cannot cover THIS command via
|
|
111
|
+
// matchesGrant is never offered. The candidate is tokenized-shape already
|
|
112
|
+
// (≤2 clean words), so the proof runs against the canonical form the
|
|
113
|
+
// matcher will see at match time.
|
|
114
|
+
const canonical = canonicalizeForGrants(command);
|
|
115
|
+
const grant = {
|
|
116
|
+
pattern: words,
|
|
117
|
+
repoKey: "",
|
|
118
|
+
addedAt: "",
|
|
119
|
+
cwd: "",
|
|
120
|
+
};
|
|
121
|
+
if (matchesGrant(canonical, [grant], "") === null)
|
|
122
|
+
return { kind: "reject" };
|
|
123
|
+
return {
|
|
124
|
+
kind: "candidate",
|
|
125
|
+
candidate: { pattern: words, description: `${words.join(" ")} …` },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
|
|
130
|
+
* consults keyed by the command segment string. In-flight promises are
|
|
131
|
+
* shared (the same key consults once, however many askers race); a
|
|
132
|
+
* thrown/rejected promise is evicted so a transient outage never poisons
|
|
133
|
+
* the key.
|
|
134
|
+
*/
|
|
135
|
+
export function createPrefixMemo(capacity = 200) {
|
|
136
|
+
const map = new Map();
|
|
137
|
+
const get = (key) => map.get(key);
|
|
138
|
+
const set = (key, entry) => {
|
|
139
|
+
map.delete(key);
|
|
140
|
+
map.set(key, entry);
|
|
141
|
+
if (map.size > capacity) {
|
|
142
|
+
const oldest = map.keys().next().value;
|
|
143
|
+
if (oldest !== undefined)
|
|
144
|
+
map.delete(oldest);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const del = (key) => {
|
|
148
|
+
map.delete(key);
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
get,
|
|
152
|
+
set,
|
|
153
|
+
delete: del,
|
|
154
|
+
size: () => map.size,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
|
|
159
|
+
* (same pattern as the guardian) so it doesn't ripple into feed/reducers.
|
|
160
|
+
* The `prefix` agent selects the persona; no tools — the persona answers
|
|
161
|
+
* from the command text alone.
|
|
162
|
+
*/
|
|
163
|
+
export function prefixStage(modelTier = PREFIX_MODEL_TIER) {
|
|
164
|
+
return {
|
|
165
|
+
id: "plan",
|
|
166
|
+
agent: "prefix",
|
|
167
|
+
model: modelTier,
|
|
168
|
+
tools: [],
|
|
169
|
+
taskTemplate: "{ticket}",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function buildPrefixDiagnosticEvent(outcome, opts) {
|
|
173
|
+
const ev = {
|
|
174
|
+
event: "prefix_consult",
|
|
175
|
+
outcome,
|
|
176
|
+
...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
|
|
177
|
+
...(opts.tier !== undefined ? { tier: opts.tier } : {}),
|
|
178
|
+
};
|
|
179
|
+
if (opts.rawOutput !== undefined)
|
|
180
|
+
ev.rawOutput = opts.rawOutput;
|
|
181
|
+
if (opts.errorClass !== undefined)
|
|
182
|
+
ev.errorClass = opts.errorClass;
|
|
183
|
+
if (opts.debug) {
|
|
184
|
+
if (opts.segmentHash)
|
|
185
|
+
ev.segmentHash = opts.segmentHash;
|
|
186
|
+
}
|
|
187
|
+
return ev;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Run ONE prefix consult on a single command segment. Pure on top of an
|
|
191
|
+
* injectable runStage; the caller (the gate) fans out per-segment and races
|
|
192
|
+
* the grace window. Memoized by the caller's memo (keyed by segment string).
|
|
193
|
+
*/
|
|
194
|
+
export async function consultPrefix(segment, deps) {
|
|
195
|
+
const runStage = deps.runStage ?? defaultRunStage;
|
|
196
|
+
const stage = prefixStage(deps.modelTier);
|
|
197
|
+
const timeoutMs = deps.timeoutMs ?? PREFIX_CONSULT_TIMEOUT_MS;
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
200
|
+
timer.unref?.();
|
|
201
|
+
if (deps.signal) {
|
|
202
|
+
if (deps.signal.aborted)
|
|
203
|
+
controller.abort();
|
|
204
|
+
else
|
|
205
|
+
deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const result = await runStage(stage, { ticket: segment }, {
|
|
209
|
+
cwd: deps.cwd,
|
|
210
|
+
signal: controller.signal,
|
|
211
|
+
callerLabel: "prefix",
|
|
212
|
+
});
|
|
213
|
+
const cost = result.usage?.cost ?? 0;
|
|
214
|
+
const output = result.finalOutput.trim();
|
|
215
|
+
if (deps.signal?.aborted) {
|
|
216
|
+
return { candidate: null, error: "aborted", cost };
|
|
217
|
+
}
|
|
218
|
+
if (!output) {
|
|
219
|
+
if (result.stopReason === "error" || result.errorMessage) {
|
|
220
|
+
const msg = (result.errorMessage ?? "").toLowerCase();
|
|
221
|
+
const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
|
|
222
|
+
return { candidate: null, error: isTimeout ? "timeout" : "network", cost };
|
|
223
|
+
}
|
|
224
|
+
return { candidate: null, error: "empty", cost };
|
|
225
|
+
}
|
|
226
|
+
const parsed = parsePrefixAnswer(output);
|
|
227
|
+
if (!parsed) {
|
|
228
|
+
return {
|
|
229
|
+
candidate: null,
|
|
230
|
+
error: "malformed",
|
|
231
|
+
cost,
|
|
232
|
+
rawOutput: scrubSecrets(output).slice(0, PREFIX_RAW_OUTPUT_CAP),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const verdict = validatePrefixAnswer(parsed, segment);
|
|
236
|
+
if (verdict.kind === "candidate") {
|
|
237
|
+
return { candidate: verdict.candidate, cost };
|
|
238
|
+
}
|
|
239
|
+
if (verdict.kind === "injection") {
|
|
240
|
+
return { candidate: null, injection: true, cost };
|
|
241
|
+
}
|
|
242
|
+
// rejection by the ladder — an expected outcome, not an error
|
|
243
|
+
return { candidate: null, injection: false, cost };
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (deps.signal?.aborted)
|
|
247
|
+
return { candidate: null, error: "aborted", cost: 0 };
|
|
248
|
+
if (controller.signal.aborted)
|
|
249
|
+
return { candidate: null, error: "timeout", cost: 0 };
|
|
250
|
+
return {
|
|
251
|
+
candidate: null,
|
|
252
|
+
error: "network",
|
|
253
|
+
cost: 0,
|
|
254
|
+
errorClass: err instanceof Error ? err.constructor.name : typeof err,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Memoized consult for one segment: consults at most once per settled key,
|
|
263
|
+
* shares in-flight promises across concurrent askers, evicts rejections so a
|
|
264
|
+
* transient outage never poisons the key, and retries once on the empty
|
|
265
|
+
* (502-shape) error — the backend's own error body invites a retry.
|
|
266
|
+
*/
|
|
267
|
+
export async function consultPrefixMemoized(segment, deps) {
|
|
268
|
+
const memo = deps.memo ?? createPrefixMemo();
|
|
269
|
+
const key = segment;
|
|
270
|
+
const memoized = memo.get(key);
|
|
271
|
+
if (memoized !== undefined) {
|
|
272
|
+
if (memoized instanceof Promise) {
|
|
273
|
+
return memoized;
|
|
274
|
+
}
|
|
275
|
+
// settled: replay the stored terminal outcome class verbatim — the
|
|
276
|
+
// memoized hit is the SAME answer the live consult settled to
|
|
277
|
+
// (unparseable stays unparseable, injection stays injection), and it
|
|
278
|
+
// EMITS through onEvent like the live consult did (cost 0, duration 0):
|
|
279
|
+
// the sink line and the backend hit-rate counters see every retry of a
|
|
280
|
+
// settled segment, not just its first consult — otherwise the dashboard
|
|
281
|
+
// undercounts exactly the segments the parser chokes on.
|
|
282
|
+
const e = memoized;
|
|
283
|
+
try {
|
|
284
|
+
deps.onEvent?.(buildPrefixDiagnosticEvent(e.outcome, {
|
|
285
|
+
durationMs: 0,
|
|
286
|
+
tier: deps.modelTier ?? PREFIX_MODEL_TIER,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* telemetry must never affect the replay */
|
|
291
|
+
}
|
|
292
|
+
return { candidate: e.candidate, cost: 0, outcome: e.outcome };
|
|
293
|
+
}
|
|
294
|
+
const started = Date.now();
|
|
295
|
+
const promise = (async () => {
|
|
296
|
+
let res = await consultPrefix(segment, deps);
|
|
297
|
+
// one retry on the empty shape (the backend's own error body invites this)
|
|
298
|
+
if (res.error === "empty" && !deps.signal?.aborted) {
|
|
299
|
+
res = await consultPrefix(segment, deps);
|
|
300
|
+
}
|
|
301
|
+
const outcome = res.candidate
|
|
302
|
+
? "hit"
|
|
303
|
+
: res.error === "malformed"
|
|
304
|
+
? "unparseable"
|
|
305
|
+
: res.error === undefined
|
|
306
|
+
? res.injection === true
|
|
307
|
+
? "injection_rejected"
|
|
308
|
+
: "none"
|
|
309
|
+
: res.error;
|
|
310
|
+
deps.onEvent?.(buildPrefixDiagnosticEvent(outcome, {
|
|
311
|
+
durationMs: Date.now() - started,
|
|
312
|
+
tier: deps.modelTier ?? PREFIX_MODEL_TIER,
|
|
313
|
+
...(res.rawOutput ? { rawOutput: res.rawOutput } : {}),
|
|
314
|
+
...(res.errorClass ? { errorClass: res.errorClass } : {}),
|
|
315
|
+
}));
|
|
316
|
+
if (res.error && res.error !== "malformed") {
|
|
317
|
+
// transient (timeout/network/empty/aborted) — evict, never poison
|
|
318
|
+
memo.delete(key);
|
|
319
|
+
return { ...res, outcome };
|
|
320
|
+
}
|
|
321
|
+
// settled: hit, none, injection_rejected, or unparseable — stored with
|
|
322
|
+
// its terminal outcome class so memoized replays count identically.
|
|
323
|
+
// (All error classes returned above; the remaining outcome is one of
|
|
324
|
+
// the four settled classes, hence the narrow.)
|
|
325
|
+
const settledOutcome = outcome;
|
|
326
|
+
memo.set(key, {
|
|
327
|
+
candidate: res.candidate,
|
|
328
|
+
injection: res.injection === true,
|
|
329
|
+
outcome: settledOutcome,
|
|
330
|
+
});
|
|
331
|
+
return { ...res, outcome };
|
|
332
|
+
})();
|
|
333
|
+
// reject-evict with identity guard (Claude's pattern): a stale rejection
|
|
334
|
+
// must not delete a newer promise that replaced it after LRU eviction.
|
|
335
|
+
promise.catch(() => {
|
|
336
|
+
if (memo.get(key) === promise)
|
|
337
|
+
memo.delete(key);
|
|
338
|
+
});
|
|
339
|
+
memo.set(key, promise);
|
|
340
|
+
return promise;
|
|
341
|
+
}
|
|
342
|
+
//# sourceMappingURL=prefixExtract.js.map
|
|
@@ -40,6 +40,31 @@ export declare function parseShellRule(ruleContent: string): ShellRuleShape;
|
|
|
40
40
|
export declare function matchWildcardPattern(pattern: string, command: string): boolean;
|
|
41
41
|
/** Split a command into its subcommand strings (operators are boundaries). */
|
|
42
42
|
export declare function splitSubcommands(command: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Whitelist of environment variables that are safe to strip before rule/
|
|
45
|
+
* grant matching: they CANNOT execute code or load libraries (the same bar
|
|
46
|
+
* Claude Code's list documents). Claude's list verbatim, with TWO
|
|
47
|
+
* deliberate divergences, both directions:
|
|
48
|
+
*
|
|
49
|
+
* KEPT from ours: `CI` — a boolean non-interactivity flag, same safety
|
|
50
|
+
* class as FORCE_COLOR/NO_COLOR, and a real idiom for our users.
|
|
51
|
+
*
|
|
52
|
+
* DROPPED from ours (Claude excludes them too, and for good reason):
|
|
53
|
+
* `DEBUG`/`VERBOSE` — the most overloaded var names in existence; dozens
|
|
54
|
+
* of tools overload them for behavior switches, not just logging. `PWD`
|
|
55
|
+
* — some tools read it for path resolution, and nobody legitimately
|
|
56
|
+
* writes `PWD=x cmd`; stripping accidental state is exactly what a
|
|
57
|
+
* safe-list must not do.
|
|
58
|
+
*
|
|
59
|
+
* DROPPED from Claude's: `ANTHROPIC_API_KEY` — it is on their list for
|
|
60
|
+
* their own CLI's use case; for us its only effect would be hiding WHICH
|
|
61
|
+
* account a command authenticates as. No value, an honesty cost.
|
|
62
|
+
*
|
|
63
|
+
* SECURITY: never add PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*, PYTHONPATH,
|
|
64
|
+
* NODE_PATH, CLASSPATH, RUBYLIB, GOFLAGS, RUSTFLAGS, NODE_OPTIONS, HOME,
|
|
65
|
+
* TMPDIR, SHELL, BASH_ENV, DOCKER_HOST, KUBECONFIG (execution / library
|
|
66
|
+
* loading / behavior-steering / endpoint-hiding).
|
|
67
|
+
*/
|
|
43
68
|
export declare const SAFE_ENV_VARS: Set<string>;
|
|
44
69
|
/** Strip ALL leading env assignments — deny/ask rules only. Fixed-point. */
|
|
45
70
|
export declare function stripAllEnvVars(command: string): string;
|
|
@@ -145,9 +145,52 @@ function stripSafeWrappers(cmd) {
|
|
|
145
145
|
s = next.trim();
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Whitelist of environment variables that are safe to strip before rule/
|
|
150
|
+
* grant matching: they CANNOT execute code or load libraries (the same bar
|
|
151
|
+
* Claude Code's list documents). Claude's list verbatim, with TWO
|
|
152
|
+
* deliberate divergences, both directions:
|
|
153
|
+
*
|
|
154
|
+
* KEPT from ours: `CI` — a boolean non-interactivity flag, same safety
|
|
155
|
+
* class as FORCE_COLOR/NO_COLOR, and a real idiom for our users.
|
|
156
|
+
*
|
|
157
|
+
* DROPPED from ours (Claude excludes them too, and for good reason):
|
|
158
|
+
* `DEBUG`/`VERBOSE` — the most overloaded var names in existence; dozens
|
|
159
|
+
* of tools overload them for behavior switches, not just logging. `PWD`
|
|
160
|
+
* — some tools read it for path resolution, and nobody legitimately
|
|
161
|
+
* writes `PWD=x cmd`; stripping accidental state is exactly what a
|
|
162
|
+
* safe-list must not do.
|
|
163
|
+
*
|
|
164
|
+
* DROPPED from Claude's: `ANTHROPIC_API_KEY` — it is on their list for
|
|
165
|
+
* their own CLI's use case; for us its only effect would be hiding WHICH
|
|
166
|
+
* account a command authenticates as. No value, an honesty cost.
|
|
167
|
+
*
|
|
168
|
+
* SECURITY: never add PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*, PYTHONPATH,
|
|
169
|
+
* NODE_PATH, CLASSPATH, RUBYLIB, GOFLAGS, RUSTFLAGS, NODE_OPTIONS, HOME,
|
|
170
|
+
* TMPDIR, SHELL, BASH_ENV, DOCKER_HOST, KUBECONFIG (execution / library
|
|
171
|
+
* loading / behavior-steering / endpoint-hiding).
|
|
172
|
+
*/
|
|
148
173
|
export const SAFE_ENV_VARS = new Set([
|
|
149
|
-
|
|
150
|
-
"
|
|
174
|
+
// Go — build/runtime settings only
|
|
175
|
+
"GOEXPERIMENT", "GOOS", "GOARCH", "CGO_ENABLED", "GO111MODULE",
|
|
176
|
+
// Rust — logging/debugging only
|
|
177
|
+
"RUST_BACKTRACE", "RUST_LOG",
|
|
178
|
+
// Node — environment name only (never NODE_OPTIONS)
|
|
179
|
+
"NODE_ENV",
|
|
180
|
+
// Python — behavior flags only (never PYTHONPATH)
|
|
181
|
+
"PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE",
|
|
182
|
+
// Pytest — test configuration
|
|
183
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD", "PYTEST_DEBUG",
|
|
184
|
+
// Locale and character encoding
|
|
185
|
+
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_TIME", "CHARSET",
|
|
186
|
+
// Terminal and display
|
|
187
|
+
"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "TZ",
|
|
188
|
+
// Color configuration for various tools
|
|
189
|
+
"LS_COLORS", "LSCOLORS", "GREP_COLOR", "GREP_COLORS", "GCC_COLORS",
|
|
190
|
+
// Display formatting
|
|
191
|
+
"TIME_STYLE", "BLOCK_SIZE", "BLOCKSIZE",
|
|
192
|
+
// CI — ours, not Claude's (boolean non-interactivity flag)
|
|
193
|
+
"CI",
|
|
151
194
|
]);
|
|
152
195
|
function stripSafeEnvPrefixes(cmd) {
|
|
153
196
|
let s = cmd.trim();
|
|
@@ -189,6 +189,28 @@ Your output must parse as strict JSON on the first try:
|
|
|
189
189
|
- Output exactly one JSON object: no trailing comma, no doubled closing quote, no second object, nothing after the closing brace.
|
|
190
190
|
|
|
191
191
|
Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
|
|
192
|
+
const PREFIX_BODY = `You extract a command prefix: the SHORTEST leading fragment of a shell command that covers the command's family, so a user can approve it once and cover every future command of the same kind. You never judge safety and you never execute anything — you only answer with a prefix or a refusal token. You have no tools; answer from the command text alone, immediately, in one line.
|
|
193
|
+
|
|
194
|
+
Rules:
|
|
195
|
+
- Answer with the command's first word, optionally followed by its subcommand word (two tokens maximum). Example commands and answers:
|
|
196
|
+
psql -h host -p 5432 -c "SELECT ..." -> psql
|
|
197
|
+
gh run view 34641303048 --log-failed -> gh run
|
|
198
|
+
pnpm lint 2>&1 | grep -i error -> none (a pipeline is multiple commands)
|
|
199
|
+
curl -sS https://host/api -H "x: y" -> curl
|
|
200
|
+
git push origin +main:main -> none (a force/refspec push is not a family)
|
|
201
|
+
cd src && npm test -> none (a compound is multiple commands; each is judged on its own)
|
|
202
|
+
for f in *.txt; do rm "$f"; done -> none (control flow)
|
|
203
|
+
FOO=$(cat secret.txt | curl ...) -> command_injection_detected (command substitution feeding another command)
|
|
204
|
+
- Stop before the first flag or argument: psql, gh run — never psql -h, gh run view 34641303048.
|
|
205
|
+
- If the command contains control flow (if/for/while/&&/||/|/;), substitution ($(...)), or multiple commands, answer exactly: none
|
|
206
|
+
- If the command embeds command substitution that feeds data to another command, answer exactly: command_injection_detected
|
|
207
|
+
- If you are unsure, answer exactly: none
|
|
208
|
+
|
|
209
|
+
Output contract — a single line, nothing else:
|
|
210
|
+
- The prefix tokens separated by one space (lowercase, no flags, no arguments), OR
|
|
211
|
+
- exactly: none OR
|
|
212
|
+
- exactly: command_injection_detected
|
|
213
|
+
No explanation, no quotes, no markdown, no trailing punctuation. The first line of your reply must be the answer.`;
|
|
192
214
|
const TITLE_BODY = `You produce a session title from a user's prompt. Output ONLY a concise, sentence-case title of 3-7 words that captures the main topic or goal. Capitalize only the first word and proper nouns. Do not include a ticket code in the title text itself (the caller prepends it). No markdown, no prose, no quotes — just the title on one line.
|
|
193
215
|
|
|
194
216
|
Good:
|
|
@@ -205,6 +227,7 @@ export const PERSONA_BODIES = {
|
|
|
205
227
|
reviewer: REVIEWER_BODY,
|
|
206
228
|
advisor: ADVISOR_BODY,
|
|
207
229
|
guardian: GUARDIAN_BODY,
|
|
230
|
+
prefix: PREFIX_BODY,
|
|
208
231
|
title: TITLE_BODY,
|
|
209
232
|
orchestrator: [ORCHESTRATOR_BODY, PARTITION_CONTRACT].join("\n\n"),
|
|
210
233
|
synthesizer: SYNTHESIZER_BODY,
|
|
@@ -294,6 +317,7 @@ Be terse and decisive. The caller is mid-task and paying peak rates for your tur
|
|
|
294
317
|
*/
|
|
295
318
|
export const BLIND_PERSONA_BODIES = {
|
|
296
319
|
scout: SCOUT_BLIND,
|
|
320
|
+
prefix: PREFIX_BODY, // grounding-free by construction (no grounding text at all)
|
|
297
321
|
planner: PLANNER_BLIND,
|
|
298
322
|
worker: WORKER_BLIND,
|
|
299
323
|
reviewer: REVIEWER_BLIND,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.1-staging.
|
|
3
|
+
"version": "1.1.1-staging.1361.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "45da756463bd91f8c8e4d110e41da939a3228a25"
|
|
62
62
|
}
|