@yagni-app/code-staging 1.1.1-staging.1355.1 → 1.1.1-staging.1358.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/permission/approvedPrefixes.d.ts +3 -1
- package/dist/extension/permission/approvedPrefixes.js +140 -21
- package/dist/extension/permission/gate.js +59 -19
- package/dist/extension/sandbox/bash.js +4 -4
- package/dist/extension/sandbox/config.d.ts +13 -2
- package/dist/extension/sandbox/config.js +49 -6
- package/dist/extension/sandbox/panel.d.ts +5 -2
- package/dist/extension/sandbox/panel.js +4 -4
- package/package.json +2 -2
|
@@ -51,6 +51,8 @@ export interface ApprovedPrefixFile {
|
|
|
51
51
|
version: 1;
|
|
52
52
|
grants: ApprovedPrefixGrant[];
|
|
53
53
|
}
|
|
54
|
+
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
55
|
+
export declare const MULTI_SUBCOMMAND_TOOLS: Set<string>;
|
|
54
56
|
/**
|
|
55
57
|
* Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
|
|
56
58
|
* line, plus the destruction family we keep fenced beyond it).
|
|
@@ -152,7 +154,7 @@ export declare function heredocPrefix(command: string): string | null;
|
|
|
152
154
|
* Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
|
|
153
155
|
* seeds are hypothetical until the user picks remember.
|
|
154
156
|
*/
|
|
155
|
-
export declare function deriveRememberSeeds(command: string,
|
|
157
|
+
export declare function deriveRememberSeeds(command: string, uncoveredSegments: readonly string[], repoKey?: string): {
|
|
156
158
|
seeds: ApprovedPrefixGrant[];
|
|
157
159
|
description: string;
|
|
158
160
|
} | null;
|
|
@@ -29,11 +29,11 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPolicy.js";
|
|
32
|
-
import { SAFE_ENV_VARS
|
|
32
|
+
import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
|
|
33
33
|
import { codeStateHome } from "../stateHome.js";
|
|
34
34
|
// --- Derivation ---
|
|
35
35
|
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
36
|
-
const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
36
|
+
export const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
37
37
|
"git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
|
|
38
38
|
]);
|
|
39
39
|
/**
|
|
@@ -211,13 +211,35 @@ export function storagePrefix(command) {
|
|
|
211
211
|
function hasGitPushRefspecDanger(tokens) {
|
|
212
212
|
return tokens.slice(2).some((t) => t.startsWith("+") || (!t.startsWith("-") && t.includes(":")));
|
|
213
213
|
}
|
|
214
|
+
/** The push shapes a grant pattern covers: a two-token [git, push] grant
|
|
215
|
+
* covers only `git push …`; a single-token [git] grant (the flag-first
|
|
216
|
+
* fallback — `git -C repo push …`, or a user-typed bare `git` in the custom
|
|
217
|
+
* field) covers EVERY subcommand, push included. The push fence must fire
|
|
218
|
+
* for both shapes or the fencing invariant is defeated by the shorter
|
|
219
|
+
* pattern. Deliberate deviation from Claude Code: their Bash(git:*) also
|
|
220
|
+
* matches `git push --force`, but their allow rules never grant UNSANDBOXED
|
|
221
|
+
* execution (permission and sandbox-wrap are orthogonal — an allowed
|
|
222
|
+
* command still runs sandboxed). Our escape-flow grants are standing
|
|
223
|
+
* unsandboxed-run authorizations, so the fence is load-bearing for us. */
|
|
224
|
+
function grantCoversPush(pattern, tokens) {
|
|
225
|
+
if (tokens[0] !== "git")
|
|
226
|
+
return false;
|
|
227
|
+
if (pattern.length >= 2)
|
|
228
|
+
return pattern[1] === "push";
|
|
229
|
+
// single-token [git]: covers a push when the COMMAND is one — any bare
|
|
230
|
+
// `push` word in token position (not a flag value), which also catches
|
|
231
|
+
// flag-first shapes (`git -C /x push --force-with-lease …`). Over-fencing
|
|
232
|
+
// in the safe direction: a benign `git push -c push=…`-style flag value
|
|
233
|
+
// reads as covered-but-fenced only for that command shape, not a grant.
|
|
234
|
+
return tokens.some((t) => t === "push");
|
|
235
|
+
}
|
|
214
236
|
/**
|
|
215
237
|
* Flags that must never ride a grant even though the exec policy leaves them
|
|
216
238
|
* in the prompt band (e.g. --force-with-lease is Guardian-reviewable but a
|
|
217
239
|
* standing grant for it would be a silent force-push license).
|
|
218
240
|
*/
|
|
219
241
|
function hasGrantFencedFlag(pattern, tokens) {
|
|
220
|
-
if (pattern
|
|
242
|
+
if (grantCoversPush(pattern, tokens)) {
|
|
221
243
|
return tokens.some((t) => t.startsWith("--force") || t === "-f");
|
|
222
244
|
}
|
|
223
245
|
return false;
|
|
@@ -341,7 +363,7 @@ export function matchesGrant(command, grants, repoKey) {
|
|
|
341
363
|
continue;
|
|
342
364
|
if (hasGrantFencedFlag(grant.pattern, tokens))
|
|
343
365
|
continue;
|
|
344
|
-
if (grant.pattern
|
|
366
|
+
if (grantCoversPush(grant.pattern, tokens) && hasGitPushRefspecDanger(tokens))
|
|
345
367
|
continue;
|
|
346
368
|
return grant;
|
|
347
369
|
}
|
|
@@ -412,6 +434,17 @@ export function heredocPrefix(command) {
|
|
|
412
434
|
}
|
|
413
435
|
return prefix.length > 0 ? prefix : null;
|
|
414
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Self-match proof for a literal seed: does the seed, as a grant, cover the
|
|
439
|
+
* very command it was derived from? The single gate every literal rung
|
|
440
|
+
* shares — a seed that fails it would hand the user a "don't ask again"
|
|
441
|
+
* option that never works (the dead-rule bug: the multiline -Atc "\"SQL class
|
|
442
|
+
* matched nothing, ever, and re-asked every invocation). Pure.
|
|
443
|
+
*/
|
|
444
|
+
function literalSeedSelfMatches(command, literal) {
|
|
445
|
+
const grant = { pattern: [], literal, repoKey: "", addedAt: "", cwd: "" };
|
|
446
|
+
return matchesGrant(command, [grant], "") !== null;
|
|
447
|
+
}
|
|
415
448
|
/**
|
|
416
449
|
* The FULL remember ladder for one ask, in order (all five rungs — a shape
|
|
417
450
|
* never asks twice):
|
|
@@ -424,18 +457,27 @@ export function heredocPrefix(command) {
|
|
|
424
457
|
* Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
|
|
425
458
|
* seeds are hypothetical until the user picks remember.
|
|
426
459
|
*/
|
|
427
|
-
export function deriveRememberSeeds(command,
|
|
428
|
-
|
|
460
|
+
export function deriveRememberSeeds(command, uncoveredSegments, repoKey) {
|
|
461
|
+
// Rung 1 derives and self-matches on the CANONICAL form of each raw
|
|
462
|
+
// segment (the anti-dead-rule invariant — the same normalization
|
|
463
|
+
// matchesGrant applies at match time). The raw segments arrive from
|
|
464
|
+
// evaluateCompoundForEscape's splitSubcommandsQuoted, which preserves
|
|
465
|
+
// quoting so a quoted arg body (a `;` inside `-c "…SQL…"`) reads as ONE
|
|
466
|
+
// argument; canonicalization then strips safe decorations the same way
|
|
467
|
+
// matching does, keeping the token rung reachable for exactly the plain
|
|
468
|
+
// single commands it exists for (the psql case).
|
|
469
|
+
const canonicalSegments = uncoveredSegments.map((seg) => canonicalizeForGrants(seg));
|
|
470
|
+
const derivable = canonicalSegments
|
|
429
471
|
.map((seg) => derivePrefix(seg))
|
|
430
472
|
.filter((p) => p !== null);
|
|
431
473
|
// Rung 1: every uncovered segment token-derivable AND every seed
|
|
432
474
|
// self-matches its own segment (the fencing proof: a `git push +x:y` shape
|
|
433
475
|
// derives [git, push] but the fenced command never matches it, so the
|
|
434
476
|
// remember option is never offered for a shape the grant can't cover).
|
|
435
|
-
if (derivable.length ===
|
|
477
|
+
if (derivable.length === canonicalSegments.length && derivable.length > 0) {
|
|
436
478
|
const key = repoKey ?? "";
|
|
437
479
|
const seeds = derivable.map((pattern) => ({ pattern, repoKey: key, addedAt: "", cwd: "" }));
|
|
438
|
-
const allSelfMatch =
|
|
480
|
+
const allSelfMatch = canonicalSegments.every((seg, i) => matchesGrant(seg, [seeds[i]], key) !== null);
|
|
439
481
|
if (allSelfMatch) {
|
|
440
482
|
return {
|
|
441
483
|
seeds: derivable.map((pattern) => ({ pattern, repoKey: "", addedAt: "", cwd: "" })),
|
|
@@ -445,9 +487,13 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
|
|
|
445
487
|
// A fenced segment kills the token rung — fall through to the literal
|
|
446
488
|
// rungs below (a narrower prefix may still be rememberable).
|
|
447
489
|
}
|
|
448
|
-
// Rung 2: heredoc prefix
|
|
490
|
+
// Rung 2: heredoc prefix — self-match checked (a literal seed is offered
|
|
491
|
+
// ONLY when it provably matches the very command being asked about; the
|
|
492
|
+
// heredoc rung's remainder is its heredoc tail, which the inert-remainder
|
|
493
|
+
// check admits, so this proof is structural — but it runs anyway: a
|
|
494
|
+
// shape change upstream must never resurrect a dead seed).
|
|
449
495
|
const hp = heredocPrefix(command);
|
|
450
|
-
if (hp) {
|
|
496
|
+
if (hp && literalSeedSelfMatches(command, hp)) {
|
|
451
497
|
return {
|
|
452
498
|
seeds: [{ pattern: [], literal: hp, repoKey: "", addedAt: "", cwd: "" }],
|
|
453
499
|
description: `${truncateSeedLabel(hp)} …`,
|
|
@@ -455,10 +501,15 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
|
|
|
455
501
|
}
|
|
456
502
|
// Rung 3: first line of a multiline command — but NEVER an assignment-only
|
|
457
503
|
// first line (`X=…\nreal cmd`): that literal is a dead rule (it matches the
|
|
458
|
-
// assignment, grants nothing about the real segment).
|
|
504
|
+
// assignment, grants nothing about the real segment). Self-match checked
|
|
505
|
+
// too: a first-line prefix whose remainder is NOT inert (the multiline
|
|
506
|
+
// `-Atc "` SQL body class) matches nothing, ever — offering it would hand
|
|
507
|
+
// the user a remember option that never works (the dead-rule bug).
|
|
459
508
|
if (command.includes("\n")) {
|
|
460
509
|
const firstLine = command.split("\n")[0].trim();
|
|
461
|
-
if (firstLine.length > 0 &&
|
|
510
|
+
if (firstLine.length > 0 &&
|
|
511
|
+
!/^([A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(firstLine) &&
|
|
512
|
+
literalSeedSelfMatches(command, firstLine)) {
|
|
462
513
|
return {
|
|
463
514
|
seeds: [{ pattern: [], literal: firstLine, repoKey: "", addedAt: "", cwd: "" }],
|
|
464
515
|
description: `${truncateSeedLabel(firstLine)} …`,
|
|
@@ -517,30 +568,89 @@ export function validateGrantForEscape(command, policy, repoKey) {
|
|
|
517
568
|
// seed every uncovered one — the compound remember unit. A compound whose
|
|
518
569
|
// uncovered segments are all token-derivable AND self-matching gets the
|
|
519
570
|
// multi-grant; otherwise the literal rungs apply to the FULL command.
|
|
571
|
+
// (uncovered now carries the RAW quote-preserving segments — see
|
|
572
|
+
// evaluateCompoundForEscape — so the token rung sees quoted arg bodies
|
|
573
|
+
// as ONE plain command, not a dequoted compound.)
|
|
520
574
|
const compound = evaluateCompoundForEscape(command, [], repoKey, policy);
|
|
521
575
|
if (!compound.forbidden && compound.uncovered.length > 0) {
|
|
522
576
|
const res = deriveRememberSeeds(command, compound.uncovered, repoKey);
|
|
523
577
|
if (res && res.seeds.length > 0)
|
|
524
578
|
return res;
|
|
525
579
|
}
|
|
526
|
-
|
|
527
|
-
|
|
580
|
+
// Single-command token rung — derived and self-matched against the RAW
|
|
581
|
+
// command (the quote-preserving form; the canonical form drops quoting
|
|
582
|
+
// and makes single commands look compound — the dead-literal bug).
|
|
583
|
+
const single = isSinglePlainCommand(command);
|
|
528
584
|
if (single) {
|
|
529
|
-
const pattern = derivePrefix(
|
|
585
|
+
const pattern = derivePrefix(command);
|
|
530
586
|
if (pattern) {
|
|
531
587
|
const candidate = { pattern, repoKey, addedAt: new Date().toISOString(), cwd: "" };
|
|
532
|
-
if (matchesGrant(
|
|
588
|
+
if (matchesGrant(command, [candidate], repoKey)) {
|
|
533
589
|
return { seeds: [candidate], description: describePrefix(pattern) };
|
|
534
590
|
}
|
|
535
591
|
}
|
|
536
592
|
}
|
|
537
|
-
// literal rungs (heredoc/first-line/full) —
|
|
538
|
-
//
|
|
539
|
-
|
|
593
|
+
// literal rungs (heredoc/first-line/full) — each rung self-match-proved
|
|
594
|
+
// by deriveRememberSeeds; a rung that cannot cover THIS command is not
|
|
595
|
+
// offered (null = no remember option at all).
|
|
596
|
+
return deriveRememberSeeds(command, [command]);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Quote-preserving segment splitter for the ESCAPE flow (a local, faithful
|
|
600
|
+
* counterpart to shellRules' splitSubcommands — which rejoins tokens with
|
|
601
|
+
* plain spaces and LOSES the quoting: a `;` inside `-c "…SQL…"` comes back
|
|
602
|
+
* out as a bare unquoted `;`, and every downstream pass then reads a
|
|
603
|
+
* single command as a compound — the dead-literal bug). Here, string tokens
|
|
604
|
+
* carrying shell metacharacters are re-quoted on the join so the segment
|
|
605
|
+
* string parses back to the SAME tokens; redirect-out operators keep their
|
|
606
|
+
* fd and target (2>&1, >>file) and background renders as `&`. Known lossy
|
|
607
|
+
* renderings, none grant-relevant: an input redirect renders as a bare
|
|
608
|
+
* `<` (target dropped — a construct, never a command word; the segment
|
|
609
|
+
* falls to the literal rungs, fail-closed), and tokens containing a single
|
|
610
|
+
* quote use the bash `'''` idiom our own shellParse does not
|
|
611
|
+
* implement on re-parse — the command WORD never carries one, so the
|
|
612
|
+
* derived prefix is unaffected (fail-closed at the rung if it ever did).
|
|
613
|
+
*/
|
|
614
|
+
function splitSubcommandsQuoted(command) {
|
|
615
|
+
const tokens = shellParse(command);
|
|
616
|
+
const segments = [];
|
|
617
|
+
let current = [];
|
|
618
|
+
const flush = () => {
|
|
619
|
+
if (current.length > 0) {
|
|
620
|
+
segments.push(current.join(" "));
|
|
621
|
+
current = [];
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const renderString = (t) => /[\s;|&<>'"\\$`]/.test(t) ? `'${t.replace(/'/g, `'\\''`)}'` : t;
|
|
625
|
+
const renderToken = (t) => {
|
|
626
|
+
if (typeof t === "string")
|
|
627
|
+
return renderString(t);
|
|
628
|
+
if (t.op === "redirect") {
|
|
629
|
+
if (t.direction === "out") {
|
|
630
|
+
const fd = t.fd === "stderr" ? "2" : "1";
|
|
631
|
+
return `${fd}${t.append ? ">>" : ">"}${renderString(t.target)}`;
|
|
632
|
+
}
|
|
633
|
+
return "<";
|
|
634
|
+
}
|
|
635
|
+
if (t.op === "background")
|
|
636
|
+
return "&";
|
|
637
|
+
return ""; // pipe/and/or/semi/substitution are boundaries — never rendered
|
|
638
|
+
};
|
|
639
|
+
for (const t of tokens) {
|
|
640
|
+
if (typeof t === "object" && "op" in t && (t.op === "pipe" || t.op === "and" || t.op === "or" || t.op === "semi" || t.op === "substitution")) {
|
|
641
|
+
flush();
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const rendered = renderToken(t);
|
|
645
|
+
if (rendered !== "")
|
|
646
|
+
current.push(rendered);
|
|
647
|
+
}
|
|
648
|
+
flush();
|
|
649
|
+
return segments;
|
|
540
650
|
}
|
|
541
651
|
export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
|
|
542
652
|
const isHeredoc = /<<[-~]?\s*(["']?)(\w+)\1/.test(command);
|
|
543
|
-
const rawSegments = isHeredoc ? [command] :
|
|
653
|
+
const rawSegments = isHeredoc ? [command] : splitSubcommandsQuoted(command);
|
|
544
654
|
const segments = [];
|
|
545
655
|
const uncovered = [];
|
|
546
656
|
let forbidden = false;
|
|
@@ -579,7 +689,16 @@ export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
|
|
|
579
689
|
continue;
|
|
580
690
|
}
|
|
581
691
|
segments.push({ segment: canonical, kind: "uncovered" });
|
|
582
|
-
|
|
692
|
+
// The seed ladder consumes the RAW trimmed segment, not the canonical
|
|
693
|
+
// form: canonicalizeForGrants runs the quote-aware tokenizer, and its
|
|
694
|
+
// re-join DROPS the quoting (a `;` inside `-c "…SQL…"` comes back out as
|
|
695
|
+
// a bare unquoted `;`) — derivePrefix's isSinglePlainCommand then sees a
|
|
696
|
+
// compound and kills the token rung, so flag-first single commands
|
|
697
|
+
// (psql -h … -Atc "…") fall to the dead first-line literal rung
|
|
698
|
+
// (`… -Atc "` — a rule that matches nothing, ever). The RAW segment
|
|
699
|
+
// preserves the quoting the tokenizer needs to see the command as ONE
|
|
700
|
+
// plain command and derive ["psql"].
|
|
701
|
+
uncovered.push(trimmed);
|
|
583
702
|
}
|
|
584
703
|
return { forbidden, uncovered, segments };
|
|
585
704
|
}
|
|
@@ -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 { describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
29
|
+
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
30
30
|
import { logEvent } from "../errorSink.js";
|
|
31
31
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
32
32
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
@@ -505,6 +505,28 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
505
505
|
// escape_ask_headless_blocked, escape_aborted, escape_ask_failed — policy,
|
|
506
506
|
// infra, and abort outcomes never read as a user action in the trail.
|
|
507
507
|
};
|
|
508
|
+
/** Persist a grant, fail-soft WITH a trail: the in-memory grant still
|
|
509
|
+
* applies for this session, but a silent persist failure would leave the
|
|
510
|
+
* user believing "don't ask again" survived the restart while the same
|
|
511
|
+
* dialog re-appears next session with zero trace — one warn sink line
|
|
512
|
+
* (error class only, never the thrown message: appendGrant failures can
|
|
513
|
+
* carry file paths, and the sink's content discipline is class-level)
|
|
514
|
+
* makes it observable. */
|
|
515
|
+
const persistGrantFailSoft = (grant) => {
|
|
516
|
+
try {
|
|
517
|
+
deps.persistGrant?.(grant);
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
logEvent({
|
|
521
|
+
source: "permission-rules",
|
|
522
|
+
level: "warn",
|
|
523
|
+
event: "grant_persist_failed",
|
|
524
|
+
fields: {
|
|
525
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
};
|
|
508
530
|
const emitGateEvent = (slot, event) => {
|
|
509
531
|
const mapped = GUARDIAN_OUTCOME_SOURCE[event.outcome];
|
|
510
532
|
if (mapped)
|
|
@@ -1152,12 +1174,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1152
1174
|
addedAt: new Date().toISOString(),
|
|
1153
1175
|
};
|
|
1154
1176
|
grants.push(grantRecord);
|
|
1155
|
-
|
|
1156
|
-
deps.persistGrant?.(grantRecord);
|
|
1157
|
-
}
|
|
1158
|
-
catch {
|
|
1159
|
-
// Fail-soft: the in-memory grant still applies this session.
|
|
1160
|
-
}
|
|
1177
|
+
persistGrantFailSoft(grantRecord);
|
|
1161
1178
|
emitGateEvent(slot, {
|
|
1162
1179
|
...eventBase,
|
|
1163
1180
|
outcome: "ask_approved_remembered",
|
|
@@ -1536,15 +1553,41 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1536
1553
|
}
|
|
1537
1554
|
if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
|
|
1538
1555
|
const trimmedCustom = custom.trim();
|
|
1539
|
-
// The custom
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1542
|
-
|
|
1556
|
+
// The custom field accepts two shapes:
|
|
1557
|
+
// (a) a clean command word (`npx`, `psql`) — the first-word rung of
|
|
1558
|
+
// the ladder — validated by derivePrefix's shape check and
|
|
1559
|
+
// self-matched as a TOKEN grant (covers every later `npx …` /
|
|
1560
|
+
// `psql …` regardless of arguments; Claude's `psql:*` semantics).
|
|
1561
|
+
// A bare word can never pass the literal-remainder check (the
|
|
1562
|
+
// words after it are "not inert"), which is why the field used
|
|
1563
|
+
// to reject exactly this input with a dead-rule warning. The
|
|
1564
|
+
// git-push force/refspec fence is pattern-length-aware (a
|
|
1565
|
+
// bare `git` grant covers pushes too) — see matchesGrant.
|
|
1566
|
+
// (b) anything longer — a literal string prefix, validated as
|
|
1567
|
+
// before (must cover THIS command, banned/fenced shapes
|
|
1568
|
+
// refused the same way every rung does).
|
|
1569
|
+
const tokenPattern = trimmedCustom.includes(" ") || trimmedCustom.includes("\n")
|
|
1570
|
+
? null
|
|
1571
|
+
: derivePrefix(trimmedCustom);
|
|
1572
|
+
const customGrant = tokenPattern
|
|
1573
|
+
? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
|
|
1574
|
+
: { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
|
|
1575
|
+
// Validation must be compound-aware: the escaped command is often a
|
|
1576
|
+
// compound (`S=…; curl … && echo done`), and matchesGrant alone can
|
|
1577
|
+
// never match a single token grant against a compound. A bare-word
|
|
1578
|
+
// token grant is valid when EVERY segment of the compound is covered
|
|
1579
|
+
// — by the token grant or an existing grant — exactly the ladder's
|
|
1580
|
+
// rung-1 self-match, applied to the user's custom word.
|
|
1581
|
+
const customValid = tokenPattern
|
|
1582
|
+
? (() => {
|
|
1583
|
+
const evalPolicy = deps.policy?.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
1584
|
+
const ev = evaluateCompoundForEscape(command, [...grants, customGrant], repoKey, evalPolicy);
|
|
1585
|
+
return !ev.forbidden && ev.uncovered.length === 0;
|
|
1586
|
+
})()
|
|
1587
|
+
: matchesGrant(command, [customGrant], repoKey);
|
|
1588
|
+
if (customValid) {
|
|
1543
1589
|
grants.push(customGrant);
|
|
1544
|
-
|
|
1545
|
-
deps.persistGrant?.(customGrant);
|
|
1546
|
-
}
|
|
1547
|
-
catch { /* fail-soft */ }
|
|
1590
|
+
persistGrantFailSoft(customGrant);
|
|
1548
1591
|
rememberApproved(cwd, command);
|
|
1549
1592
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
1550
1593
|
return {};
|
|
@@ -1617,10 +1660,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1617
1660
|
cwd,
|
|
1618
1661
|
};
|
|
1619
1662
|
grants.push(grantRecord);
|
|
1620
|
-
|
|
1621
|
-
deps.persistGrant?.(grantRecord);
|
|
1622
|
-
}
|
|
1623
|
-
catch { /* fail-soft: in-memory grant applies this session */ }
|
|
1663
|
+
persistGrantFailSoft(grantRecord);
|
|
1624
1664
|
}
|
|
1625
1665
|
rememberApproved(cwd, command);
|
|
1626
1666
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
@@ -299,7 +299,7 @@ export function networkDenialHint(output) {
|
|
|
299
299
|
return {
|
|
300
300
|
cls: "ipc-listen",
|
|
301
301
|
hint: "This looks like a sandbox network-posture denial (a socket bind the sandbox profile denies — test-runner IPC is the usual case). " +
|
|
302
|
-
"The user can
|
|
302
|
+
"The unix-sockets knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
}
|
|
@@ -307,7 +307,7 @@ export function networkDenialHint(output) {
|
|
|
307
307
|
return {
|
|
308
308
|
cls: "tls-trustd",
|
|
309
309
|
hint: "This looks like a sandbox TLS-verification denial (the trustd.agent mach lookup is denied — Go CLIs like gh verify certs through it even on allowlisted domains). " +
|
|
310
|
-
"The user can
|
|
310
|
+
"The trustd knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
// Loopback connect denials: EPERM/not-permitted text with a loopback
|
|
@@ -319,8 +319,8 @@ export function networkDenialHint(output) {
|
|
|
319
319
|
/(connect|curl|psql|wget|fetch|Failed to connect|Couldn't connect)/i.test(output)) {
|
|
320
320
|
return {
|
|
321
321
|
cls: "loopback",
|
|
322
|
-
hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services
|
|
323
|
-
"The user can
|
|
322
|
+
hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services need the local-binding knob). " +
|
|
323
|
+
"The local-binding knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
326
|
return null;
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
19
19
|
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
20
20
|
* — loopback bypasses the proxy via no_proxy).
|
|
21
|
+
*
|
|
22
|
+
* Engineering default (deliberate deviation from Claude Code's opt-in
|
|
23
|
+
* posture): the three network-posture knobs resolve ON when unset —
|
|
24
|
+
* allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
|
|
25
|
+
* posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
|
|
26
|
+
* path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
|
|
27
|
+
* (macOS trustd for Go TLS). An induced knob-off denial makes the model
|
|
28
|
+
* retry with dangerouslyDisableSandbox, and the escaped command runs with
|
|
29
|
+
* full user authority — strictly worse than the sandboxed posture the knob
|
|
30
|
+
* would have allowed. Explicit opt-out (false / []) at any tier still wins.
|
|
21
31
|
*/
|
|
22
32
|
import { type WorktreeGitAccess } from "./worktreeGit.js";
|
|
23
33
|
import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
@@ -68,8 +78,9 @@ export declare function readSandboxSettingsFromFile(configPath: string, warnings
|
|
|
68
78
|
/**
|
|
69
79
|
* Load + merge sandbox settings from all three config files. Scalars: local
|
|
70
80
|
* beats project beats user; arrays: union. Defaults for scalars land here
|
|
71
|
-
* too
|
|
72
|
-
*
|
|
81
|
+
* too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
|
|
82
|
+
* three network-posture knobs ON when unset (the Engineering default — see
|
|
83
|
+
* the file header; an explicit false/[] at any tier still wins).
|
|
73
84
|
*/
|
|
74
85
|
export declare function loadSandboxSettings(opts?: {
|
|
75
86
|
cwd?: string;
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
19
19
|
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
20
20
|
* — loopback bypasses the proxy via no_proxy).
|
|
21
|
+
*
|
|
22
|
+
* Engineering default (deliberate deviation from Claude Code's opt-in
|
|
23
|
+
* posture): the three network-posture knobs resolve ON when unset —
|
|
24
|
+
* allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
|
|
25
|
+
* posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
|
|
26
|
+
* path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
|
|
27
|
+
* (macOS trustd for Go TLS). An induced knob-off denial makes the model
|
|
28
|
+
* retry with dangerouslyDisableSandbox, and the escaped command runs with
|
|
29
|
+
* full user authority — strictly worse than the sandboxed posture the knob
|
|
30
|
+
* would have allowed. Explicit opt-out (false / []) at any tier still wins.
|
|
21
31
|
*/
|
|
22
32
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
33
|
import { tmpdir } from "node:os";
|
|
@@ -219,16 +229,47 @@ function union(...lists) {
|
|
|
219
229
|
return undefined;
|
|
220
230
|
return [...new Set(present.flat())];
|
|
221
231
|
}
|
|
232
|
+
/** The Engineering network-posture defaults — the single flip point for
|
|
233
|
+
* the default-on decision. If the escape-rate rationale proves wrong in
|
|
234
|
+
* the field, reverting the posture is editing THIS constant (and the
|
|
235
|
+
* enableWeakerNetworkIsolation fallback below), not the merge expressions.
|
|
236
|
+
* Platform-shaped: macOS path-scopes socket binds to the temp dirs;
|
|
237
|
+
* Linux cannot path-scope (seccomp), so its door is the broader allow-all. */
|
|
238
|
+
function ENGINEERING_NETWORK_DEFAULTS(isDarwin) {
|
|
239
|
+
return {
|
|
240
|
+
allowUnixSockets: isDarwin ? ["$TMPDIR"] : [],
|
|
241
|
+
allowAllUnixSockets: !isDarwin,
|
|
242
|
+
allowLocalBinding: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
222
245
|
function mergeNetwork(user, project, local) {
|
|
223
246
|
const u = user ?? {};
|
|
224
247
|
const p = project ?? {};
|
|
225
248
|
const l = local ?? {};
|
|
249
|
+
// Engineering default (deliberate deviation from Claude Code's opt-in
|
|
250
|
+
// posture): when NO tier sets a posture knob, it resolves ON. Rationale —
|
|
251
|
+
// a knob-off default does not buy safety: the induced OS denial makes the
|
|
252
|
+
// model retry with dangerouslyDisableSandbox, and the escaped command runs
|
|
253
|
+
// with FULL user authority (no network allowlist, no filesystem fences) —
|
|
254
|
+
// strictly worse than a sandboxed process that can bind an IPC pipe under
|
|
255
|
+
// $TMPDIR or connect to loopback. An explicit `false` / `[]` from any tier
|
|
256
|
+
// still wins: opt-out is first-class.
|
|
257
|
+
//
|
|
258
|
+
// Platform shape of the DEFAULT socket posture (mirrors the Engineering
|
|
259
|
+
// preset): macOS path-scopes binds to the temp dirs via `allowUnixSockets:
|
|
260
|
+
// ["$TMPDIR"]` with allow-all OFF (srt's allow-all would emit a broader
|
|
261
|
+
// `path-regex ^/` rule that overrides the per-path list); Linux has no
|
|
262
|
+
// path-scoped unix sockets (seccomp cannot path-filter), so its default
|
|
263
|
+
// is the broader `allowAllUnixSockets` — the known, documented cost of the
|
|
264
|
+
// Linux default (it admits /var/run/docker.sock).
|
|
265
|
+
const isDarwin = process.platform === "darwin";
|
|
266
|
+
const defaults = ENGINEERING_NETWORK_DEFAULTS(isDarwin);
|
|
226
267
|
return {
|
|
227
268
|
allowedDomains: union(u.allowedDomains, p.allowedDomains, l.allowedDomains) ?? [],
|
|
228
269
|
deniedDomains: union(u.deniedDomains, p.deniedDomains, l.deniedDomains) ?? [],
|
|
229
|
-
allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets),
|
|
230
|
-
allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets,
|
|
231
|
-
allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding,
|
|
270
|
+
allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets) ?? defaults.allowUnixSockets,
|
|
271
|
+
allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets ?? defaults.allowAllUnixSockets,
|
|
272
|
+
allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding ?? defaults.allowLocalBinding,
|
|
232
273
|
httpProxyPort: l.httpProxyPort ?? p.httpProxyPort ?? u.httpProxyPort,
|
|
233
274
|
socksProxyPort: l.socksProxyPort ?? p.socksProxyPort ?? u.socksProxyPort,
|
|
234
275
|
};
|
|
@@ -247,8 +288,9 @@ function mergeFilesystem(user, project, local) {
|
|
|
247
288
|
/**
|
|
248
289
|
* Load + merge sandbox settings from all three config files. Scalars: local
|
|
249
290
|
* beats project beats user; arrays: union. Defaults for scalars land here
|
|
250
|
-
* too
|
|
251
|
-
*
|
|
291
|
+
* too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
|
|
292
|
+
* three network-posture knobs ON when unset (the Engineering default — see
|
|
293
|
+
* the file header; an explicit false/[] at any tier still wins).
|
|
252
294
|
*/
|
|
253
295
|
export function loadSandboxSettings(opts = {}) {
|
|
254
296
|
const warnings = [];
|
|
@@ -290,7 +332,8 @@ export function loadSandboxSettings(opts = {}) {
|
|
|
290
332
|
userSettings?.enableWeakerNestedSandbox,
|
|
291
333
|
enableWeakerNetworkIsolation: localSettings?.enableWeakerNetworkIsolation ??
|
|
292
334
|
projectSettings?.enableWeakerNetworkIsolation ??
|
|
293
|
-
userSettings?.enableWeakerNetworkIsolation
|
|
335
|
+
userSettings?.enableWeakerNetworkIsolation ??
|
|
336
|
+
true,
|
|
294
337
|
};
|
|
295
338
|
return { settings, diagnostics: { warnings, unknownKeys } };
|
|
296
339
|
}
|
|
@@ -73,8 +73,11 @@ export declare function overrideOptions(current: SandboxOverrideChoice): SelectI
|
|
|
73
73
|
/** Per-mode explanation (Claude's copy, adapted to our surfaces). */
|
|
74
74
|
export declare function modeExplanation(mode: SandboxModeChoice): string;
|
|
75
75
|
export declare function overrideExplanation(choice: SandboxOverrideChoice): string;
|
|
76
|
-
/** The three knobs' resolved state, derived from settings
|
|
77
|
-
*
|
|
76
|
+
/** The three knobs' resolved state, derived from the settings this layer
|
|
77
|
+
* is given. NOTE: the panel is fed the MERGED settings (loadSandboxSettings
|
|
78
|
+
* output — the Engineering default resolves the three knobs ON when unset);
|
|
79
|
+
* a raw `{}` here would read all-off, which never happens in production
|
|
80
|
+
* wiring (session.ts builds the panel state from the merged load). */
|
|
78
81
|
export interface NetworkKnobState {
|
|
79
82
|
trustd: boolean;
|
|
80
83
|
/** macOS: the $TMPDIR entry is present in allowUnixSockets; Linux:
|
|
@@ -101,8 +101,8 @@ export function networkOptions(knobs, platform) {
|
|
|
101
101
|
const items = [
|
|
102
102
|
{
|
|
103
103
|
value: "engineering-preset",
|
|
104
|
-
label: `Engineering
|
|
105
|
-
description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — the
|
|
104
|
+
label: `Engineering defaults: tests + gh + localhost (on by default)${presetApplied(knobs, platform) ? " (applied)" : ""}`,
|
|
105
|
+
description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — ON by default for every session; applying writes the explicit form to this project's local settings",
|
|
106
106
|
},
|
|
107
107
|
];
|
|
108
108
|
if (isMac) {
|
|
@@ -148,12 +148,12 @@ export function networkExplanation(item, platform) {
|
|
|
148
148
|
: "Applies: all unix sockets (no path filtering on Linux — docker.sock included) + localhost connections. Held work (test runs, local DB clients) runs sandboxed instead of escaping.";
|
|
149
149
|
case "trustd":
|
|
150
150
|
case "trustd-off":
|
|
151
|
-
return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service —
|
|
151
|
+
return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service — on by default; turn it off here if you don't use Go CLIs.";
|
|
152
152
|
case "unix-sockets":
|
|
153
153
|
case "unix-sockets-off":
|
|
154
154
|
return platform === "darwin"
|
|
155
155
|
? "Allows binding unix sockets under $TMPDIR only — every tsx/vitest/node test-runner IPC socket lives there. Writes to $TMPDIR are already sandbox-allowed, so this stays low-risk."
|
|
156
|
-
: "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock.
|
|
156
|
+
: "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock. On by default (the Engineering default); excludedCommands can keep specific commands out of the sandbox instead.";
|
|
157
157
|
case "local-binding":
|
|
158
158
|
case "local-binding-off":
|
|
159
159
|
return "psql/redis to localhost, dev servers on :4567, and any 127.0.0.1 client. Loopback bypasses the per-domain allowlist — this is all-or-nothing for localhost.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.1-staging.
|
|
3
|
+
"version": "1.1.1-staging.1358.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": "d3f818e827670951d1a080060578f482cbdd9fbc"
|
|
62
62
|
}
|