@yagni-app/code-staging 1.1.1-staging.1340.1 → 1.1.1-staging.1347.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 +79 -1
- package/dist/extension/permission/approvedPrefixes.d.ts +122 -8
- package/dist/extension/permission/approvedPrefixes.js +455 -37
- package/dist/extension/permission/execPolicy.d.ts +3 -0
- package/dist/extension/permission/execPolicy.js +3 -2
- package/dist/extension/permission/gate.d.ts +22 -2
- package/dist/extension/permission/gate.js +330 -2
- package/dist/extension/permissionRules/shellRules.d.ts +1 -0
- package/dist/extension/permissionRules/shellRules.js +1 -1
- package/dist/extension/sandbox/escapeTally.d.ts +29 -0
- package/dist/extension/sandbox/escapeTally.js +43 -0
- package/dist/extension/sandbox/panel.d.ts +16 -1
- package/dist/extension/sandbox/panel.js +16 -2
- package/dist/extension/sandbox/session.d.ts +7 -0
- package/dist/extension/sandbox/session.js +12 -3
- package/dist/extension/telemetry/attrs.d.ts +1 -0
- package/dist/extension/telemetry/attrs.js +1 -0
- package/dist/extension/telemetry/register.d.ts +3 -0
- package/dist/extension/telemetry/register.js +2 -0
- package/dist/extension/telemetry/tracker.d.ts +5 -0
- package/dist/extension/telemetry/tracker.js +8 -1
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
|
37
37
|
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
|
|
38
38
|
import { registerSandbox } from "./sandbox/session.js";
|
|
39
39
|
import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
|
|
40
|
+
import { createEscapeTally } from "./sandbox/escapeTally.js";
|
|
40
41
|
import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
|
|
41
42
|
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
42
43
|
import { loadPermissionRules } from "./permissionRules/loadConfig.js";
|
|
@@ -452,7 +453,42 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
452
453
|
if (event?.toolCallId)
|
|
453
454
|
toolOutcomes.toolEnd(event.toolCallId, { isError: !!event.isError, result: event.result });
|
|
454
455
|
});
|
|
455
|
-
pi.on("session_shutdown", async () => {
|
|
456
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
457
|
+
// Session-end visibility: the one-line escape summary (only when the
|
|
458
|
+
// session actually escaped — zero-escape sessions stay silent) via notify
|
|
459
|
+
// (RPC-forwarded — protocol-visible for e2e) plus the summary sink event
|
|
460
|
+
// for the product metric.
|
|
461
|
+
const summary = escapeTally.summaryLine();
|
|
462
|
+
if (summary) {
|
|
463
|
+
try {
|
|
464
|
+
ctx?.ui?.notify?.(summary, "info");
|
|
465
|
+
}
|
|
466
|
+
catch { /* fail-soft */ }
|
|
467
|
+
logEvent({
|
|
468
|
+
source: "sandbox",
|
|
469
|
+
level: "info",
|
|
470
|
+
event: "sandbox_escape_summary",
|
|
471
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
472
|
+
fields: {
|
|
473
|
+
count: escapeTally.count(),
|
|
474
|
+
// Command-family labels are command content — debug tier only,
|
|
475
|
+
// same contract as the sandbox_escape line and gate_outcome's
|
|
476
|
+
// commandPrefix. The always-on info tier carries the count alone.
|
|
477
|
+
...(isDebug(env)
|
|
478
|
+
? { prefixes: escapeTally.breakdown().map((b) => `${b.prefix}=${b.n}`) }
|
|
479
|
+
: {}),
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
if (isDebug(env)) {
|
|
483
|
+
logEvent({
|
|
484
|
+
source: "sandbox",
|
|
485
|
+
level: "debug",
|
|
486
|
+
event: "sandbox_escape_summary",
|
|
487
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
488
|
+
fields: { prefixes: escapeTally.breakdown().map((b) => `${b.prefix}=${b.n}`) },
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
456
492
|
await toolOutcomes.close();
|
|
457
493
|
});
|
|
458
494
|
const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
|
|
@@ -535,6 +571,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
535
571
|
// session lifecycle (init/reset, fail-soft unless failIfUnavailable).
|
|
536
572
|
// Null on unsupported platforms — stock bash stays. Shares the loaded
|
|
537
573
|
// permission rules so sandbox config and the gate read one rule source.
|
|
574
|
+
// The session escape tally — interception-time counts by command family,
|
|
575
|
+
// shared by the gate (record), the /sandbox panel (breakdown), and the
|
|
576
|
+
// session-end summary line.
|
|
577
|
+
const escapeTally = createEscapeTally();
|
|
538
578
|
const sandboxHandle = evalMode
|
|
539
579
|
? null
|
|
540
580
|
: registerSandbox(pi, {
|
|
@@ -542,6 +582,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
542
582
|
env,
|
|
543
583
|
hasUI: (ctx) => ctx.hasUI,
|
|
544
584
|
onShellResolutionRetry: (outcome) => telemetry.sandboxShellResolutionRetry(outcome),
|
|
585
|
+
escapeBreakdown: () => escapeTally.breakdown(),
|
|
545
586
|
// Anchors project-protected paths (.yagni-code + its config.json in
|
|
546
587
|
// denyWrite) and project-sourced permission rules to the repo the
|
|
547
588
|
// session runs in — same root the gate uses for its rule anchoring.
|
|
@@ -553,6 +594,37 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
553
594
|
...(sandboxHandle
|
|
554
595
|
? {
|
|
555
596
|
sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
|
|
597
|
+
// The escape predicate: this call carries dangerouslyDisableSandbox
|
|
598
|
+
// AND will actually run unsandboxed (sandbox initialized + the
|
|
599
|
+
// setting allows unsandboxed commands). Strict mode (allow=false)
|
|
600
|
+
// ignores the flag entirely — the command runs wrapped, no ask.
|
|
601
|
+
sandboxEscape: (toolName, params) => toolName === "bash" &&
|
|
602
|
+
sandboxHandle.manager.initialized &&
|
|
603
|
+
params.dangerouslyDisableSandbox === true &&
|
|
604
|
+
sandboxHandle.settings().allowUnsandboxedCommands !== false,
|
|
605
|
+
// Escape tally: interception-time, content-free on the always-on
|
|
606
|
+
// info tier (the command prefix is command content — it rides the
|
|
607
|
+
// debug tier only, same contract as logGateOutcomeTrail; the OTel
|
|
608
|
+
// counter is outcome-tagged only).
|
|
609
|
+
onSandboxEscape: (prefix) => {
|
|
610
|
+
escapeTally.record(prefix);
|
|
611
|
+
logEvent({
|
|
612
|
+
source: "sandbox",
|
|
613
|
+
level: "info",
|
|
614
|
+
event: "sandbox_escape",
|
|
615
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
616
|
+
fields: {},
|
|
617
|
+
});
|
|
618
|
+
if (isDebug(env)) {
|
|
619
|
+
logEvent({
|
|
620
|
+
source: "sandbox",
|
|
621
|
+
level: "debug",
|
|
622
|
+
event: "sandbox_escape",
|
|
623
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
624
|
+
fields: { prefix },
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
},
|
|
556
628
|
}
|
|
557
629
|
: {}),
|
|
558
630
|
modeHolder,
|
|
@@ -675,6 +747,12 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
675
747
|
// never touches the session.
|
|
676
748
|
onGuardianEvent: (ev) => {
|
|
677
749
|
logGateOutcomeTrail(ev, env);
|
|
750
|
+
// Escape-flow outcomes feed the product metric (one counter add per
|
|
751
|
+
// unsandboxed-retry attempt, tagged by outcome only — classes/prefixes
|
|
752
|
+
// stay local per the telemetry cardinality contract).
|
|
753
|
+
if (ev.outcome.startsWith("escape_")) {
|
|
754
|
+
telemetry.sandboxEscapeRetry(ev.outcome);
|
|
755
|
+
}
|
|
678
756
|
// Opt-in storage stream (YAG-510): tier decides what leaves the machine.
|
|
679
757
|
if (guardianStorageTier === "off" || evalMode)
|
|
680
758
|
return;
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { type ExecPolicy } from "./execPolicy.js";
|
|
29
29
|
export interface ApprovedPrefixGrant {
|
|
30
|
-
/** Ordered command tokens the grant covers, e.g. ["git", "push"].
|
|
30
|
+
/** Ordered command tokens the grant covers, e.g. ["git", "push"].
|
|
31
|
+
* Empty when `literal` is set (the string-prefix rungs). */
|
|
31
32
|
pattern: string[];
|
|
32
33
|
/** Repo the grant applies to (git remote origin URL or realpath of cwd). */
|
|
33
34
|
repoKey: string;
|
|
@@ -35,19 +36,72 @@ export interface ApprovedPrefixGrant {
|
|
|
35
36
|
addedAt: string;
|
|
36
37
|
/** The cwd where the grant was made (provenance for a future revoke UI). */
|
|
37
38
|
cwd: string;
|
|
39
|
+
/**
|
|
40
|
+
* String-prefix rung (Claude's suggestionForExactCommand): the stable
|
|
41
|
+
* prefix before a heredoc operator, the first line of a multiline command,
|
|
42
|
+
* or the full literal command — matched with startsWith against the RAW
|
|
43
|
+
* command (not the canonical form; these rungs exist precisely because
|
|
44
|
+
* the decorated shape can't be token-derived). A literal grant is the
|
|
45
|
+
* fallback when no token prefix is derivable, and the editable field is
|
|
46
|
+
* how the user narrows it. Empty string on token-pattern grants.
|
|
47
|
+
*/
|
|
48
|
+
literal?: string;
|
|
38
49
|
}
|
|
39
50
|
export interface ApprovedPrefixFile {
|
|
40
51
|
version: 1;
|
|
41
52
|
grants: ApprovedPrefixGrant[];
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
44
|
-
* Prefixes that must never be grantable
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
55
|
+
* Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
|
|
56
|
+
* line, plus the destruction family we keep fenced beyond it).
|
|
57
|
+
*
|
|
58
|
+
* Claude bans only arbitrary-execution words: shells, wrappers that exec
|
|
59
|
+
* their args (env, xargs, nice, stdbuf, nohup, timeout, time), and
|
|
60
|
+
* privilege escalators (sudo, doas, pkexec). Interpreters (node, python3)
|
|
61
|
+
* and egress tools (curl, psql, ssh) are deliberately grantable there — the
|
|
62
|
+
* human approving the rule IS the security control, and the editable prefix
|
|
63
|
+
* field lets them narrow it. We adopt that stance verbatim.
|
|
64
|
+
*
|
|
65
|
+
* Our one widening of Claude's list: the destruction family (rm, kill,
|
|
66
|
+
* chmod, chown, dd, mkfs, truncate). A standing grant there has no
|
|
67
|
+
* legitimate use, the fence is free, and the exec-policy forbidden band
|
|
68
|
+
* would block the dangerous shapes anyway — this keeps a user from
|
|
69
|
+
* accidentally widening a grant INTO the neighborhood.
|
|
49
70
|
*/
|
|
50
71
|
export declare const BANNED_PREFIXES: Set<string>;
|
|
72
|
+
/**
|
|
73
|
+
* The single normalization both derivation AND matching run on — the
|
|
74
|
+
* derive/match disagreement is the root cause of dead-rule ask loops
|
|
75
|
+
* (Claude's GH#11380). Decoration that never changes what runs is stripped:
|
|
76
|
+
*
|
|
77
|
+
* - full-line comments
|
|
78
|
+
* - safe env prefixes (SAFE_ENV_VARS, fixed-point) — `NODE_ENV=test pnpm test`
|
|
79
|
+
* canonicalizes to `pnpm test`; a NON-safe var (RUN=… ) stops stripping
|
|
80
|
+
* so matching fails and the command asks (correct: the env changes
|
|
81
|
+
* behavior in ways the safe-list can't vouch for)
|
|
82
|
+
* - safe wrappers (timeout/time/nice/nohup, fixed-point)
|
|
83
|
+
* - safe redirects ONLY: fd merges (`2>&1`) and /dev/null discards.
|
|
84
|
+
* Target-bearing redirects (`> out.log`) never strip — we lack Claude's
|
|
85
|
+
* redirect-target path validation, and an escaped command runs with the
|
|
86
|
+
* user's full authority, so a grant must never silently cover a write to
|
|
87
|
+
* an arbitrary path.
|
|
88
|
+
*
|
|
89
|
+
* Compound commands pass through untouched; segments are handled explicitly
|
|
90
|
+
* (never canonicalized as one string).
|
|
91
|
+
*/
|
|
92
|
+
export declare function canonicalizeForGrants(command: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Derive the grantable token prefix for a single canonical command, Claude's
|
|
95
|
+
* getSimpleCommandPrefix → getFirstWordPrefix ladder:
|
|
96
|
+
* 1. [tool, subcommand] for multi-subcommand tools (`git push`, `pnpm test`)
|
|
97
|
+
* 2. [first-word] for any other clean command word (`psql`, `node`, `gh` on
|
|
98
|
+
* flag-first shapes) — Claude's first-word fallback; the editable field
|
|
99
|
+
* lets the user narrow it.
|
|
100
|
+
* Returns null when nothing token-shaped is grantable (banned word, dirty
|
|
101
|
+
* shape, path-prefixed). The literal rungs (heredoc prefix, first line, full
|
|
102
|
+
* command) live in deriveRememberSeeds — they are string prefixes, not
|
|
103
|
+
* token patterns, and ride the grant as a `literal` field.
|
|
104
|
+
*/
|
|
51
105
|
export declare function derivePrefix(command: string): string[] | null;
|
|
52
106
|
/**
|
|
53
107
|
* Command-family label for storage analytics (YAG-510): token 1 (basename'd),
|
|
@@ -60,8 +114,10 @@ export declare function derivePrefix(command: string): string[] | null;
|
|
|
60
114
|
export declare function storagePrefix(command: string): string;
|
|
61
115
|
/**
|
|
62
116
|
* Does `command` fall under one of the session's grants? Pure. The caller
|
|
63
|
-
* (permission gate) must only consult this AFTER
|
|
64
|
-
*
|
|
117
|
+
* (permission gate) must only consult this AFTER the exec policy ran —
|
|
118
|
+
* grants never override forbidden. Token grants match on the CANONICAL
|
|
119
|
+
* form (same normalization derivation ran on — the anti-dead-rule
|
|
120
|
+
* invariant); literal grants match with startsWith against the raw command.
|
|
65
121
|
*/
|
|
66
122
|
export declare function matchesGrant(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string): ApprovedPrefixGrant | null;
|
|
67
123
|
/**
|
|
@@ -73,6 +129,64 @@ export declare function matchesGrant(command: string, grants: readonly ApprovedP
|
|
|
73
129
|
export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
|
|
74
130
|
/** Human label for the remember option: "git push …". */
|
|
75
131
|
export declare function describePrefix(pattern: string[]): string;
|
|
132
|
+
/** Claude's suggestionForExactCommand: stable prefix before a heredoc
|
|
133
|
+
* operator — heredoc bodies change every invocation, so the exact command
|
|
134
|
+
* would be a dead rule; the pre-<< prefix is the remember unit.
|
|
135
|
+
*
|
|
136
|
+
* OUR refinement over Claude: when the pre-<< prefix carries an OUTPUT
|
|
137
|
+
* REDIRECT (`cat > "$S/r1.txt" <<EOF`), the redirect TARGET varies between
|
|
138
|
+
* invocations as much as the body does (r1.txt, r2.txt, …) — so the prefix
|
|
139
|
+
* cuts at the redirect operator. `cat >` seeds `…\ncat`; a redirect-free
|
|
140
|
+
* heredoc (`gh api … <<EOF`) keeps Claude's full pre-<< prefix. The
|
|
141
|
+
* editable field is the narrowing control on top of either. */
|
|
142
|
+
export declare function heredocPrefix(command: string): string | null;
|
|
143
|
+
/**
|
|
144
|
+
* The FULL remember ladder for one ask, in order (all five rungs — a shape
|
|
145
|
+
* never asks twice):
|
|
146
|
+
* 1. token patterns for every derivable uncovered segment (compound:
|
|
147
|
+
* multi-grant, atomically)
|
|
148
|
+
* 2. heredoc prefix (stable string before <<)
|
|
149
|
+
* 3. first line of a multiline command
|
|
150
|
+
* 4. the full literal command (Claude's editable-field final fallback; the
|
|
151
|
+
* user narrows it in the dialog)
|
|
152
|
+
* Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
|
|
153
|
+
* seeds are hypothetical until the user picks remember.
|
|
154
|
+
*/
|
|
155
|
+
export declare function deriveRememberSeeds(command: string, uncoveredCanonicalSegments: readonly string[], repoKey?: string): {
|
|
156
|
+
seeds: ApprovedPrefixGrant[];
|
|
157
|
+
description: string;
|
|
158
|
+
} | null;
|
|
159
|
+
/** The fencing check applied to raw command text: canonicalize + tokenize +
|
|
160
|
+
* the same refspec/flag dangers matchesGrant fences. Exported for tests. */
|
|
161
|
+
export declare function isFencedPushLiteral(command: string): boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Grant-time validation for the ESCAPE flow: the same self-match proof as
|
|
164
|
+
* validateGrant, minus the classification==="prompt" requirement — escaped
|
|
165
|
+
* commands are exactly the ones the exec policy classifies "allow" (that's
|
|
166
|
+
* the bug this flow fixes), so requiring prompt would never offer remember.
|
|
167
|
+
* The forbidden band is still enforced by the caller BEFORE the ask fires,
|
|
168
|
+
* so a forbidden command never reaches this function's seeds either way.
|
|
169
|
+
*/
|
|
170
|
+
export declare function validateGrantForEscape(command: string, policy: ExecPolicy, repoKey: string): {
|
|
171
|
+
seeds: ApprovedPrefixGrant[];
|
|
172
|
+
description: string;
|
|
173
|
+
} | null;
|
|
174
|
+
/**
|
|
175
|
+
* Compound evaluation for the escape flow (Claude's subcommandResults
|
|
176
|
+
* analog): every segment must be covered — by a grant, by exec-policy
|
|
177
|
+
* `allow` (read-only — their isReadOnly analog), or be a pure env-assignment
|
|
178
|
+
* no-op — for the compound to run without asking. `forbidden` propagates:
|
|
179
|
+
* the whole compound blocks regardless of what else it contains.
|
|
180
|
+
*/
|
|
181
|
+
export interface CompoundSegmentVerdict {
|
|
182
|
+
segment: string;
|
|
183
|
+
kind: "covered" | "assignment" | "uncovered" | "forbidden" | "classify-error";
|
|
184
|
+
}
|
|
185
|
+
export declare function evaluateCompoundForEscape(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string, policy: ExecPolicy): {
|
|
186
|
+
forbidden: boolean;
|
|
187
|
+
uncovered: string[];
|
|
188
|
+
segments: CompoundSegmentVerdict[];
|
|
189
|
+
};
|
|
76
190
|
/**
|
|
77
191
|
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
78
192
|
* file is plain JSON on disk, so a row that derivation could not have written
|