@yagni-app/code-staging 1.1.4-staging.1422.1 → 1.1.4-staging.1424.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/README.md +13 -0
- package/dist/extension/index.js +17 -5
- package/dist/extension/permission/approvedPrefixes.d.ts +51 -1
- package/dist/extension/permission/approvedPrefixes.js +137 -20
- package/dist/extension/permission/envPrefixDiagnosis.d.ts +45 -0
- package/dist/extension/permission/envPrefixDiagnosis.js +82 -0
- package/dist/extension/permission/gate.d.ts +21 -8
- package/dist/extension/permission/gate.js +115 -17
- package/dist/extension/sandbox/session.js +8 -2
- package/dist/upgrade.js +7 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -23,6 +23,19 @@ correct, autonomous work than a coding agent that starts blank.
|
|
|
23
23
|
npm install -g @yagni-app/code
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Or use the installer, which also repairs an npm global prefix your user
|
|
27
|
+
cannot write to (common on managed machines) without sudo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# macOS / Linux
|
|
31
|
+
curl -fsSL https://yagni.app/install.sh | sh
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
# Windows (PowerShell)
|
|
36
|
+
irm https://yagni.app/install.ps1 | iex
|
|
37
|
+
```
|
|
38
|
+
|
|
26
39
|
Requires Node.js 22.19 or newer (`node --version`); an older Node stops at
|
|
27
40
|
launch with an upgrade message instead of crashing mid-session.
|
|
28
41
|
|
package/dist/extension/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
4
4
|
import { makeChildUsageState } from "./childUsage.js";
|
|
5
|
-
import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
|
|
5
|
+
import { appendGrant, createGrantsRefresher, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
|
|
6
6
|
import { redactCommand } from "./redact.js";
|
|
7
7
|
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
|
|
8
8
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
@@ -751,11 +751,22 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
751
751
|
}
|
|
752
752
|
})();
|
|
753
753
|
};
|
|
754
|
-
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
754
|
+
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded at
|
|
755
|
+
// startup PLUS refreshed at ask time (adopt-only — see refreshGrants
|
|
756
|
+
// below): grants banked by concurrent sessions are adopted at the next
|
|
757
|
+
// grant consultation, while same-session self-authorization stays closed
|
|
758
|
+
// — the OS sandbox's denyWrite band on ~/.yagni-code (pinned by test)
|
|
759
|
+
// means planting a grant still requires an approved unsandboxed write.
|
|
760
|
+
// (Supersedes the pure load-once posture from the PR #1698 review: the
|
|
761
|
+
// dead-grant re-asks it produced — 11 in the 5-day operator dataset —
|
|
762
|
+
// outweighed the same-tick write race it guarded against, and the
|
|
763
|
+
// guard it actually needed lives in the sandbox, not the load.)
|
|
758
764
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
765
|
+
// Ask-time adopt-only refresh (the dead-grant re-ask fix): the
|
|
766
|
+
// createGrantsRefresher seam (approvedPrefixes.ts — owns the stamp pair,
|
|
767
|
+
// the failure trail, and the null-baseline recovery, all unit-tested
|
|
768
|
+
// directly); the gate merges whatever it returns ADOPT-ONLY.
|
|
769
|
+
const grantsRefresher = createGrantsRefresher();
|
|
759
770
|
// Prior sandboxed failures (the auto-sandbox rung's session state): a
|
|
760
771
|
// bash tool_result whose output carries a sandbox-denial signature marks
|
|
761
772
|
// the command — its later escape attempts go to the consent dialog (the
|
|
@@ -990,6 +1001,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
990
1001
|
},
|
|
991
1002
|
grants: sessionGrants,
|
|
992
1003
|
resolveRepoKey,
|
|
1004
|
+
refreshGrants: () => (evalMode ? null : grantsRefresher()),
|
|
993
1005
|
persistGrant: (grant) => {
|
|
994
1006
|
if (!evalMode)
|
|
995
1007
|
appendGrant(grant);
|
|
@@ -307,7 +307,26 @@ export declare function rulesFilePath(homeOverride?: string | null): string;
|
|
|
307
307
|
* missing). Fail-soft — never throws.
|
|
308
308
|
*/
|
|
309
309
|
export declare function resolveRepoKey(cwd: string): string;
|
|
310
|
-
/**
|
|
310
|
+
/**
|
|
311
|
+
* loadGrants's result TRIPLE — distinguishes the three terminal states the
|
|
312
|
+
* refresher keys on: parsed grants, a PARSED-EMPTY store (version-1 file
|
|
313
|
+
* with an empty list — a legitimate bulk cleanup), and a FAILURE (missing
|
|
314
|
+
* file, unreadable, malformed). The failure carries a CAUSE — the errno
|
|
315
|
+
* code for I/O throws (a fixed OS vocabulary: ENOENT/EACCES/EISDIR…),
|
|
316
|
+
* "malformed" for content the parser or validator rejects — so the
|
|
317
|
+
* refresher's trail names the real failure instead of an undifferentiated
|
|
318
|
+
* string.
|
|
319
|
+
*/
|
|
320
|
+
export type GrantsLoad = {
|
|
321
|
+
ok: true;
|
|
322
|
+
grants: ApprovedPrefixGrant[];
|
|
323
|
+
} | {
|
|
324
|
+
ok: false;
|
|
325
|
+
cause: string;
|
|
326
|
+
};
|
|
327
|
+
export declare function loadGrantsSentinel(homeOverride?: string | null): GrantsLoad;
|
|
328
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft).
|
|
329
|
+
* Delegates to loadGrantsSentinel so the row validation is shared. */
|
|
311
330
|
export declare function loadGrants(homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
312
331
|
/**
|
|
313
332
|
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
@@ -316,4 +335,35 @@ export declare function loadGrants(homeOverride?: string | null): ApprovedPrefix
|
|
|
316
335
|
* never (fail-soft, returns the in-memory merge even if the write fails).
|
|
317
336
|
*/
|
|
318
337
|
export declare function appendGrant(grant: ApprovedPrefixGrant, homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
338
|
+
/**
|
|
339
|
+
* The ask-time adopt-only grant refresh (the dead-grant re-ask fix) — the
|
|
340
|
+
* PRODUCTION seam index.ts wires into the gate's refreshGrants dep.
|
|
341
|
+
*
|
|
342
|
+
* One consultation: stat rules.json and compare against the last-seen stamp
|
|
343
|
+
* (an mtimeMs+size pair — mtimeMs alone can miss a same-tick rewrite);
|
|
344
|
+
* return the fresh grant list ONLY when the file changed, null when it
|
|
345
|
+
* hasn't. The gate merges whatever returns ADOPT-ONLY (append; its session
|
|
346
|
+
* grants are never removed) — this side only supplies the data.
|
|
347
|
+
*
|
|
348
|
+
* Failure contracts, all test-pinned:
|
|
349
|
+
* - a PARSED-EMPTY store (version-1, empty list — a legitimate bulk
|
|
350
|
+
* cleanup) consumes the stamp: no adoption (there is nothing to
|
|
351
|
+
* adopt), but no re-read storm either, and no trail — it is a healthy
|
|
352
|
+
* state, not a failure;
|
|
353
|
+
* - a FAILED load (missing, unreadable, malformed — the fail-soft-empty
|
|
354
|
+
* class loadGrants cannot distinguish on its own) leaves one sink line
|
|
355
|
+
* (grants_refresh_failed) and does NOT consume the stamp: the next
|
|
356
|
+
* consultation retries until a real parse succeeds, so a repair adopts
|
|
357
|
+
* instead of reading as unchanged forever;
|
|
358
|
+
* - a PERSISTENTLY ABSENT store is the normal fresh-install state: when
|
|
359
|
+
* it was absent at boot too (null baseline), its stat throw is SILENT
|
|
360
|
+
* — one warn per grant check all session would drown the signal for
|
|
361
|
+
* every new user. The trail fires only when a store that EXISTED
|
|
362
|
+
* becomes unreadable, or when the throw is something other than a
|
|
363
|
+
* plain absence (ENOENT);
|
|
364
|
+
* - a NULL baseline also makes the FIRST consultation adopt whatever
|
|
365
|
+
* is on disk — recovery from a boot-time read failure without waiting
|
|
366
|
+
* for a further file change.
|
|
367
|
+
*/
|
|
368
|
+
export declare function createGrantsRefresher(homeOverride?: string | null): () => ApprovedPrefixGrant[] | null;
|
|
319
369
|
//# sourceMappingURL=approvedPrefixes.d.ts.map
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
* testable without touching the filesystem.
|
|
27
27
|
*/
|
|
28
28
|
import { execFileSync } from "node:child_process";
|
|
29
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPolicy.js";
|
|
32
32
|
import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
|
|
33
|
+
import { logEvent } from "../errorSink.js";
|
|
33
34
|
import { codeStateHome } from "../stateHome.js";
|
|
34
35
|
// --- Derivation ---
|
|
35
36
|
/**
|
|
@@ -987,32 +988,52 @@ export function resolveRepoKey(cwd) {
|
|
|
987
988
|
return cwd;
|
|
988
989
|
}
|
|
989
990
|
}
|
|
990
|
-
/**
|
|
991
|
-
|
|
991
|
+
/** loadGrants with the sentinel: parsed grants vs parsed-empty vs failure. */
|
|
992
|
+
/**
|
|
993
|
+
* The ONE row validator every loader goes through — the boot-time loader
|
|
994
|
+
* and the refresh sentinel must agree on what counts as a grant by
|
|
995
|
+
* CONSTRUCTION, never by duplicated predicates (drift between them was a
|
|
996
|
+
* review finding: a grant accepted at boot but rejected at refresh — or
|
|
997
|
+
* vice versa — silently breaks the adoption contract).
|
|
998
|
+
*/
|
|
999
|
+
function isValidGrant(g) {
|
|
1000
|
+
if (typeof g !== "object" || g === null)
|
|
1001
|
+
return false;
|
|
1002
|
+
const row = g;
|
|
1003
|
+
return (typeof row.repoKey === "string" &&
|
|
1004
|
+
typeof row.addedAt === "string" &&
|
|
1005
|
+
typeof row.cwd === "string" &&
|
|
1006
|
+
((Array.isArray(row.pattern) &&
|
|
1007
|
+
row.pattern.length > 0 &&
|
|
1008
|
+
row.pattern.every((t) => typeof t === "string") &&
|
|
1009
|
+
isDerivablePattern(row.pattern)) ||
|
|
1010
|
+
(Array.isArray(row.pattern) &&
|
|
1011
|
+
row.pattern.length === 0 &&
|
|
1012
|
+
typeof row.literal === "string" &&
|
|
1013
|
+
row.literal.length > 0)));
|
|
1014
|
+
}
|
|
1015
|
+
export function loadGrantsSentinel(homeOverride = null) {
|
|
992
1016
|
try {
|
|
993
1017
|
const path = rulesFilePath(homeOverride);
|
|
994
1018
|
if (!existsSync(path))
|
|
995
|
-
return
|
|
1019
|
+
return { ok: false, cause: "ENOENT" };
|
|
996
1020
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
997
|
-
if (parsed?.version !== 1 || !Array.isArray(parsed.grants))
|
|
998
|
-
return
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
typeof g?.cwd === "string" &&
|
|
1002
|
-
// literal rung: empty pattern + a non-empty string literal
|
|
1003
|
-
((Array.isArray(g?.pattern) &&
|
|
1004
|
-
g.pattern.length > 0 &&
|
|
1005
|
-
g.pattern.every((t) => typeof t === "string") &&
|
|
1006
|
-
isDerivablePattern(g.pattern)) ||
|
|
1007
|
-
(Array.isArray(g?.pattern) &&
|
|
1008
|
-
g.pattern.length === 0 &&
|
|
1009
|
-
typeof g?.literal === "string" &&
|
|
1010
|
-
g.literal.length > 0)));
|
|
1021
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.grants)) {
|
|
1022
|
+
return { ok: false, cause: "malformed" };
|
|
1023
|
+
}
|
|
1024
|
+
return { ok: true, grants: parsed.grants.filter(isValidGrant) };
|
|
1011
1025
|
}
|
|
1012
|
-
catch {
|
|
1013
|
-
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
const code = err instanceof Error ? err.code : undefined;
|
|
1028
|
+
return { ok: false, cause: typeof code === "string" ? code : "malformed" };
|
|
1014
1029
|
}
|
|
1015
1030
|
}
|
|
1031
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft).
|
|
1032
|
+
* Delegates to loadGrantsSentinel so the row validation is shared. */
|
|
1033
|
+
export function loadGrants(homeOverride = null) {
|
|
1034
|
+
const load = loadGrantsSentinel(homeOverride);
|
|
1035
|
+
return load.ok ? load.grants : [];
|
|
1036
|
+
}
|
|
1016
1037
|
/**
|
|
1017
1038
|
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
1018
1039
|
* The re-read is the concurrency guard — a parallel session's grant appended
|
|
@@ -1036,4 +1057,100 @@ export function appendGrant(grant, homeOverride = null) {
|
|
|
1036
1057
|
}
|
|
1037
1058
|
return merged;
|
|
1038
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The ask-time adopt-only grant refresh (the dead-grant re-ask fix) — the
|
|
1062
|
+
* PRODUCTION seam index.ts wires into the gate's refreshGrants dep.
|
|
1063
|
+
*
|
|
1064
|
+
* One consultation: stat rules.json and compare against the last-seen stamp
|
|
1065
|
+
* (an mtimeMs+size pair — mtimeMs alone can miss a same-tick rewrite);
|
|
1066
|
+
* return the fresh grant list ONLY when the file changed, null when it
|
|
1067
|
+
* hasn't. The gate merges whatever returns ADOPT-ONLY (append; its session
|
|
1068
|
+
* grants are never removed) — this side only supplies the data.
|
|
1069
|
+
*
|
|
1070
|
+
* Failure contracts, all test-pinned:
|
|
1071
|
+
* - a PARSED-EMPTY store (version-1, empty list — a legitimate bulk
|
|
1072
|
+
* cleanup) consumes the stamp: no adoption (there is nothing to
|
|
1073
|
+
* adopt), but no re-read storm either, and no trail — it is a healthy
|
|
1074
|
+
* state, not a failure;
|
|
1075
|
+
* - a FAILED load (missing, unreadable, malformed — the fail-soft-empty
|
|
1076
|
+
* class loadGrants cannot distinguish on its own) leaves one sink line
|
|
1077
|
+
* (grants_refresh_failed) and does NOT consume the stamp: the next
|
|
1078
|
+
* consultation retries until a real parse succeeds, so a repair adopts
|
|
1079
|
+
* instead of reading as unchanged forever;
|
|
1080
|
+
* - a PERSISTENTLY ABSENT store is the normal fresh-install state: when
|
|
1081
|
+
* it was absent at boot too (null baseline), its stat throw is SILENT
|
|
1082
|
+
* — one warn per grant check all session would drown the signal for
|
|
1083
|
+
* every new user. The trail fires only when a store that EXISTED
|
|
1084
|
+
* becomes unreadable, or when the throw is something other than a
|
|
1085
|
+
* plain absence (ENOENT);
|
|
1086
|
+
* - a NULL baseline also makes the FIRST consultation adopt whatever
|
|
1087
|
+
* is on disk — recovery from a boot-time read failure without waiting
|
|
1088
|
+
* for a further file change.
|
|
1089
|
+
*/
|
|
1090
|
+
export function createGrantsRefresher(homeOverride = null) {
|
|
1091
|
+
let stamp = null;
|
|
1092
|
+
try {
|
|
1093
|
+
const st = statSync(rulesFilePath(homeOverride));
|
|
1094
|
+
stamp = { mtimeMs: st.mtimeMs, size: st.size };
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
stamp = null;
|
|
1098
|
+
}
|
|
1099
|
+
return () => {
|
|
1100
|
+
try {
|
|
1101
|
+
const st = statSync(rulesFilePath(homeOverride));
|
|
1102
|
+
const current = { mtimeMs: st.mtimeMs, size: st.size };
|
|
1103
|
+
if (stamp !== null && current.mtimeMs === stamp.mtimeMs && current.size === stamp.size) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
const load = loadGrantsSentinel(homeOverride);
|
|
1107
|
+
if (load.ok) {
|
|
1108
|
+
// Parsed — a real answer, including the legitimate parsed-empty
|
|
1109
|
+
// store: consume the stamp. An empty grant list adopts nothing
|
|
1110
|
+
// (null, not []) — the gate's adopt-only merge is a no-op on it.
|
|
1111
|
+
stamp = current;
|
|
1112
|
+
return load.grants.length === 0 ? null : load.grants;
|
|
1113
|
+
}
|
|
1114
|
+
// The failed-load class: not a parse — a missing, unreadable, or
|
|
1115
|
+
// malformed store. Fail-soft (null), trail the CAUSE (the sentinel's
|
|
1116
|
+
// errno/malformed code — the same fixed vocabulary the stat catch
|
|
1117
|
+
// carries), and do NOT consume the stamp so the next consultation
|
|
1118
|
+
// retries.
|
|
1119
|
+
logEvent({
|
|
1120
|
+
source: "permission-rules",
|
|
1121
|
+
level: "warn",
|
|
1122
|
+
event: "grants_refresh_failed",
|
|
1123
|
+
fields: { error: load.cause },
|
|
1124
|
+
});
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
catch (err) {
|
|
1128
|
+
// The stat throw. ABSENCE is only newsworthy when the store previously
|
|
1129
|
+
// existed (a store that vanished mid-session) or the throw is not a
|
|
1130
|
+
// plain ENOENT — a store absent since boot is the fresh-install
|
|
1131
|
+
// normal and stays silent. After ONE vanished-store trail the stamp
|
|
1132
|
+
// is nulled: subsequent consultations take the silent fresh-install
|
|
1133
|
+
// posture (no per-check storm for the rest of the session).
|
|
1134
|
+
const isPlainAbsence = err instanceof Error && err.code === "ENOENT";
|
|
1135
|
+
if (!isPlainAbsence || stamp !== null) {
|
|
1136
|
+
logEvent({
|
|
1137
|
+
source: "permission-rules",
|
|
1138
|
+
level: "warn",
|
|
1139
|
+
event: "grants_refresh_failed",
|
|
1140
|
+
// The errno code (a fixed OS vocabulary — EACCES/EPERM/EISDIR…),
|
|
1141
|
+
// not err.constructor.name, which is "Error" for every Node fs
|
|
1142
|
+
// throw and distinguishes nothing.
|
|
1143
|
+
fields: {
|
|
1144
|
+
error: err instanceof Error && typeof err.code === "string"
|
|
1145
|
+
? err.code
|
|
1146
|
+
: "stat_or_read_failed",
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
if (isPlainAbsence)
|
|
1151
|
+
stamp = null;
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1039
1156
|
//# sourceMappingURL=approvedPrefixes.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-prefix diagnosis: the single most common SELF-INFLICTED prompt cause
|
|
3
|
+
* in operator data (14/120 out-of-sandbox dialogs in a 5-day window traced
|
|
4
|
+
* to one invented variable). An unverified env prefix
|
|
5
|
+
* (`GH_RUN_VIEW_LOG_CACHE_DISABLED=1 gh run view …`) defeats three layers
|
|
6
|
+
* at once:
|
|
7
|
+
*
|
|
8
|
+
* 1. the exec policy — the assignment is not in SAFE_ENV_VARS, so
|
|
9
|
+
* canonicalization refuses to strip it and an otherwise-allow command
|
|
10
|
+
* classifies prompt;
|
|
11
|
+
* 2. grants — the same canonicalization backs grant matching, so a
|
|
12
|
+
* banked prefix never matches the decorated shape;
|
|
13
|
+
* 3. the sandbox escape rung — where classification-gated, the
|
|
14
|
+
* non-allow verdict declines it.
|
|
15
|
+
*
|
|
16
|
+
* This module names the cause at the moment it bites: when stripping the
|
|
17
|
+
* leading env assignments flips the classification prompt→allow, the
|
|
18
|
+
* prefix IS the approval trigger — and the cure is the plain command, not
|
|
19
|
+
* a permission.
|
|
20
|
+
*
|
|
21
|
+
* PURE — no I/O, no state; mirrors the exec policy's own failure-isolated
|
|
22
|
+
* contract (a classify error reads as "no diagnosis", never a crash).
|
|
23
|
+
*/
|
|
24
|
+
import { type ExecPolicy } from "./execPolicy.js";
|
|
25
|
+
export interface EnvPrefixDiagnosis {
|
|
26
|
+
/** The leading env var names whose presence flipped the classification. */
|
|
27
|
+
vars: string[];
|
|
28
|
+
/** The command with all leading env assignments stripped. */
|
|
29
|
+
strippedCommand: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Does an UNVERIFIED env prefix flip this command from grant-coverable to
|
|
33
|
+
* uncovered? True only when ALL hold:
|
|
34
|
+
* - the command starts with env assignments (stripAllEnvVars changes it),
|
|
35
|
+
* - at least one leading var is NOT in SAFE_ENV_VARS (a safe prefix strips
|
|
36
|
+
* during grant canonicalization, so `CI=true pnpm test` still matches a
|
|
37
|
+
* `pnpm test` grant — no diagnosis warranted),
|
|
38
|
+
* - the stripped command classifies `allow` while the full command does
|
|
39
|
+
* not (the classifier's never-allow decoration rule — any env prefix
|
|
40
|
+
* floors the segment to prompt — is the flip's mechanism).
|
|
41
|
+
*/
|
|
42
|
+
export declare function envPrefixFlipsClassification(command: string, policy: ExecPolicy): EnvPrefixDiagnosis | null;
|
|
43
|
+
/** One user/model-facing sentence for the diagnosis, or null. */
|
|
44
|
+
export declare function envPrefixAdvice(d: EnvPrefixDiagnosis | null): string | null;
|
|
45
|
+
//# sourceMappingURL=envPrefixDiagnosis.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-prefix diagnosis: the single most common SELF-INFLICTED prompt cause
|
|
3
|
+
* in operator data (14/120 out-of-sandbox dialogs in a 5-day window traced
|
|
4
|
+
* to one invented variable). An unverified env prefix
|
|
5
|
+
* (`GH_RUN_VIEW_LOG_CACHE_DISABLED=1 gh run view …`) defeats three layers
|
|
6
|
+
* at once:
|
|
7
|
+
*
|
|
8
|
+
* 1. the exec policy — the assignment is not in SAFE_ENV_VARS, so
|
|
9
|
+
* canonicalization refuses to strip it and an otherwise-allow command
|
|
10
|
+
* classifies prompt;
|
|
11
|
+
* 2. grants — the same canonicalization backs grant matching, so a
|
|
12
|
+
* banked prefix never matches the decorated shape;
|
|
13
|
+
* 3. the sandbox escape rung — where classification-gated, the
|
|
14
|
+
* non-allow verdict declines it.
|
|
15
|
+
*
|
|
16
|
+
* This module names the cause at the moment it bites: when stripping the
|
|
17
|
+
* leading env assignments flips the classification prompt→allow, the
|
|
18
|
+
* prefix IS the approval trigger — and the cure is the plain command, not
|
|
19
|
+
* a permission.
|
|
20
|
+
*
|
|
21
|
+
* PURE — no I/O, no state; mirrors the exec policy's own failure-isolated
|
|
22
|
+
* contract (a classify error reads as "no diagnosis", never a crash).
|
|
23
|
+
*/
|
|
24
|
+
import { classifyCommand } from "./execPolicy.js";
|
|
25
|
+
import { SAFE_ENV_VARS, stripAllEnvVars } from "../permissionRules/shellRules.js";
|
|
26
|
+
/** Leading env assignment names, in order (used for the diagnosis text). */
|
|
27
|
+
function leadingEnvVarNames(command) {
|
|
28
|
+
const names = [];
|
|
29
|
+
let s = command.trim();
|
|
30
|
+
// Same assignment pattern family as stripAllEnvVars (quoted values with
|
|
31
|
+
// escapes, +=, array subscripts) — kept separate so the name extraction
|
|
32
|
+
// can drift from the strip without silent disagreement.
|
|
33
|
+
for (;;) {
|
|
34
|
+
const m = s.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?)\+?=(?:'[^'\n\r]*'|"(?:\\.|[^"$`\\\n\r])*"|\\.|[^ \t\n\r$`;|&()<>\\'"])*[ \t]+/);
|
|
35
|
+
if (!m)
|
|
36
|
+
return names;
|
|
37
|
+
names.push(m[1]);
|
|
38
|
+
s = s.slice(m[0].length);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Does an UNVERIFIED env prefix flip this command from grant-coverable to
|
|
43
|
+
* uncovered? True only when ALL hold:
|
|
44
|
+
* - the command starts with env assignments (stripAllEnvVars changes it),
|
|
45
|
+
* - at least one leading var is NOT in SAFE_ENV_VARS (a safe prefix strips
|
|
46
|
+
* during grant canonicalization, so `CI=true pnpm test` still matches a
|
|
47
|
+
* `pnpm test` grant — no diagnosis warranted),
|
|
48
|
+
* - the stripped command classifies `allow` while the full command does
|
|
49
|
+
* not (the classifier's never-allow decoration rule — any env prefix
|
|
50
|
+
* floors the segment to prompt — is the flip's mechanism).
|
|
51
|
+
*/
|
|
52
|
+
export function envPrefixFlipsClassification(command, policy) {
|
|
53
|
+
const raw = command.trim();
|
|
54
|
+
if (!raw)
|
|
55
|
+
return null;
|
|
56
|
+
const vars = leadingEnvVarNames(raw);
|
|
57
|
+
if (vars.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
if (vars.every((v) => SAFE_ENV_VARS.has(v)))
|
|
60
|
+
return null;
|
|
61
|
+
const stripped = stripAllEnvVars(raw);
|
|
62
|
+
if (stripped === raw)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const full = classifyCommand(raw, policy).decision;
|
|
66
|
+
const bare = classifyCommand(stripped, policy).decision;
|
|
67
|
+
if (full !== "prompt" || bare !== "allow")
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null; // a classifier error is never a diagnosis
|
|
72
|
+
}
|
|
73
|
+
return { vars, strippedCommand: stripped };
|
|
74
|
+
}
|
|
75
|
+
/** One user/model-facing sentence for the diagnosis, or null. */
|
|
76
|
+
export function envPrefixAdvice(d) {
|
|
77
|
+
if (!d)
|
|
78
|
+
return null;
|
|
79
|
+
return (`An unverified environment-variable prefix (${d.vars.join(", ")}) is the only reason this needs approval — ` +
|
|
80
|
+
`it defeats grants and auto-allow even when the underlying command is safe. Run the plain command instead: \`${d.strippedCommand.slice(0, 80)}${d.strippedCommand.length > 80 ? "…" : ""}\``);
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=envPrefixDiagnosis.js.map
|
|
@@ -122,14 +122,6 @@ export type AllowRuleFloorOutcome = {
|
|
|
122
122
|
kind: "hold";
|
|
123
123
|
};
|
|
124
124
|
export declare function allowRuleFloorVerdict(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): AllowRuleFloorOutcome;
|
|
125
|
-
/**
|
|
126
|
-
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
127
|
-
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
128
|
-
* unless a recorded decision blesses them. A dangerouslyDisableSandbox retry that
|
|
129
|
-
* will run unsandboxed is flagged BEFORE any mode/exec-policy handling — the
|
|
130
|
-
* consent flow owns it in every mode (Claude Code's default-mode contract:
|
|
131
|
-
* passthrough → ask, never a silent LLM-approved escape).
|
|
132
|
-
*/
|
|
133
125
|
export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
|
|
134
126
|
/**
|
|
135
127
|
* Terminal outcome of one prompt-band decision (YAG-510). One storage event
|
|
@@ -243,6 +235,20 @@ export interface RegisterPermissionDeps {
|
|
|
243
235
|
/** Persist a new grant (fire-and-forget; the in-memory list is updated
|
|
244
236
|
* either way). index.ts wires approvedPrefixes.appendGrant. */
|
|
245
237
|
persistGrant?: (grant: ApprovedPrefixGrant) => void;
|
|
238
|
+
/**
|
|
239
|
+
* Adopt-only grant refresh, consulted at ASK TIME (both grant sites):
|
|
240
|
+
* when the persisted store changed since session start (a sibling
|
|
241
|
+
* session banked a grant), return the fresh list — the gate merges it
|
|
242
|
+
* ADOPT-ONLY (new grants append; session grants are never removed or
|
|
243
|
+
* mutated, so a corrupted/emptied file cannot strip what the session
|
|
244
|
+
* already holds). Return null/undefined when nothing changed. Fail-soft:
|
|
245
|
+
* a throw leaves the session list untouched. The self-authoring hole a
|
|
246
|
+
* mid-session re-read could open is closed by the sandbox itself — the
|
|
247
|
+
* escape flow only runs while the OS sandbox is up, and ~/.yagni-code is
|
|
248
|
+
* in its denyWrite band, so planting a grant still requires an approved
|
|
249
|
+
* unsandboxed write (the same bar as today).
|
|
250
|
+
*/
|
|
251
|
+
refreshGrants?: () => ApprovedPrefixGrant[] | null;
|
|
246
252
|
/**
|
|
247
253
|
* persist a user-level permission rule string (e.g.
|
|
248
254
|
* `Bash(git push:*)`) into ~/.yagni-code/config.json permissions.allow.
|
|
@@ -415,6 +421,13 @@ export declare function buildModeContextMessage(mode: PermissionMode): string;
|
|
|
415
421
|
export declare function filterStaleModeContext<T>(messages: T[], currentMode?: PermissionMode): T[];
|
|
416
422
|
/** Legacy alias — the original plan-mode filter name. */
|
|
417
423
|
export declare const filterStalePlanContext: typeof filterStaleModeContext;
|
|
424
|
+
/**
|
|
425
|
+
* The widened-rung kill-switch (read once per gate registration — the same
|
|
426
|
+
* env-var contract as YAGNI_COMPOUND_PREFIX_GRANTS): truthy restores the
|
|
427
|
+
* classify=allow gate for auto-mode escapes, reverting the rung to its
|
|
428
|
+
* pre-widening read-only shape without a release.
|
|
429
|
+
*/
|
|
430
|
+
export declare function escapeClassifyGateKnob(env?: NodeJS.ProcessEnv): boolean;
|
|
418
431
|
/** The decision source of ONE tool_call (telemetry), carried per invocation
|
|
419
432
|
* because tool calls run concurrently: refined by the user dialog, hooks,
|
|
420
433
|
* and Guardian outcomes as the call moves through the gate. */
|
|
@@ -36,6 +36,7 @@ import { isDebug } from "../diagnostics.js";
|
|
|
36
36
|
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
37
37
|
import { askCustomPrefix, CUSTOM_PREFIX_CANCELLED } from "./prefixInput.js";
|
|
38
38
|
import { computeCustomPrefixUnit, describeCustomPrefixFill } from "./approvedPrefixes.js";
|
|
39
|
+
import { envPrefixAdvice, envPrefixFlipsClassification } from "./envPrefixDiagnosis.js";
|
|
39
40
|
export function createModeHolder(initial = "auto") {
|
|
40
41
|
let current = initial;
|
|
41
42
|
const listeners = new Set();
|
|
@@ -108,6 +109,17 @@ function sideEffectToolsFor(policy) {
|
|
|
108
109
|
* consent flow owns it in every mode (Claude Code's default-mode contract:
|
|
109
110
|
* passthrough → ask, never a silent LLM-approved escape).
|
|
110
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* The prompt-band justification with the env-prefix diagnosis appended
|
|
114
|
+
* when an unverified env prefix is the ONLY reason the command needs
|
|
115
|
+
* approval. PURE — wraps the diagnosis module for decideGate's three
|
|
116
|
+
* prompt-return sites so the consult prompt and storage events all see
|
|
117
|
+
* the same sentence.
|
|
118
|
+
*/
|
|
119
|
+
function withEnvPrefixDiagnosis(command, execPolicy, justification) {
|
|
120
|
+
const advice = envPrefixAdvice(envPrefixFlipsClassification(command, execPolicy));
|
|
121
|
+
return advice ? `${justification}. ${advice}` : justification;
|
|
122
|
+
}
|
|
111
123
|
export function decideGate(toolName, params, mode, policy) {
|
|
112
124
|
if (mode === "plan") {
|
|
113
125
|
// Non-bash tools in planBlockTools are held outright — they are
|
|
@@ -133,8 +145,14 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
133
145
|
// prompt — Guardian reviews. The gate handler runs the Guardian
|
|
134
146
|
// and handles allow/ask/deny. Grants and cache are skipped in
|
|
135
147
|
// plan mode (they can cover writes). Guardian unavailable/capped/
|
|
136
|
-
// disabled → block (fail closed).
|
|
137
|
-
|
|
148
|
+
// disabled → block (fail closed). The justification carries the
|
|
149
|
+
// env-prefix diagnosis when an unverified env prefix is the sole
|
|
150
|
+
// approval trigger (the most common self-inflicted cause).
|
|
151
|
+
return {
|
|
152
|
+
block: false,
|
|
153
|
+
classify: "prompt",
|
|
154
|
+
classifyJustification: withEnvPrefixDiagnosis(command, execPolicy, classification.justification),
|
|
155
|
+
};
|
|
138
156
|
}
|
|
139
157
|
catch {
|
|
140
158
|
// classifyCommand threw — fail closed in plan mode.
|
|
@@ -176,7 +194,11 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
176
194
|
// In auto mode the handler runs the Guardian; in review mode the
|
|
177
195
|
// handler runs the Guardian first, then falls back to user confirm.
|
|
178
196
|
if (mode === "auto") {
|
|
179
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
block: false,
|
|
199
|
+
classify: "prompt",
|
|
200
|
+
classifyJustification: withEnvPrefixDiagnosis(command, execPolicy, classification.justification),
|
|
201
|
+
};
|
|
180
202
|
}
|
|
181
203
|
// review mode
|
|
182
204
|
if (policy.isBlessed?.(toolName, params))
|
|
@@ -185,7 +207,7 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
185
207
|
block: false,
|
|
186
208
|
confirm: true,
|
|
187
209
|
classify: "prompt",
|
|
188
|
-
classifyJustification: classification.justification,
|
|
210
|
+
classifyJustification: withEnvPrefixDiagnosis(command, execPolicy, classification.justification),
|
|
189
211
|
};
|
|
190
212
|
}
|
|
191
213
|
catch {
|
|
@@ -297,6 +319,16 @@ export function filterStaleModeContext(messages, currentMode) {
|
|
|
297
319
|
}
|
|
298
320
|
/** Legacy alias — the original plan-mode filter name. */
|
|
299
321
|
export const filterStalePlanContext = filterStaleModeContext;
|
|
322
|
+
/**
|
|
323
|
+
* The widened-rung kill-switch (read once per gate registration — the same
|
|
324
|
+
* env-var contract as YAGNI_COMPOUND_PREFIX_GRANTS): truthy restores the
|
|
325
|
+
* classify=allow gate for auto-mode escapes, reverting the rung to its
|
|
326
|
+
* pre-widening read-only shape without a release.
|
|
327
|
+
*/
|
|
328
|
+
export function escapeClassifyGateKnob(env = process.env) {
|
|
329
|
+
const v = env.YAGNI_ESCAPE_CLASSIFY_GATE;
|
|
330
|
+
return v === "1" || v === "true";
|
|
331
|
+
}
|
|
300
332
|
const MODE_COPY = {
|
|
301
333
|
auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
|
|
302
334
|
plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
|
|
@@ -439,6 +471,42 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
439
471
|
// startup load is the trust boundary (PR #1698 review).
|
|
440
472
|
const compoundPrefixGrantsEnabled = process.env.YAGNI_COMPOUND_PREFIX_GRANTS !== "0";
|
|
441
473
|
const grants = [...(deps.grants ?? [])];
|
|
474
|
+
// Adopt-only refresh (the dead-grant re-ask fix): consulted at each GRANT
|
|
475
|
+
// consultation — when a sibling session banked a grant after this one
|
|
476
|
+
// booted, this session adopts it for the ask it was about to open, so a
|
|
477
|
+
// remembered approval actually lands everywhere. Adopt-only: new grants
|
|
478
|
+
// append; existing session grants are never removed or mutated. Fail-soft:
|
|
479
|
+
// a throw (or null) leaves the session list untouched.
|
|
480
|
+
const grantKeyOf = (g) => `${g.repoKey}\u0000${g.pattern.join(" ")}\u0000${g.literal ?? ""}`;
|
|
481
|
+
const refreshGrantsAdoptOnly = () => {
|
|
482
|
+
try {
|
|
483
|
+
const fresh = deps.refreshGrants?.();
|
|
484
|
+
if (!fresh)
|
|
485
|
+
return;
|
|
486
|
+
const known = new Set(grants.map(grantKeyOf));
|
|
487
|
+
for (const g of fresh) {
|
|
488
|
+
if (!known.has(grantKeyOf(g))) {
|
|
489
|
+
grants.push(g);
|
|
490
|
+
known.add(grantKeyOf(g));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
// Fail-soft — but never SILENT: a persistently failing refresh would
|
|
496
|
+
// resurrect the dead-grant re-ask bug with no diagnostic trail, so a
|
|
497
|
+
// thrown refresh leaves one sink line (error class only, no command
|
|
498
|
+
// content — the same discipline as every sibling gate trace).
|
|
499
|
+
try {
|
|
500
|
+
logEvent({
|
|
501
|
+
source: "permission-rules",
|
|
502
|
+
level: "warn",
|
|
503
|
+
event: "grants_refresh_failed",
|
|
504
|
+
fields: { error: err instanceof Error ? err.constructor.name : typeof err },
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch { /* telemetry must never affect the gate */ }
|
|
508
|
+
}
|
|
509
|
+
};
|
|
442
510
|
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
443
511
|
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
444
512
|
// commands running in repo B (PR #1694 review).
|
|
@@ -1098,6 +1166,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1098
1166
|
// confirm-each-command). A grant can never cover forbidden commands:
|
|
1099
1167
|
// decideGate already returned block for those.
|
|
1100
1168
|
if (modeAtEntry === "auto" && command) {
|
|
1169
|
+
refreshGrantsAdoptOnly();
|
|
1101
1170
|
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
1102
1171
|
if (grant || (compoundPrefixGrantsEnabled && matchesCompoundGrants(command, grants, resolveRepoKeyFor(cwd), effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY))) {
|
|
1103
1172
|
if (grant) {
|
|
@@ -1611,7 +1680,9 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1611
1680
|
* One flow for every mode; REPLACES the Guardian and the review/plan
|
|
1612
1681
|
* confirm machinery for this call. Order: tally → forbidden check →
|
|
1613
1682
|
* compound evaluation (grants + readonly segments) → exact-command cache →
|
|
1614
|
-
*
|
|
1683
|
+
* auto-sandbox rung (mode-scoped: AUTO accepts every non-forbidden shape;
|
|
1684
|
+
* plan/review still require an allow classification) → human ask (with
|
|
1685
|
+
* remember/editable-prefix options) → headless fail-closed.
|
|
1615
1686
|
*/
|
|
1616
1687
|
const gateSandboxEscape = async (event, ctx, slot, modeAtEntry) => {
|
|
1617
1688
|
const cmdRaw = event.input?.command;
|
|
@@ -1664,6 +1735,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1664
1735
|
// is plan-mode-skipped.
|
|
1665
1736
|
const repoKey = resolveRepoKeyFor(cwd);
|
|
1666
1737
|
const inPlanMode = modeAtEntry === "plan";
|
|
1738
|
+
if (!inPlanMode)
|
|
1739
|
+
refreshGrantsAdoptOnly();
|
|
1667
1740
|
const compound = evaluateCompoundForEscape(command, grants, repoKey, execPolicy);
|
|
1668
1741
|
if (compound.forbidden) {
|
|
1669
1742
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_forbidden_blocked", consulted: false });
|
|
@@ -1726,15 +1799,30 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1726
1799
|
}
|
|
1727
1800
|
catch { /* telemetry must never affect the gate */ }
|
|
1728
1801
|
};
|
|
1802
|
+
// The classification gate is MODE-SCOPED: auto mode runs the FIRST
|
|
1803
|
+
// escape attempt inside the sandbox whatever its shape (the rung's
|
|
1804
|
+
// strictly-less-authority argument covers write shapes too — a
|
|
1805
|
+
// sandboxed run of `mv` enforces the fs scope; the "Yes, run it"
|
|
1806
|
+
// button it replaces ran unsandboxed); plan and review keep the
|
|
1807
|
+
// classify=allow requirement (plan's no-mutation contract and
|
|
1808
|
+
// review's human-confirms-each-bash contract both outrank the rung
|
|
1809
|
+
// for mutating shapes). A classifier crash keeps the dialog (fail
|
|
1810
|
+
// closed) in every mode. The kill-switch knob (YAGNI_ESCAPE_CLASSIFY_GATE
|
|
1811
|
+
// truthy, the YAGNI_COMPOUND_PREFIX_GRANTS sibling pattern) restores the
|
|
1812
|
+
// classify=allow gate in auto mode too — a bad interaction can be
|
|
1813
|
+
// disabled without a release.
|
|
1814
|
+
const classifyGateApplies = modeAtEntry !== "auto" || escapeClassifyGateKnob();
|
|
1729
1815
|
let classifyAllowForAutoSandbox = false;
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1816
|
+
if (classifyGateApplies) {
|
|
1817
|
+
try {
|
|
1818
|
+
classifyAllowForAutoSandbox =
|
|
1819
|
+
classifyCommand(command, execPolicy).decision === "allow";
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
classifyAllowForAutoSandbox = false; // classifier crash → dialog (fail closed)
|
|
1823
|
+
}
|
|
1736
1824
|
}
|
|
1737
|
-
if (!classifyAllowForAutoSandbox)
|
|
1825
|
+
if (!classifyAllowForAutoSandbox && classifyGateApplies)
|
|
1738
1826
|
declineTrace("classify_not_allow");
|
|
1739
1827
|
else if (hasSandboxedFailure(command))
|
|
1740
1828
|
declineTrace("prior_failure");
|
|
@@ -1751,7 +1839,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1751
1839
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_auto_sandboxed", consulted: false });
|
|
1752
1840
|
if (ctx?.hasUI) {
|
|
1753
1841
|
try {
|
|
1754
|
-
ctx.ui.notify("Ran this command inside the sandbox instead of unsandboxed
|
|
1842
|
+
ctx.ui.notify("Ran this command inside the sandbox instead of unsandboxed; if a sandbox restriction blocks it, ask.", "info");
|
|
1755
1843
|
}
|
|
1756
1844
|
catch { /* notify must never block */ }
|
|
1757
1845
|
}
|
|
@@ -1833,6 +1921,13 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1833
1921
|
? `Yes, and don't ask again for \`${seeds.description}\` in this repo`
|
|
1834
1922
|
: null;
|
|
1835
1923
|
const customLabel = "Yes, and don't ask again for a custom prefix…";
|
|
1924
|
+
// The env-prefix diagnosis (self-inflicted prompt cause): when an
|
|
1925
|
+
// unverified env prefix is the ONLY reason the command needs approval,
|
|
1926
|
+
// the dialog says so up front — the cure is the plain command, and the
|
|
1927
|
+
// user reading "drop the prefix" decides faster than reading a
|
|
1928
|
+
// decorated command they must parse for risk.
|
|
1929
|
+
const envPrefixNote = envPrefixAdvice(envPrefixFlipsClassification(command, execPolicy));
|
|
1930
|
+
const escapeTitle = `Run outside of the sandbox\n$ ${boundedCommand(command)}${envPrefixNote ? `\n\n${envPrefixNote}` : ""}`;
|
|
1836
1931
|
const options = [
|
|
1837
1932
|
ASK_YES_ESC,
|
|
1838
1933
|
...(rememberLabel ? [rememberLabel] : []),
|
|
@@ -1844,7 +1939,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1844
1939
|
let choice;
|
|
1845
1940
|
let selectThrew = false;
|
|
1846
1941
|
try {
|
|
1847
|
-
choice = await ctx.ui.select(
|
|
1942
|
+
choice = await ctx.ui.select(escapeTitle, options, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
1848
1943
|
}
|
|
1849
1944
|
catch (err) {
|
|
1850
1945
|
selectThrew = true;
|
|
@@ -2001,7 +2096,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
2001
2096
|
// dialog-layer failure — retryErrored routes the resolution to
|
|
2002
2097
|
// "error" (escape_ask_failed), never dismissed/user_reject.
|
|
2003
2098
|
try {
|
|
2004
|
-
choice = await ctx.ui.select(
|
|
2099
|
+
choice = await ctx.ui.select(escapeTitle, [ASK_YES_ESC, ...(rememberLabel ? [rememberLabel] : []), ASK_NO_ESC], { ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
2005
2100
|
}
|
|
2006
2101
|
catch (err) {
|
|
2007
2102
|
retryErrored = true;
|
|
@@ -2018,7 +2113,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
2018
2113
|
else {
|
|
2019
2114
|
// cancelled input → re-offer the plain dialog (same throw handling)
|
|
2020
2115
|
try {
|
|
2021
|
-
choice = await ctx.ui.select(
|
|
2116
|
+
choice = await ctx.ui.select(escapeTitle, [ASK_YES_ESC, ...(rememberLabel ? [rememberLabel] : []), ASK_NO_ESC], { ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
2022
2117
|
}
|
|
2023
2118
|
catch (err) {
|
|
2024
2119
|
retryErrored = true;
|
|
@@ -2087,9 +2182,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
2087
2182
|
}
|
|
2088
2183
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_denied", consulted: false, prefixConsult: foldStatus() });
|
|
2089
2184
|
if (resolution === "no") {
|
|
2185
|
+
// ONE diagnosis pass (the decline reason rides the same env-prefix
|
|
2186
|
+
// note the dialog title showed — computed once, not per call site).
|
|
2187
|
+
const denyNote = envPrefixAdvice(envPrefixFlipsClassification(command, execPolicy));
|
|
2090
2188
|
return {
|
|
2091
2189
|
block: true,
|
|
2092
|
-
reason:
|
|
2190
|
+
reason: `The user declined running this command outside the sandbox. Try a sandboxed alternative — the /sandbox panel's Network tab can usually fix the restriction that caused the failure.${denyNote ? ` ${denyNote}` : ""}`,
|
|
2093
2191
|
};
|
|
2094
2192
|
}
|
|
2095
2193
|
// Unreachable defensive tail: every resolution is handled above.
|
|
@@ -108,7 +108,7 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
108
108
|
: "Default to running commands in the sandbox. Do NOT set dangerouslyDisableSandbox: true unless the user explicitly asks you to bypass the sandbox, or a specific command just failed with evidence of a sandbox restriction. " +
|
|
109
109
|
"Evidence of sandbox-caused failures includes: \"Operation not permitted\" errors for file/network operations, access denied to paths outside allowed directories, network connection failures to non-allowlisted hosts, unix-socket connection errors, and TLS certificate verification failures (x509: OSStatus -26276, tls: failed to verify certificate). " +
|
|
110
110
|
"Commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, ordinary network issues) — only retry unsandboxed on that evidence. " +
|
|
111
|
-
"When you see it: retry the SAME command unchanged, adding only dangerouslyDisableSandbox: true, briefly explain what restriction likely caused the failure, and mention that the user can manage restrictions with /sandbox.
|
|
111
|
+
"When you see it: retry the SAME command unchanged, adding only dangerouslyDisableSandbox: true, briefly explain what restriction likely caused the failure, and mention that the user can manage restrictions with /sandbox. In auto mode the retry first runs inside the sandbox again (no prompt); you are only asked for permission when the sandbox genuinely cannot run the command. " +
|
|
112
112
|
"Treat each command you execute with dangerouslyDisableSandbox: true individually. Even if you have recently run a command with this setting, you should default to running future commands within the sandbox. " +
|
|
113
113
|
"Do not suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the sandbox allowlist.";
|
|
114
114
|
// Heredoc caveat (verified live): macOS bash 3.2 writes heredoc temp
|
|
@@ -122,9 +122,15 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
122
122
|
// directory takes priority for temporary files — stated here so the two
|
|
123
123
|
// guidance lines never read as mutually exclusive always-rules.
|
|
124
124
|
const tmpdirNote = "For temporary files, use the $TMPDIR environment variable unless your system prompt names a scratchpad directory — the scratchpad takes priority for temporary files. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use /tmp directly - use $TMPDIR instead.";
|
|
125
|
+
// The env-prefix caveat: an unverified env prefix is the single most
|
|
126
|
+
// common self-inflicted prompt cause in operator data (14/120 dialogs in
|
|
127
|
+
// the 5-day window traced to one invented variable) — it defeats grants,
|
|
128
|
+
// auto-allow, and the escape rung at once. Stated here so the model
|
|
129
|
+
// never mints one.
|
|
130
|
+
const envPrefixNote = "Do not prepend environment variables you have not verified exist (grep the tool's binary/docs/help first) — an unverified prefix defeats grants, auto-allow, and the sandbox retry rung; write the plain command instead.";
|
|
125
131
|
const wrapped = {
|
|
126
132
|
...def,
|
|
127
|
-
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}`,
|
|
133
|
+
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}\n${envPrefixNote}`,
|
|
128
134
|
parameters: schema,
|
|
129
135
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
130
136
|
const input = params;
|
package/dist/upgrade.js
CHANGED
|
@@ -271,7 +271,13 @@ function failureHint(method) {
|
|
|
271
271
|
return `Try \`brew update\` and \`brew reinstall ${BREW_FORMULA}\`, or \`brew doctor\` if that fails too.`;
|
|
272
272
|
}
|
|
273
273
|
// Never suggest sudo: on managed machines the fix is a user-writable
|
|
274
|
-
// prefix, which the installer sets up automatically.
|
|
274
|
+
// prefix, which the installer sets up automatically. Windows has no `sh`,
|
|
275
|
+
// so it gets the PowerShell installer instead of a command that fails with
|
|
276
|
+
// "'sh' is not recognized".
|
|
277
|
+
if (process.platform === "win32") {
|
|
278
|
+
return ("If it was a permissions error, close any running yagni sessions and re-run the installer " +
|
|
279
|
+
"in PowerShell: irm https://yagni.app/install.ps1 | iex");
|
|
280
|
+
}
|
|
275
281
|
return ("If it was a permissions error, the installer repairs the global prefix without sudo: " +
|
|
276
282
|
"curl -fsSL https://yagni.app/install.sh | sh");
|
|
277
283
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.4-staging.
|
|
3
|
+
"version": "1.1.4-staging.1424.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": "bd7d06bc8fc95da1e22d6b06146cf7a0ea20ad78"
|
|
62
62
|
}
|