@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 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
 
@@ -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 once
755
- // at startup (grants added by other concurrent sessions appear next launch —
756
- // the startup load is the trust boundary; live reload was reviewed and
757
- // rejected as a same-session self-authorization path, PR #1698).
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
- /** Load persisted grants. Malformed or missing file → empty (fail-soft). */
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
- /** Load persisted grants. Malformed or missing file empty (fail-soft). */
991
- export function loadGrants(homeOverride = null) {
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
- return parsed.grants.filter((g) => typeof g?.repoKey === "string" &&
1000
- typeof g?.addedAt === "string" &&
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
- return [];
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
- return { block: false, classify: "prompt", classifyJustification: classification.justification };
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 { block: false, classify: "prompt", classifyJustification: classification.justification };
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
- * human ask (with remember/editable-prefix options) headless fail-closed.
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
- try {
1731
- classifyAllowForAutoSandbox =
1732
- classifyCommand(command, execPolicy).decision === "allow";
1733
- }
1734
- catch {
1735
- classifyAllowForAutoSandbox = false; // classifier crash → dialog (fail closed)
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 (read-only; ask if a sandbox restriction blocks it).", "info");
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(`Run outside of the sandbox\n$ ${boundedCommand(command)}`, options, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
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(`Run outside of the sandbox\n$ ${boundedCommand(command)}`, [ASK_YES_ESC, ...(rememberLabel ? [rememberLabel] : []), ASK_NO_ESC], { ...(ctx.signal ? { signal: ctx.signal } : {}) });
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(`Run outside of the sandbox\n$ ${boundedCommand(command)}`, [ASK_YES_ESC, ...(rememberLabel ? [rememberLabel] : []), ASK_NO_ESC], { ...(ctx.signal ? { signal: ctx.signal } : {}) });
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: "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.",
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. This will prompt the user for permission. " +
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.1422.1",
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": "708eff198d4e1268db1a212ebcd03bf9434bff88"
61
+ "yagniSourceSha": "bd7d06bc8fc95da1e22d6b06146cf7a0ea20ad78"
62
62
  }