@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.
- package/dist/extension/index.js +84 -4
- package/dist/extension/permission/approvedPrefixes.d.ts +15 -0
- package/dist/extension/permission/approvedPrefixes.js +38 -4
- package/dist/extension/permission/gate.d.ts +34 -1
- package/dist/extension/permission/gate.js +121 -13
- package/dist/extension/permission/grantCensus.d.ts +37 -0
- package/dist/extension/permission/grantCensus.js +52 -0
- package/dist/extension/sandbox/bash.d.ts +20 -0
- package/dist/extension/sandbox/bash.js +68 -0
- package/dist/extension/sandbox/panel.d.ts +7 -1
- package/dist/extension/sandbox/panel.js +17 -1
- package/dist/extension/sandbox/session.d.ts +4 -0
- package/dist/extension/sandbox/session.js +7 -2
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -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
|
|
1674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
730
|
-
|
|
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`,
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
// git-push force/refspec fence is pattern-length-
|
|
1720
|
-
// bare `git` grant covers pushes too) —
|
|
1721
|
-
// (b) anything
|
|
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("
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "2eb361867f2478b0c87963dd560acde18ff5ecf6"
|
|
62
62
|
}
|