@yagni-app/code-staging 1.1.2-staging.1370.1 → 1.1.2-staging.1372.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.
@@ -36,8 +36,10 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
36
36
  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
- import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
39
+ import { isSandboxDenialOutput, sandboxAutoAllowDecision, sandboxDenialSignature, shouldUseSandbox } from "./sandbox/bash.js";
40
+ import { sandboxFailureKey } from "./permission/approvedPrefixes.js";
40
41
  import { createEscapeTally } from "./sandbox/escapeTally.js";
42
+ import { createGrantCensus } from "./permission/grantCensus.js";
41
43
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
42
44
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
45
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
@@ -723,6 +725,37 @@ export async function registerYagni(pi, deps = {}) {
723
725
  // the startup load is the trust boundary; live reload was reviewed and
724
726
  // rejected as a same-session self-authorization path, PR #1698).
725
727
  const sessionGrants = evalMode ? [] : loadGrants();
728
+ // Prior sandboxed failures (the auto-sandbox rung's session state): a
729
+ // bash tool_result whose output carries a sandbox-denial signature marks
730
+ // the command — its later escape attempts go to the consent dialog (the
731
+ // sandbox genuinely cannot run it). Content stays local: only the trimmed
732
+ // command string is held in memory, never logged.
733
+ const sandboxedFailures = new Map();
734
+ const SANDBOXED_FAILURES_MAX = 50;
735
+ const recordSandboxedFailure = (command) => {
736
+ // The CANONICAL key (sandboxFailureKey): a retry differing by quote
737
+ // rendering or safe decoration must hit the same entry — raw-string
738
+ // keys would miss exactly the variable shapes the grants layer
739
+ // canonicalizes for.
740
+ const key = sandboxFailureKey(command);
741
+ if (!key)
742
+ return;
743
+ sandboxedFailures.delete(key);
744
+ sandboxedFailures.set(key, true);
745
+ if (sandboxedFailures.size > SANDBOXED_FAILURES_MAX) {
746
+ const oldest = sandboxedFailures.keys().next().value;
747
+ if (oldest !== undefined)
748
+ sandboxedFailures.delete(oldest);
749
+ }
750
+ };
751
+ const hasSandboxedFailure = (command) => sandboxedFailures.has(sandboxFailureKey(command));
752
+ // A mode transition is a trust-posture change — the auto-sandbox rung's
753
+ // prior-failure evidence clears with the gate's other session caches
754
+ // (the gate's own fallback map clears via modeHolder.onSet; this map is
755
+ // the production one, so it must clear here too).
756
+ modeHolder.onSet(() => {
757
+ sandboxedFailures.clear();
758
+ });
726
759
  // settings-based permission rules, loaded once at startup (same
727
760
  // trust-boundary posture as grants: no mid-session reload). User config
728
761
  // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
@@ -758,6 +791,11 @@ export async function registerYagni(pi, deps = {}) {
758
791
  // shared by the gate (record), the /sandbox panel (breakdown), and the
759
792
  // session-end summary line.
760
793
  const escapeTally = createEscapeTally();
794
+ // The session grant census (telemetry-only): every grant the session
795
+ // loaded plus every match, for the /sandbox panel's never-matched
796
+ // surfacing. Nothing is pruned, nothing is written back to rules.json.
797
+ const grantCensus = createGrantCensus();
798
+ grantCensus.seed(sessionGrants);
761
799
  const sandboxHandle = evalMode
762
800
  ? null
763
801
  : registerSandbox(pi, {
@@ -766,6 +804,9 @@ export async function registerYagni(pi, deps = {}) {
766
804
  hasUI: (ctx) => ctx.hasUI,
767
805
  onShellResolutionRetry: (outcome) => telemetry.sandboxShellResolutionRetry(outcome),
768
806
  escapeBreakdown: () => escapeTally.breakdown(),
807
+ // The grant census snapshot for the /sandbox panel's grants
808
+ // section (session-scoped match counts + never-matched labels).
809
+ grantCensus: () => grantCensus.entries(),
769
810
  // Anchors project-protected paths (.yagni-code + its config.json in
770
811
  // denyWrite) and project-sourced permission rules to the repo the
771
812
  // session runs in — same root the gate uses for its rule anchoring.
@@ -785,6 +826,18 @@ export async function registerYagni(pi, deps = {}) {
785
826
  sandboxHandle.manager.initialized &&
786
827
  params.dangerouslyDisableSandbox === true &&
787
828
  sandboxHandle.settings().allowUnsandboxedCommands !== false,
829
+ // The auto-sandbox rung's would-run-wrapped proof: after the gate
830
+ // strips the flag, this command must actually be wrapped by
831
+ // shouldUseSandbox — an excludedCommands entry (or a manager reset)
832
+ // would otherwise run it UNSANDBOXED with zero consent. The gate
833
+ // additionally requires its own allow classification before it
834
+ // consults this predicate.
835
+ sandboxEscapeAutoSandbox: (command) => shouldUseSandbox({ command, dangerouslyDisableSandbox: false }, sandboxHandle.manager, sandboxHandle.settings()),
836
+ recordSandboxedFailure,
837
+ hasSandboxedFailure,
838
+ // Grant census feed: every grant match (prompt band + escape flow)
839
+ // counts toward the never-matched surfacing in /sandbox.
840
+ onGrantMatch: (grant) => grantCensus.record(grant),
788
841
  // Escape tally: interception-time, content-free on the always-on
789
842
  // info tier (the command prefix is command content — it rides the
790
843
  // debug tier only, same contract as logGateOutcomeTrail; the OTel
@@ -1670,12 +1723,39 @@ export async function registerYagni(pi, deps = {}) {
1670
1723
  pi.on("tool_result", (event) => {
1671
1724
  if (event.toolName !== "bash")
1672
1725
  return;
1673
- const command = typeof event.input?.command === "string"
1674
- ? event.input.command
1675
- : "";
1726
+ const input = (event.input ?? {});
1727
+ const command = typeof input.command === "string" ? input.command : "";
1676
1728
  if (GIT_MUTATING_PATTERN.test(command)) {
1677
1729
  footerInvalidateHandle.invalidateGit();
1678
1730
  }
1731
+ // The auto-sandbox rung's prior-failure evidence: a SANDBOXED bash
1732
+ // result carrying a sandbox-denial signature marks this command — its
1733
+ // later escape attempts re-arm the consent dialog instead of
1734
+ // auto-sandboxing into the same denial forever. Only sandboxed runs
1735
+ // count (an escaped run's failure text proves nothing about the
1736
+ // sandbox); only the trimmed command is kept, never logged.
1737
+ if (sandboxHandle &&
1738
+ !input.dangerouslyDisableSandbox &&
1739
+ event.isError &&
1740
+ command) {
1741
+ const text = (event.content ?? [])
1742
+ .map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : ""))
1743
+ .join("\n");
1744
+ if (isSandboxDenialOutput(text)) {
1745
+ recordSandboxedFailure(command);
1746
+ // The seed must be observable (debug tier only — no command content,
1747
+ // the closed signature class rides the field): a false-positive seed
1748
+ // session-long disables auto-sandbox for a command, and without this
1749
+ // line the only symptom would be unexplained dialogs.
1750
+ const signature = sandboxDenialSignature(text);
1751
+ logEvent({
1752
+ source: "sandbox",
1753
+ level: "debug",
1754
+ event: "sandbox_failure_seeded",
1755
+ fields: { signature },
1756
+ });
1757
+ }
1758
+ }
1679
1759
  });
1680
1760
  // Seed the unified error trail from tool-exec failures. A tool's SUCCESS
1681
1761
  // is content (it lives in the transcript); its FAILURE is an error and belongs
@@ -100,6 +100,16 @@ export declare const BANNED_PREFIXES: Set<string>;
100
100
  * (never canonicalized as one string).
101
101
  */
102
102
  export declare function canonicalizeForGrants(command: string): string;
103
+ /**
104
+ * The canonical key for the prior-sandboxed-failure set (the auto-sandbox
105
+ * rung's session state, consumed by the gate and index.ts). Raw command
106
+ * strings would miss a retry that differs by quote rendering or safe
107
+ * decoration — exactly the variable shapes canonicalization exists for —
108
+ * so the key is the SAME normalization grant matching uses. Lives HERE
109
+ * (next to canonicalizeForGrants) so the sandbox layer never imports from
110
+ * permission. Pure.
111
+ */
112
+ export declare function sandboxFailureKey(command: string): string;
103
113
  /**
104
114
  * Derive the grantable token prefix for a single canonical command, Claude's
105
115
  * getSimpleCommandPrefix → getFirstWordPrefix ladder:
@@ -203,6 +213,11 @@ export declare function validateGrantForAsk(command: string, policy: ExecPolicy,
203
213
  export interface CompoundSegmentVerdict {
204
214
  segment: string;
205
215
  kind: "covered" | "assignment" | "uncovered" | "forbidden" | "classify-error";
216
+ /** Present on `covered` verdicts: the grant that covered the segment.
217
+ * Carried so the census (and any other consumer) reads the match the
218
+ * evaluation already computed — no second matchesGrant pass, no drift
219
+ * between two call sites. */
220
+ grant?: ApprovedPrefixGrant;
206
221
  }
207
222
  export declare function evaluateCompoundForEscape(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string, policy: ExecPolicy): {
208
223
  forbidden: boolean;
@@ -129,13 +129,41 @@ export function canonicalizeForGrants(command) {
129
129
  // purpose: isSinglePlainCommand then fails, token grants never match, and
130
130
  // the shape falls to the literal rungs (an escaped command with a write
131
131
  // target must never silently ride a prefix grant).
132
+ //
133
+ // The re-join RE-QUOTES metachar-bearing tokens (the renderString idiom
134
+ // from splitSubcommandsQuoted below): a quoted `|`/`;`/`&` inside a data
135
+ // argument (a jq -q filter, an SQL body, an awk script) is TEXT, not an
136
+ // operator — a plain-string re-join used to emit it BARE, so every real
137
+ // command carrying `2>&1` plus quoted data canonicalized into a compound
138
+ // and EVERY token grant died (the escape-consent dead-grant bug: 5 dialogs
139
+ // for read-only gh commands in one session). Tokens containing a single
140
+ // quote cannot be re-quoted losslessly (the `'\''` idiom is not
141
+ // implemented on re-parse) — such a token makes the re-join REFUSE (the
142
+ // raw string is returned; matching then fails closed, the documented lossy
143
+ // case — the command word never carries one).
132
144
  const parsed = shellParse(s);
133
145
  const onlySafeOps = parsed.every((t) => typeof t === "string" || (t.op === "redirect" && isSafeRedirect(t)));
134
146
  if (onlySafeOps && parsed.some((t) => typeof t === "object")) {
135
- s = parsed.filter((t) => typeof t === "string").join(" ");
147
+ const strings = parsed.filter((t) => typeof t === "string");
148
+ const renderQuoted = (t) => /[\s;|&<>"'\\$`]/.test(t) ? `'${t.replace(/'/g, "'\\''")}'` : t;
149
+ if (strings.every((t) => !t.includes("'"))) {
150
+ s = strings.map(renderQuoted).join(" ");
151
+ }
136
152
  }
137
153
  return s.trim();
138
154
  }
155
+ /**
156
+ * The canonical key for the prior-sandboxed-failure set (the auto-sandbox
157
+ * rung's session state, consumed by the gate and index.ts). Raw command
158
+ * strings would miss a retry that differs by quote rendering or safe
159
+ * decoration — exactly the variable shapes canonicalization exists for —
160
+ * so the key is the SAME normalization grant matching uses. Lives HERE
161
+ * (next to canonicalizeForGrants) so the sandbox layer never imports from
162
+ * permission. Pure.
163
+ */
164
+ export function sandboxFailureKey(command) {
165
+ return canonicalizeForGrants(command);
166
+ }
139
167
  /**
140
168
  * A command qualifies for grant coverage only when it is one plain command:
141
169
  * no operators, no constructs (checked via the quote-aware tokenizer, so
@@ -724,10 +752,16 @@ export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
724
752
  // exists for (gh/test/psql all classify allow). Coverage = grants only
725
753
  // (+ assignment no-ops); readonly classification is irrelevant to the
726
754
  // escape consent. (Claude parity: their sandboxOverride ask fires for the
727
- // whole call regardless of subcommand read-only-ness.)
755
+ // whole call regardless of subcommand read-only-ness.) The gate's
756
+ // auto-sandbox rung (gateSandboxEscape) sits ABOVE this evaluation for
757
+ // all-`allow`-classified commands with no prior sandboxed failure: the
758
+ // command runs INSIDE the sandbox with no ask — running a read-only
759
+ // command sandboxed is not an exercise of unsandboxed authority, so
760
+ // the consent this flow guards is never owed for it.
728
761
  const canonical = canonicalizeForGrants(trimmed);
729
- if (matchesGrant(trimmed, grants, repoKey)) {
730
- segments.push({ segment: trimmed, kind: "covered" });
762
+ const matched = matchesGrant(trimmed, grants, repoKey);
763
+ if (matched) {
764
+ segments.push({ segment: trimmed, kind: "covered", grant: matched });
731
765
  continue;
732
766
  }
733
767
  segments.push({ segment: canonical, kind: "uncovered" });
@@ -136,7 +136,7 @@ export declare function decideGate(toolName: string, params: Record<string, unkn
136
136
  * is emitted per terminal outcome; `consulted` says whether a Guardian LLM
137
137
  * call actually happened (grants/cache hits skip it).
138
138
  */
139
- export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "sandbox_auto_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | "escape_grant_allow" | "escape_cached_allow" | "escape_ask_approved" | "escape_ask_approved_remembered" | "escape_hook_allowed" | "escape_ask_denied" | "escape_ask_headless_blocked" | "escape_forbidden_blocked" | "escape_aborted" | "escape_ask_failed" | GuardianError;
139
+ export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "sandbox_auto_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | "escape_grant_allow" | "escape_cached_allow" | "escape_ask_approved" | "escape_ask_approved_remembered" | "escape_hook_allowed" | "escape_auto_sandboxed" | "escape_ask_denied" | "escape_ask_headless_blocked" | "escape_forbidden_blocked" | "escape_aborted" | "escape_ask_failed" | GuardianError;
140
140
  /**
141
141
  * Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
142
142
  * command — the wiring layer (index.ts) hashes/redacts per the workspace's
@@ -246,6 +246,13 @@ export interface RegisterPermissionDeps {
246
246
  * Fail-soft; never blocks.
247
247
  */
248
248
  onGuardianEvent?: (event: GuardianGateEvent) => void;
249
+ /**
250
+ * Called (fire-and-forget) when a grant MATCHED — at the prompt-band
251
+ * prefix_allow site and at the escape flow's grant allow sites. Feeds the
252
+ * session grant census (never-matched surfacing in /sandbox). Fail-soft;
253
+ * never blocks, never widens anything (telemetry-only).
254
+ */
255
+ onGrantMatch?: (grant: ApprovedPrefixGrant) => void;
249
256
  /**
250
257
  * Called (fire-and-forget) at EVERY terminal tool_call outcome, for every
251
258
  * tool, with the Claude Code-shaped decision (accept/reject + source).
@@ -299,6 +306,32 @@ export interface RegisterPermissionDeps {
299
306
  * escaped command. Fail-soft contract: never blocks the gate.
300
307
  */
301
308
  onSandboxEscape?: (prefix: string) => void;
309
+ /**
310
+ * Auto-sandbox escape rung: when true, a dangerouslyDisableSandbox call
311
+ * whose command classifies `allow` will run INSIDE the sandbox instead of
312
+ * prompting — the gate strips the flag from event.input in place and the
313
+ * bash tool wraps it (strictly less authority than the "Yes, run it"
314
+ * button it replaces). The session owns the decision — it must verify the
315
+ * command WILL actually run wrapped after the strip (manager initialized,
316
+ * not in excludedCommands). When absent or false the rung is inert and
317
+ * every escape asks as before.
318
+ */
319
+ sandboxEscapeAutoSandbox?: (command: string) => boolean;
320
+ /**
321
+ * Record that this command's SANDBXED run failed a sandbox denial this
322
+ * session (the prior-failure gate: the FIRST genuine sandbox denial of a
323
+ * command sends its later escape attempts back to the consent dialog —
324
+ * the auto-sandbox rung would otherwise loop a denied command forever).
325
+ * Wired by index.ts from a tool_result listener; fail-soft.
326
+ */
327
+ recordSandboxedFailure?: (command: string) => void;
328
+ /**
329
+ * Has this command failed a sandboxed run this session? Consulted by the
330
+ * auto-sandbox rung AFTER the allow classification (a prior denial means
331
+ * the sandbox genuinely cannot run this command — the human dialog is
332
+ * the only remaining path). Absent → no prior failures known.
333
+ */
334
+ hasSandboxedFailure?: (command: string) => boolean;
302
335
  /**
303
336
  * User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
304
337
  * hooks run before decideGate and can short-circuit (allow/deny/ask),
@@ -26,7 +26,7 @@
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, matchesCompoundGrants, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
@@ -487,6 +487,38 @@ export function registerPermissionGate(pi, deps = {}) {
487
487
  // any directory, and repo scoping happens at GRANT time, not consult time).
488
488
  const prefixMemo = createPrefixMemo();
489
489
  const runPrefixConsult = deps.prefixConsultFn ?? consultPrefixMemoized;
490
+ // Prior sandboxed failures (the auto-sandbox rung's session state): a
491
+ // command whose SANDBOXED run already hit a sandbox denial this session
492
+ // never auto-sandboxes on an escape attempt again — the consent dialog is
493
+ // the honest path for a command the sandbox genuinely cannot run. The
494
+ // PRODUCTION state lives in index.ts (its tool_result listener records,
495
+ // its map keys CANONICALLY via sandboxFailureKey and clears on /mode
496
+ // transitions via the shared modeHolder); the fallback map here backs
497
+ // only test wiring that injects no deps — same canonical key, same
498
+ // onSet clear, so the two can never drift.
499
+ const sandboxedFailures = new Map();
500
+ const recordSandboxedFailure = deps.recordSandboxedFailure ?? ((command) => {
501
+ const key = sandboxFailureKey(command);
502
+ if (!key)
503
+ return;
504
+ sandboxedFailures.delete(key);
505
+ sandboxedFailures.set(key, true);
506
+ if (sandboxedFailures.size > APPROVED_CACHE_MAX) {
507
+ const oldest = sandboxedFailures.keys().next().value;
508
+ if (oldest !== undefined)
509
+ sandboxedFailures.delete(oldest);
510
+ }
511
+ });
512
+ const hasSandboxedFailure = deps.hasSandboxedFailure ?? ((command) => sandboxedFailures.has(sandboxFailureKey(command)));
513
+ // UNCONDITIONAL clear — the production map (index.ts) clears on every
514
+ // onSet, so returning to the SAME mode (auto → review → auto) drops the
515
+ // evidence there too; this fallback mirrors that contract exactly (the
516
+ // other caches keep their m !== mode guard deliberately: an approved
517
+ // command's meaning does not change when a mode flip round-trips, but
518
+ // prior-failure evidence is environmental, not per-mode).
519
+ deps.modeHolder?.onSet(() => {
520
+ sandboxedFailures.clear();
521
+ });
490
522
  /** Shared consult-call builder: routes through the injectable seam with
491
523
  * the sink line + the onPrefixConsult hook — ONE place so the escape and
492
524
  * Guardian-ask fan-outs cannot drift. */
@@ -1004,6 +1036,12 @@ export function registerPermissionGate(pi, deps = {}) {
1004
1036
  if (modeAtEntry === "auto" && command) {
1005
1037
  const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
1006
1038
  if (grant || (compoundPrefixGrantsEnabled && matchesCompoundGrants(command, grants, resolveRepoKeyFor(cwd), effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY))) {
1039
+ if (grant) {
1040
+ try {
1041
+ deps.onGrantMatch?.(grant);
1042
+ }
1043
+ catch { /* telemetry must never affect the gate */ }
1044
+ }
1007
1045
  emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
1008
1046
  return {};
1009
1047
  }
@@ -1577,6 +1615,17 @@ export function registerPermissionGate(pi, deps = {}) {
1577
1615
  // unsandboxed during plan mode; the ask is the only allow path here.
1578
1616
  if (!inPlanMode) {
1579
1617
  if (compound.uncovered.length === 0) {
1618
+ // Census: the evaluation already computed WHICH grant covered each
1619
+ // segment (the verdict carries it) — feed the census from that, no
1620
+ // second matching pass, no drift between call sites.
1621
+ try {
1622
+ for (const verdict of compound.segments) {
1623
+ if (verdict.kind === "covered" && verdict.grant) {
1624
+ deps.onGrantMatch?.(verdict.grant);
1625
+ }
1626
+ }
1627
+ }
1628
+ catch { /* telemetry must never affect the gate */ }
1580
1629
  emitGateEvent(slot, { ...eventBase, outcome: "escape_grant_allow", consulted: false });
1581
1630
  return {};
1582
1631
  }
@@ -1586,6 +1635,65 @@ export function registerPermissionGate(pi, deps = {}) {
1586
1635
  return {};
1587
1636
  }
1588
1637
  }
1638
+ // Auto-sandbox rung: an all-`allow`-classified escape attempt with no
1639
+ // prior sandboxed failure of this same command runs INSIDE the sandbox
1640
+ // instead of prompting. This covers the pipeline/`||`/awk shapes the
1641
+ // token system deliberately can't (per-stage grants for grep/head would
1642
+ // be a UX dead-end) and it does NOT widen unsandboxed authority — the
1643
+ // command runs with strictly less authority than the "Yes, run it"
1644
+ // button the dialog would have offered. The prior-failure gate keeps
1645
+ // the honest path: once the sandbox genuinely DENIES this command
1646
+ // (EPERM/locked network), its later escapes go to the consent dialog.
1647
+ // The predicate ALSO verifies the command will actually run wrapped
1648
+ // after the flag strip — an excludedCommands entry would otherwise run
1649
+ // unsandboxed with zero consent (the silent-escape hole).
1650
+ // The rung's conditions, in order — the FIRST failure names the decline
1651
+ // reason in the trail ("why did this escape ask instead of
1652
+ // auto-sandboxing?" must be answerable): closed vocabulary, no command
1653
+ // content, debug tier.
1654
+ const declineTrace = (reason) => {
1655
+ try {
1656
+ logEvent({
1657
+ source: "sandbox",
1658
+ level: "debug",
1659
+ event: "escape_rung_declined",
1660
+ fields: { reason },
1661
+ });
1662
+ }
1663
+ catch { /* telemetry must never affect the gate */ }
1664
+ };
1665
+ let classifyAllowForAutoSandbox = false;
1666
+ try {
1667
+ classifyAllowForAutoSandbox =
1668
+ classifyCommand(command, execPolicy).decision === "allow";
1669
+ }
1670
+ catch {
1671
+ classifyAllowForAutoSandbox = false; // classifier crash → dialog (fail closed)
1672
+ }
1673
+ if (!classifyAllowForAutoSandbox)
1674
+ declineTrace("classify_not_allow");
1675
+ else if (hasSandboxedFailure(command))
1676
+ declineTrace("prior_failure");
1677
+ else if (!deps.sandboxEscapeAutoSandbox || !deps.sandboxEscapeAutoSandbox(command))
1678
+ declineTrace("predicate_declined");
1679
+ else {
1680
+ // strip the flag in place — pi's documented arg-mutation seam: the
1681
+ // bash tool's shouldUseSandbox then wraps this command.
1682
+ try {
1683
+ delete event.input?.dangerouslyDisableSandbox;
1684
+ }
1685
+ catch { /* a frozen input would fail the strip — fall through to ask */ }
1686
+ if (event.input?.dangerouslyDisableSandbox !== true) {
1687
+ emitGateEvent(slot, { ...eventBase, outcome: "escape_auto_sandboxed", consulted: false });
1688
+ if (ctx?.hasUI) {
1689
+ try {
1690
+ ctx.ui.notify("Ran this command inside the sandbox instead of unsandboxed (read-only; ask if a sandbox restriction blocks it).", "info");
1691
+ }
1692
+ catch { /* notify must never block */ }
1693
+ }
1694
+ return {};
1695
+ }
1696
+ }
1589
1697
  // Headless (incl. every /go child stage): fail closed — same contract as
1590
1698
  // the Guardian ask path (ask_headless_blocked).
1591
1699
  if (!ctx?.hasUI) {
@@ -1709,19 +1817,19 @@ export function registerPermissionGate(pi, deps = {}) {
1709
1817
  if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
1710
1818
  const trimmedCustom = custom.trim();
1711
1819
  // The custom field accepts two shapes:
1712
- // (a) a clean command word (`npx`, `psql`) — the first-word rung of
1713
- // the ladder validated by derivePrefix's shape check and
1714
- // self-matched as a TOKEN grant (covers every later `npx …` /
1715
- // `psql …` regardless of arguments; Claude's `psql:*` semantics).
1716
- // A bare word can never pass the literal-remainder check (the
1717
- // words after it are "not inert"), which is why the field used
1718
- // to reject exactly this input with a dead-rule warning. The
1719
- // git-push force/refspec fence is pattern-length-aware (a
1720
- // bare `git` grant covers pushes too) — see matchesGrant.
1721
- // (b) anything longer — a literal string prefix, validated as
1722
- // before (must cover THIS command, banned/fenced shapes
1820
+ // (a) a derivable token pattern — a clean command word (`npx`,
1821
+ // `psql`) OR a word pair (`gh pr`) — derivePrefix's own ladder
1822
+ // (two-token for subcommand-shaped second words), then the
1823
+ // compound-coverage self-match. Multi-word inputs used to route
1824
+ // to the literal path (anything containing a space), whose
1825
+ // remainder can never pass the inert-remainder check the
1826
+ // Sep-14 dead-end where NO input could fix the decorated
1827
+ // shapes. The git-push force/refspec fence is pattern-length-
1828
+ // aware (a bare `git` grant covers pushes too) — matchesGrant.
1829
+ // (b) anything non-derivable — a literal string prefix, validated
1830
+ // as before (must cover THIS command, banned/fenced shapes
1723
1831
  // refused the same way every rung does).
1724
- const tokenPattern = trimmedCustom.includes(" ") || trimmedCustom.includes("\n")
1832
+ const tokenPattern = trimmedCustom.includes("\n")
1725
1833
  ? null
1726
1834
  : derivePrefix(trimmedCustom);
1727
1835
  const customGrant = tokenPattern
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Session grant census — per-grant match counts for the /sandbox panel
3
+ * (telemetry-only; no permission power). Mirrors escapeTally's shape: pure
4
+ * bookkeeping the gate records into and the panel renders.
5
+ *
6
+ * The "never matched" surfacing is deliberately SESSION-SCOPED: "dead" is
7
+ * shape-dependent, not grant-dependent (a literal that cannot cover a
8
+ * multiline body today could cover a single-line one tomorrow), so nothing
9
+ * is pruned and nothing is persisted back to rules.json. The count answers
10
+ * the operator's question at the panel: which of my saved grants actually
11
+ * fired this session, and which have never fired — with the grant's stored
12
+ * cwd provenance alongside for a future revoke UI.
13
+ */
14
+ import type { ApprovedPrefixGrant } from "./approvedPrefixes.js";
15
+ export interface GrantCensusEntry {
16
+ /** The grant's display label: token pattern joined, or the literal. */
17
+ label: string;
18
+ /** Times this grant matched a command this session. */
19
+ matches: number;
20
+ /** The grant's stored cwd (provenance for a future revoke UI). */
21
+ cwd: string;
22
+ /** When the grant was persisted (ISO), from the grant record. */
23
+ addedAt: string;
24
+ }
25
+ export interface GrantCensus {
26
+ /** Seed the census with the session's loaded grants (idempotent per grant
27
+ * identity: pattern or literal — a re-seed never resets the count). */
28
+ seed(grants: readonly ApprovedPrefixGrant[]): void;
29
+ /** Record one match of a grant (matched grants are auto-seeded). */
30
+ record(grant: ApprovedPrefixGrant): void;
31
+ /** Snapshot, most-matched first; never-matched grants keep their seed order. */
32
+ entries(): GrantCensusEntry[];
33
+ /** Count of grants that have never matched this session. */
34
+ neverMatchedCount(): number;
35
+ }
36
+ export declare function createGrantCensus(): GrantCensus;
37
+ //# sourceMappingURL=grantCensus.d.ts.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Session grant census — per-grant match counts for the /sandbox panel
3
+ * (telemetry-only; no permission power). Mirrors escapeTally's shape: pure
4
+ * bookkeeping the gate records into and the panel renders.
5
+ *
6
+ * The "never matched" surfacing is deliberately SESSION-SCOPED: "dead" is
7
+ * shape-dependent, not grant-dependent (a literal that cannot cover a
8
+ * multiline body today could cover a single-line one tomorrow), so nothing
9
+ * is pruned and nothing is persisted back to rules.json. The count answers
10
+ * the operator's question at the panel: which of my saved grants actually
11
+ * fired this session, and which have never fired — with the grant's stored
12
+ * cwd provenance alongside for a future revoke UI.
13
+ */
14
+ const grantLabel = (grant) => grant.pattern.length > 0 ? grant.pattern.join(" ") : (grant.literal ?? "");
15
+ const grantKey = (grant) => grant.pattern.length > 0 ? `t:${grant.pattern.join(" ")}` : `l:${grant.literal ?? ""}`;
16
+ export function createGrantCensus() {
17
+ const counts = new Map();
18
+ const upsert = (grant) => {
19
+ const key = grantKey(grant);
20
+ let entry = counts.get(key);
21
+ if (!entry) {
22
+ entry = {
23
+ label: grantLabel(grant),
24
+ matches: 0,
25
+ cwd: grant.cwd,
26
+ addedAt: grant.addedAt,
27
+ };
28
+ counts.set(key, entry);
29
+ }
30
+ return entry;
31
+ };
32
+ return {
33
+ seed(grants) {
34
+ for (const grant of grants)
35
+ upsert(grant);
36
+ },
37
+ record(grant) {
38
+ upsert(grant).matches += 1;
39
+ },
40
+ entries() {
41
+ return [...counts.values()].sort((a, b) => b.matches - a.matches);
42
+ },
43
+ neverMatchedCount() {
44
+ let n = 0;
45
+ for (const entry of counts.values())
46
+ if (entry.matches === 0)
47
+ n += 1;
48
+ return n;
49
+ },
50
+ };
51
+ }
52
+ //# sourceMappingURL=grantCensus.js.map
@@ -82,6 +82,26 @@ export declare function _setShellResolutionRetryBackoffForTest(ms: number | null
82
82
  * spawnSync('which') times out under load. Self-healing after ~15–30s;
83
83
  * preWrappedCommand retries it once. */
84
84
  export declare function isShellResolutionFailure(err: unknown): boolean;
85
+ /**
86
+ * Did this command output indicate the OS sandbox DENIED something? The
87
+ * session-wide evidence the escape-consent flow keys its auto-sandbox rung
88
+ * on: an all-allow-classified escape runs inside the sandbox UNLESS this
89
+ * same command already failed sandboxed this session (then the consent
90
+ * dialog — the denial hint informs the retry). Wraps the same signatures the
91
+ * annotation path uses: the fs EPERM marker and the network-posture
92
+ * classifier. The `[sandbox]` annotation prefix also counts — the output
93
+ * reaching tool_result on a denial already carries the advisory hint.
94
+ * Pure; matches TEXT ONLY (never widens anything by itself).
95
+ */
96
+ /** The signature classes the seed-site trace reports (closed vocabulary,
97
+ * low cardinality — never command content). `network` outranks `annotation`
98
+ * for an annotated NETWORK denial (every hint text begins "[sandbox] This
99
+ * looks like a sandbox …", so checking the annotation first would mislabel
100
+ * the most common class); an annotated non-network denial (fs/write) is
101
+ * `annotation`; a bare EPERM line with no class markers is `epem`. Null when
102
+ * the output carries no denial signature at all. */
103
+ export declare function sandboxDenialSignature(output: string): "network" | "annotation" | "epem" | null;
104
+ export declare function isSandboxDenialOutput(output: string): boolean;
85
105
  /**
86
106
  * Detect "Operation not permitted" style sandbox denials in bash output so
87
107
  * callers (tool_result) can annotate and the model can react. Returns the
@@ -226,6 +226,74 @@ function sleep(ms, signal) {
226
226
  export function isShellResolutionFailure(err) {
227
227
  return err instanceof Error && /^Shell '[^']*' not found in PATH$/.test(err.message);
228
228
  }
229
+ /**
230
+ * Did this command output indicate the OS sandbox DENIED something? The
231
+ * session-wide evidence the escape-consent flow keys its auto-sandbox rung
232
+ * on: an all-allow-classified escape runs inside the sandbox UNLESS this
233
+ * same command already failed sandboxed this session (then the consent
234
+ * dialog — the denial hint informs the retry). Wraps the same signatures the
235
+ * annotation path uses: the fs EPERM marker and the network-posture
236
+ * classifier. The `[sandbox]` annotation prefix also counts — the output
237
+ * reaching tool_result on a denial already carries the advisory hint.
238
+ * Pure; matches TEXT ONLY (never widens anything by itself).
239
+ */
240
+ /** The signature classes the seed-site trace reports (closed vocabulary,
241
+ * low cardinality — never command content). `network` outranks `annotation`
242
+ * for an annotated NETWORK denial (every hint text begins "[sandbox] This
243
+ * looks like a sandbox …", so checking the annotation first would mislabel
244
+ * the most common class); an annotated non-network denial (fs/write) is
245
+ * `annotation`; a bare EPERM line with no class markers is `epem`. Null when
246
+ * the output carries no denial signature at all. */
247
+ export function sandboxDenialSignature(output) {
248
+ if (!isSandboxDenialOutput(output))
249
+ return null;
250
+ if (networkDenialHint(output) !== null)
251
+ return "network";
252
+ if (/^\[sandbox\] (this looks like a (sandbox|heredoc))/im.test(output))
253
+ return "annotation";
254
+ return "epem";
255
+ }
256
+ export function isSandboxDenialOutput(output) {
257
+ // The [sandbox] annotation: only the advisory hint's OWN fixed sentence
258
+ // shape counts ("[sandbox] This looks like a sandbox/heredoc …" — the
259
+ // annotation vocabulary is ours and closed). Data text merely containing
260
+ // a bracketed marker (a cat of a log file, a grep hit) does not match.
261
+ if (/^\[sandbox\] (this looks like a (sandbox|heredoc))/im.test(output))
262
+ return true;
263
+ // EPERM / "Operation not permitted": only on an ERROR-DIAGNOSTIC line.
264
+ // The qualifying shapes — the error forms the OS and its tools emit:
265
+ // `tool: … phrase` a shell/tool prefix with a separator (`bash:`,
266
+ // `a.out:`, `sandbox-exec:` — ANY word followed
267
+ // by the separator, so a binary whose name starts
268
+ // with a filter word still qualifies)
269
+ // `phrase at head` the bare denial line itself
270
+ // `code:`/`syscall:` a node error-object detail line
271
+ // The data shapes — numbered grep hits (`120: bash: …`), prose (the
272
+ // phrase mid-sentence behind an article), quoted text — are excluded.
273
+ // Cheap line-local checks; fail-safe either way (a false negative means
274
+ // the rung fires once more; a false positive means one dialog too many).
275
+ for (const line of output.split("\n")) {
276
+ const t = line.trim();
277
+ if (!/operation not permitted|\bEPERM\b/i.test(t))
278
+ continue;
279
+ // Numbered log/grep hit lines quote the denial; they are data.
280
+ if (/^\d+:/.test(t))
281
+ continue;
282
+ // Quoted text (the phrase inside a leading quote mark) — data.
283
+ if (/^["']/.test(t))
284
+ continue;
285
+ // Prose: the phrase sits mid-sentence behind an article/conjunction
286
+ // with NO word-separator before it (`the sandbox may print operation
287
+ // not permitted…`). A `word:` separator anywhere before the phrase
288
+ // is the diagnostic shape and overrides the prose read.
289
+ const phraseAt = t.search(/operation not permitted|\bEPERM\b/i);
290
+ const before = phraseAt > 0 ? t.slice(0, phraseAt) : "";
291
+ if (!/^["']?[\w.+-]+:/.test(before) && /^(the|a|an|this|that|it|when|if|note|tip|for|some)\b/i.test(t))
292
+ continue;
293
+ return true;
294
+ }
295
+ return networkDenialHint(output) !== null;
296
+ }
229
297
  /**
230
298
  * Detect "Operation not permitted" style sandbox denials in bash output so
231
299
  * callers (tool_result) can annotate and the model can react. Returns the
@@ -47,6 +47,9 @@ export interface SandboxPanelState {
47
47
  prefix: string;
48
48
  n: number;
49
49
  }[];
50
+ /** Session grant census (telemetry-only): per-grant match counts for the
51
+ * never-matched surfacing. Absent/empty ⇒ no grants section. */
52
+ grantCensus?: import("../permission/grantCensus.js").GrantCensusEntry[];
50
53
  }
51
54
  export interface SandboxPanelActions {
52
55
  /** Persist a mode choice. Resolves with the confirmation to surface
@@ -151,7 +154,10 @@ export declare function buildPanelState(settings: SandboxSettings, sessionToggle
151
154
  }, escapes?: {
152
155
  prefix: string;
153
156
  n: number;
154
- }[]): SandboxPanelState;
157
+ }[], grantCensus?: import("../permission/grantCensus.js").GrantCensusEntry[]): SandboxPanelState;
158
+ /** The grants section rows: match counts + the never-matched label. Null
159
+ * when the census is empty (no saved grants — the section stays hidden). */
160
+ export declare function grantCensusRows(census: import("../permission/grantCensus.js").GrantCensusEntry[] | undefined): string[] | null;
155
161
  /** The /sandbox row for the escape tally: null when no escapes (silent). */
156
162
  export declare function escapeTallyRow(escapes: {
157
163
  prefix: string;
@@ -210,6 +210,10 @@ export function configSections(state) {
210
210
  if (escapeRow) {
211
211
  sections.push({ title: "Unsandboxed Retries", detail: [escapeRow] });
212
212
  }
213
+ const grantRows = grantCensusRows(state.grantCensus);
214
+ if (grantRows) {
215
+ sections.push({ title: "Saved Grants (this session)", detail: grantRows });
216
+ }
213
217
  return sections;
214
218
  }
215
219
  /** Tabs offered, mirroring Claude plus our Network tab (the deliberate
@@ -493,7 +497,7 @@ export class SandboxPanel extends Container {
493
497
  * Build the panel state from the pieces session.ts already holds. Exported
494
498
  * for unit tests; the TUI component consumes it.
495
499
  */
496
- export function buildPanelState(settings, sessionToggledOff, rules, paths, sessionGrants, dependencyStatus, escapes) {
500
+ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessionGrants, dependencyStatus, escapes, grantCensus) {
497
501
  return {
498
502
  settings,
499
503
  sessionToggledOff,
@@ -502,8 +506,20 @@ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessi
502
506
  merge: mergeRulesIntoSandbox(settings, rules, paths, resolveWorktreeGitAccess(paths.cwd)),
503
507
  sessionGrants: [...sessionGrants],
504
508
  ...(escapes && escapes.length > 0 ? { escapes } : {}),
509
+ ...(grantCensus && grantCensus.length > 0 ? { grantCensus } : {}),
505
510
  };
506
511
  }
512
+ /** The grants section rows: match counts + the never-matched label. Null
513
+ * when the census is empty (no saved grants — the section stays hidden). */
514
+ export function grantCensusRows(census) {
515
+ if (!census || census.length === 0)
516
+ return null;
517
+ const rows = census.slice(0, 8).map((entry) => entry.matches > 0
518
+ ? `${entry.label}: matched ${entry.matches}${entry.matches === 1 ? " time" : " times"} this session`
519
+ : `${entry.label}: never matched this session`);
520
+ const more = census.length > 8 ? [`+${census.length - 8} more`] : [];
521
+ return [...rows, ...more];
522
+ }
507
523
  /** The /sandbox row for the escape tally: null when no escapes (silent). */
508
524
  export function escapeTallyRow(escapes) {
509
525
  if (!escapes || escapes.length === 0)
@@ -90,6 +90,10 @@ export interface RegisterSandboxOptions {
90
90
  prefix: string;
91
91
  n: number;
92
92
  }[];
93
+ /** The session grant census (telemetry-only): per-grant match counts for
94
+ * the /sandbox panel's grants section — the never-matched surfacing.
95
+ * Absent ⇒ no grants section. */
96
+ grantCensus?: () => import("../permission/grantCensus.js").GrantCensusEntry[];
93
97
  }
94
98
  /**
95
99
  * Register the sandbox surfaces on the ExtensionAPI. Returns the session
@@ -25,7 +25,7 @@ import { loadSandboxSettings } from "./config.js";
25
25
  import { resolveWorktreeGitAccess } from "./worktreeGit.js";
26
26
  import { annotateCommandOutput, fsDenialHint, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
27
27
  import { YagniSandboxManager } from "./manager.js";
28
- import { SandboxPanel, buildPanelState, engineeringPresetBlock } from "./panel.js";
28
+ import { SandboxPanel, buildPanelState, engineeringPresetBlock, grantCensusRows } from "./panel.js";
29
29
  import { effectiveRules } from "../permissionRules/loadConfig.js";
30
30
  /**
31
31
  * Build the sandbox bash composition: given ANY stock bash definition (pi's
@@ -558,6 +558,11 @@ export function registerSandbox(pi, opts) {
558
558
  lines.push(`session grants: ${[...sessionDomainGrants].join(", ")} (memory-only)`);
559
559
  if (s.excludedCommands?.length)
560
560
  lines.push(`excludedCommands: ${s.excludedCommands.join(", ")}`);
561
+ // The grant census rows (never-matched surfacing) — same content the
562
+ // panel's grants section renders.
563
+ const grantRows = grantCensusRows(opts.grantCensus?.());
564
+ if (grantRows)
565
+ lines.push(...grantRows);
561
566
  return lines;
562
567
  };
563
568
  /**
@@ -675,7 +680,7 @@ export function registerSandbox(pi, opts) {
675
680
  userStateHome: resolvedStateHome,
676
681
  projectRoot: opts.projectRoot ?? null,
677
682
  homeDir: opts.userHome,
678
- }, [...sessionDomainGrants], deps, opts.escapeBreakdown?.());
683
+ }, [...sessionDomainGrants], deps, opts.escapeBreakdown?.(), opts.grantCensus?.());
679
684
  const actions = {
680
685
  onModeSelect: async (choice) => {
681
686
  const wasEnabled = load().enabled === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.2-staging.1370.1",
3
+ "version": "1.1.2-staging.1372.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": "2f7255058068c24ee9b070aec43e23b4fc32fb39"
61
+ "yagniSourceSha": "2eb361867f2478b0c87963dd560acde18ff5ecf6"
62
62
  }