@yagni-app/code-staging 1.1.2-staging.1364.1 → 1.1.2-staging.1365.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
@@ -398,7 +398,7 @@ the two mechanisms in different releases:
398
398
  | `NODE_USE_SYSTEM_CA=1` | v22.19.0 and **v24.6.0** |
399
399
 
400
400
  So on Node **24.0–24.5** the variable is silently ignored while the flag works.
401
- Use `export NODE_OPTIONS=--use-system-ca` (or `setx NODE_OPTIONS --use-system-ca`)
401
+ Use `export YAGNI_SYSTEM_CA=1` (or `setx YAGNI_SYSTEM_CA 1`) for coding sessions
402
402
  on those versions — `yagni doctor` says so explicitly when it spots this.
403
403
 
404
404
  Two more things worth knowing:
@@ -419,3 +419,10 @@ OS store are additive, and you usually want both.
419
419
  Maintainers: YAGNI Code is built on the open-source `pi` coding agent, configured
420
420
  entirely through a first-party extension (no fork). The engineering details live
421
421
  in [`ARCHITECTURE.md`](./ARCHITECTURE.md).
422
+
423
+
424
+ System-CA opt-in reaches the coding process through a supported command-line flag
425
+ without modifying `NODE_OPTIONS`. Automatic retry covers launcher errors; restart
426
+ an already-running coding session with the opt-in if it encounters a TLS error.
427
+
428
+ `NODE_USE_SYSTEM_CA` must be the literal `1`: Node does not enable OS trust for `true`, `yes`, or `2`. Earlier YAGNI diagnostics incorrectly treated those values as enabled; change them to `1` and restart the terminal/session.
package/dist/cli.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * plus a configured pi spawn. Everything that makes this "YAGNI Code" lives in
13
13
  * pi-extension-yagni and the YAGNI backend.
14
14
  */
15
+ import { type OutputFormat } from "./outputFormat.js";
15
16
  import { promptKeepOrRemoveWorktree } from "./worktreeExitPrompt.js";
16
17
  /**
17
18
  * Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
@@ -43,6 +44,19 @@ export declare function seedCollapseChangelog(piAgentDir: string): void;
43
44
  * persists the user's choice, so this default is always reversible.
44
45
  */
45
46
  export declare function seedHideThinkingBlock(piAgentDir: string): boolean;
47
+ /**
48
+ * Spawn pi and await its exit, mapping to the launcher's exit code. Extracted
49
+ * so `runDefault` (cwd = current) and `runWorktreeLaunch` (cwd = worktree) share
50
+ * the exact same json/stream-json post-processing, signal forwarding, and exit
51
+ * code mapping — the two paths can never drift on the output contract.
52
+ */
53
+ export declare function spawnPiAndAwait(opts: {
54
+ argv: string[];
55
+ env: NodeJS.ProcessEnv;
56
+ remainingArgs: string[];
57
+ outputFormat: OutputFormat;
58
+ cwd?: string;
59
+ }, resolvePiCli?: () => string): Promise<number>;
46
60
  /** The structural slice of the extension's session-worktree entry (by file path). */
47
61
  interface SessionWorktreeModule {
48
62
  createOrResume: (name: string | undefined, deps: {
package/dist/cli.js CHANGED
@@ -37,7 +37,7 @@ import { installProcessCrashHandlers } from "./crashReport.js";
37
37
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
38
38
  import { maybeRefreshAtLaunch } from "./refresh.js";
39
39
  import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
40
- import { altNameRemediation, describeError, findTlsCertError, isTrustStoreFixable, retryCaEnv, retryExecArgv, shouldRetryWithSystemCa, systemCaMechanism, tlsRemediation, } from "./tlsTrust.js";
40
+ import { altNameRemediation, describeError, findTlsCertError, isTrustStoreFixable, retryCaEnv, retryExecArgv, sessionExecArgv, shouldRetryWithSystemCa, systemCaMechanism, tlsRemediation, } from "./tlsTrust.js";
41
41
  import { PAD_X } from "./padding.js";
42
42
  import { canPromptWorktreeCleanup, parseWorktreeFlag, validateWorktreeLaunchArgs, } from "./worktreeArgs.js";
43
43
  import { promptKeepOrRemoveWorktree } from "./worktreeExitPrompt.js";
@@ -304,7 +304,7 @@ async function runDefault(passthroughArgs) {
304
304
  * the exact same json/stream-json post-processing, signal forwarding, and exit
305
305
  * code mapping — the two paths can never drift on the output contract.
306
306
  */
307
- async function spawnPiAndAwait(opts) {
307
+ export async function spawnPiAndAwait(opts, resolvePiCli = resolvePiCliPath) {
308
308
  const { argv, env, remainingArgs, outputFormat, cwd } = opts;
309
309
  // For json/stream-json output, inject --mode json so pi emits NDJSON events.
310
310
  const userChoseMode = remainingArgs.some((a) => a === "--mode" || a.startsWith("--mode="));
@@ -316,10 +316,10 @@ async function spawnPiAndAwait(opts) {
316
316
  const stdio = outputFormat === "json"
317
317
  ? ["inherit", "pipe", "inherit"]
318
318
  : "inherit";
319
- const piCli = resolvePiCliPath();
319
+ const piCli = resolvePiCli();
320
320
  const startMs = Date.now();
321
321
  return await new Promise((resolve) => {
322
- const child = spawn(process.execPath, [piCli, ...childArgv], {
322
+ const child = spawn(process.execPath, [...sessionExecArgv(env), piCli, ...childArgv], {
323
323
  stdio: stdio,
324
324
  env,
325
325
  ...(cwd ? { cwd } : {}),
package/dist/doctor.d.ts CHANGED
@@ -63,6 +63,7 @@ export interface CaTrustProbe {
63
63
  systemCaFlag: boolean;
64
64
  /** Whether this Node honours the variable at all (v22.19+ / v24.6+). */
65
65
  systemCaEnvHonoured: boolean;
66
+ systemCaFlagAvailable?: boolean;
66
67
  /** The running Node version, for the "your Node ignores it" message. */
67
68
  nodeVersion: string;
68
69
  /**
@@ -188,7 +189,9 @@ export interface DoctorDeps {
188
189
  }
189
190
  export declare function defaultProbeBackend(baseUrl: string, token: string): Promise<BackendProbe>;
190
191
  /** Read the process's CA configuration, checking the extra bundle really loads. */
191
- export declare function defaultProbeCaTrust(env?: NodeJS.ProcessEnv, nodeVersion?: string): CaTrustProbe;
192
+ export declare function defaultProbeCaTrust(env?: NodeJS.ProcessEnv, nodeVersion?: string, execArgv?: readonly string[], allowedFlags?: {
193
+ has(flag: string): boolean;
194
+ }): CaTrustProbe;
192
195
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
193
196
  export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
194
197
  /**
package/dist/doctor.js CHANGED
@@ -22,7 +22,7 @@ import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTel
22
22
  import { readActiveProfile } from "./profiles.js";
23
23
  import { resolveMcpConfigPath } from "./mcpCommand.js";
24
24
  import { MIN_NODE_VERSION, nodeVersionSatisfies } from "./nodeVersion.js";
25
- import { EXTRA_CA_ENV, SYSTEM_CA_ENV, SYSTEM_CA_FLAG, childCaEnv, findTlsCertError, nodeOptionsHaveSystemCa, systemCaEnabled, systemCaEnvSupported, } from "./tlsTrust.js";
25
+ import { EXTRA_CA_ENV, SYSTEM_CA_ENV, SYSTEM_CA_FLAG, childCaEnv, sessionExecArgv, findTlsCertError, nodeOptionsHaveSystemCa, systemCaEnabled, systemCaEnvSupported, systemCaFlagSupported, } from "./tlsTrust.js";
26
26
  // ── Pure check builders ─────────────────────────────────────────────────────
27
27
  /**
28
28
  * The Node floor, first in the list because every other check is moot
@@ -129,11 +129,9 @@ export function checkBackend(probe) {
129
129
  name: "backend",
130
130
  status: "fail",
131
131
  detail: `TLS certificate verification failed (${probe.code})`,
132
- hint: `a TLS-inspecting proxy (Cloudflare WARP, Zscaler, Netskope) is re-signing HTTPS — ` +
133
- (systemCaEnvSupported()
134
- ? `set ${SYSTEM_CA_ENV}=1 to trust your OS certificate store`
135
- : `set NODE_OPTIONS=${SYSTEM_CA_FLAG} to trust your OS certificate store ` +
136
- `(this Node ignores ${SYSTEM_CA_ENV})`),
132
+ hint: probe.code === "ERR_TLS_CERT_ALTNAME_INVALID"
133
+ ? "the certificate hostname does not match the backend URL — check the URL, DNS, and proxy configuration; adding CAs cannot fix a hostname mismatch"
134
+ : "a TLS-inspecting proxy (Cloudflare WARP, Zscaler, Netskope) may be re-signing HTTPS — " + caRemediation(process.versions.node, systemCaFlagSupported()),
137
135
  required: true,
138
136
  };
139
137
  }
@@ -218,7 +216,7 @@ export function checkCaTrust(probe) {
218
216
  name: "ca trust",
219
217
  status: "warn",
220
218
  detail: `${SYSTEM_CA_ENV} is set, but Node v${probe.nodeVersion} ignores it`,
221
- hint: `that variable needs Node v24.6.0+ or v22.19.0+ — use NODE_OPTIONS=${SYSTEM_CA_FLAG} instead, which this Node accepts`,
219
+ hint: caRemediation(probe.nodeVersion, probe.systemCaFlagAvailable),
222
220
  required: false,
223
221
  };
224
222
  }
@@ -227,7 +225,7 @@ export function checkCaTrust(probe) {
227
225
  name: "ca trust",
228
226
  status: "warn",
229
227
  detail: `${EXTRA_CA_ENV} points at ${probe.extraCertsPath}, which can't be read`,
230
- hint: `Node ignores an unreadable bundle with only a warning — fix the path, or use ${SYSTEM_CA_ENV}=1 instead`,
228
+ hint: `Node ignores an unreadable bundle with only a warning — fix the path, or ${caRemediation(probe.nodeVersion, probe.systemCaFlagAvailable)}`,
231
229
  required: false,
232
230
  };
233
231
  }
@@ -553,14 +551,19 @@ export async function defaultProbeBackend(baseUrl, token) {
553
551
  }
554
552
  }
555
553
  /** Read the process's CA configuration, checking the extra bundle really loads. */
556
- export function defaultProbeCaTrust(env = process.env, nodeVersion = process.versions.node) {
554
+ export function defaultProbeCaTrust(env = process.env, nodeVersion = process.versions.node, execArgv = process.execArgv, allowedFlags = process.allowedNodeEnvironmentFlags) {
555
+ const processHasSystemCa = nodeOptionsHaveSystemCa(env) || execArgv.includes(SYSTEM_CA_FLAG) ||
556
+ (systemCaEnabled(env) && systemCaEnvSupported(nodeVersion));
557
+ const sessionAddsFlag = sessionExecArgv(env, execArgv, allowedFlags).length > 0;
558
+ const sessionAddsEnv = systemCaEnvSupported(nodeVersion) && Object.keys(childCaEnv(env)).length > 0;
557
559
  const base = {
558
560
  systemCa: systemCaEnabled(env),
559
- systemCaFlag: nodeOptionsHaveSystemCa(env),
561
+ systemCaFlag: nodeOptionsHaveSystemCa(env) || execArgv.includes(SYSTEM_CA_FLAG),
560
562
  systemCaEnvHonoured: systemCaEnvSupported(nodeVersion),
563
+ systemCaFlagAvailable: systemCaFlagSupported(allowedFlags),
561
564
  nodeVersion,
562
565
  // Exactly what buildLaunch will decide for the session, asked the same way.
563
- launchAddsSystemCa: Object.keys(childCaEnv(env)).length > 0,
566
+ launchAddsSystemCa: !processHasSystemCa && (sessionAddsFlag || sessionAddsEnv),
564
567
  };
565
568
  const extraCertsPath = env[EXTRA_CA_ENV]?.trim();
566
569
  if (!extraCertsPath)
@@ -686,4 +689,13 @@ export async function runDoctor(deps = {}) {
686
689
  log(formatDoctorReport(report));
687
690
  return report.exitCode;
688
691
  }
692
+ function caRemediation(nodeVersion, flagAvailable) {
693
+ if (systemCaEnvSupported(nodeVersion))
694
+ return `set ${SYSTEM_CA_ENV}=1 to trust your OS certificate store`;
695
+ const [major, minor] = nodeVersion.replace(/^v/, "").split(".").map(Number);
696
+ if (flagAvailable ?? (major >= 24 || (major === 23 && minor >= 8) || (major === 22 && minor >= 19))) {
697
+ return `set YAGNI_SYSTEM_CA=1 for coding sessions, or run node ${SYSTEM_CA_FLAG} with the YAGNI entry point; this Node ignores ${SYSTEM_CA_ENV}`;
698
+ }
699
+ return `upgrade to Node v24.6.0+ or provide an IT-approved PEM bundle through ${EXTRA_CA_ENV}`;
700
+ }
689
701
  //# sourceMappingURL=doctor.js.map
@@ -43,7 +43,7 @@ export interface AttributionSettings {
43
43
  pr: string;
44
44
  }
45
45
  export declare const DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: YAGNI Code <code@yagni.app>";
46
- export declare const DEFAULT_PR_ATTRIBUTION = "Generated with [YAGNI Code](https://yagni.app/code)";
46
+ export declare const DEFAULT_PR_ATTRIBUTION = "Co-authored with [YAGNI Code](https://yagni.app/code)";
47
47
  export declare const DEFAULT_ATTRIBUTION: AttributionSettings;
48
48
  export type AttributionSource = "claude-user" | "claude-project" | "claude-local" | "user" | "project" | "local";
49
49
  export interface AttributionLoad {
@@ -42,7 +42,7 @@ import { join } from "node:path";
42
42
  import { logEvent } from "./errorSink.js";
43
43
  import { codeStateHome } from "./stateHome.js";
44
44
  export const DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: YAGNI Code <code@yagni.app>";
45
- export const DEFAULT_PR_ATTRIBUTION = "Generated with [YAGNI Code](https://yagni.app/code)";
45
+ export const DEFAULT_PR_ATTRIBUTION = "Co-authored with [YAGNI Code](https://yagni.app/code)";
46
46
  export const DEFAULT_ATTRIBUTION = {
47
47
  commit: DEFAULT_COMMIT_ATTRIBUTION,
48
48
  pr: DEFAULT_PR_ATTRIBUTION,
@@ -678,7 +678,6 @@ export async function registerYagni(pi, deps = {}) {
678
678
  try {
679
679
  const body = {
680
680
  outcome: ev.outcome,
681
- ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
682
681
  ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
683
682
  ...(ev.tier ? { tier: ev.tier } : {}),
684
683
  };
@@ -1534,6 +1533,7 @@ export async function registerYagni(pi, deps = {}) {
1534
1533
  }
1535
1534
  }
1536
1535
  }
1536
+ resolveAttributionForSession(ctx);
1537
1537
  // Best-effort, once at session start: if the token is at or near expiry, say
1538
1538
  // so via a single notice so a long session does not silently start 401-ing
1539
1539
  // mid-flight (both completions and grounding tools). The provider's expiry
@@ -1585,7 +1585,6 @@ export async function registerYagni(pi, deps = {}) {
1585
1585
  // malformed file, or a repo tier ignored until the folder is trusted —
1586
1586
  // so the org's trailer never silently falls back to the default. Same
1587
1587
  // one-shot posture; never breaks session start.
1588
- resolveAttributionForSession(ctx);
1589
1588
  for (const warning of attributionState.load.warnings) {
1590
1589
  try {
1591
1590
  ctx.ui.notify(warning, "warning");
@@ -130,6 +130,8 @@ export declare function storagePrefix(command: string): string;
130
130
  * invariant); literal grants match with startsWith against the raw command.
131
131
  */
132
132
  export declare function matchesGrant(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string): ApprovedPrefixGrant | null;
133
+ /** Sequence grants are valid only when every unchanged-scope segment is covered. */
134
+ export declare function matchesCompoundGrants(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string, policy: ExecPolicy): boolean;
133
135
  /**
134
136
  * Grant-time validation: only offer/accept a grant when the current command
135
137
  * would actually auto-run under it — classification is "prompt" AND the
@@ -139,16 +141,7 @@ export declare function matchesGrant(command: string, grants: readonly ApprovedP
139
141
  export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
140
142
  /** Human label for the remember option: "git push …". */
141
143
  export declare function describePrefix(pattern: string[]): string;
142
- /** Claude's suggestionForExactCommand: stable prefix before a heredoc
143
- * operator — heredoc bodies change every invocation, so the exact command
144
- * would be a dead rule; the pre-<< prefix is the remember unit.
145
- *
146
- * OUR refinement over Claude: when the pre-<< prefix carries an OUTPUT
147
- * REDIRECT (`cat > "$S/r1.txt" <<EOF`), the redirect TARGET varies between
148
- * invocations as much as the body does (r1.txt, r2.txt, …) — so the prefix
149
- * cuts at the redirect operator. `cat >` seeds `…\ncat`; a redirect-free
150
- * heredoc (`gh api … <<EOF`) keeps Claude's full pre-<< prefix. The
151
- * editable field is the narrowing control on top of either. */
144
+ /** Preserve the command and output target before a heredoc marker. */
152
145
  export declare function heredocPrefix(command: string): string | null;
153
146
  /**
154
147
  * The FULL remember ladder for one ask, in order (all five rungs — a shape
@@ -259,59 +259,28 @@ function hasGrantFencedFlag(pattern, tokens) {
259
259
  }
260
260
  return false;
261
261
  }
262
- /**
263
- * Is the text AFTER a literal grant's prefix inert (nothing new runs)?
264
- *
265
- * The literal rung's isSinglePlainCommand analog. A literal grant covers
266
- * commands that start with its string; whatever follows must not introduce
267
- * a NEW command. The dangerous remainders are command separators — `&&`,
268
- * `||`, `|`, `;`, newline, background `&` — and extra words (a longer
269
- * command). Allowed remainders, and ONLY these:
270
- *
271
- * - empty / whitespace (the literal IS the command)
272
- * - redirects (incl. a target — the heredoc rung cuts its seed BEFORE the
273
- * varying target, so the target legitimately rides the remainder) as
274
- * long as a heredoc marker follows and swallows the rest as data: the
275
- * scratchpad reply shape `…\ncat > \"$S/r1.txt\" <<'EOF'\nbody\nEOF`
276
- * - safe redirects without a heredoc: fd merges (`2>&1`) and /dev/null
277
- * discards — decorations that change nothing about what runs
278
- *
279
- * The check: find the first top-level heredoc marker. Before it: only
280
- * whitespace + redirect operators (no words, no command separators — a
281
- * tokenizer pass proves both). After the marker, inertness is STRICT —
282
- * shell semantics do NOT make everything after `<<` data:
283
- * - the marker must END ITS LINE: same-line text after the delimiter
284
- * word is still the command line (`… <<EOF; curl evil.com` runs curl)
285
- * - the heredoc BODY runs to the line that is exactly the closing
286
- * delimiter; any NON-EMPTY line after that closer is a new command
287
- * (`… <<'EOF'\nbody\nEOF\nrm -rf /` runs rm)
288
- * So the remainder's shape must be: [redirects] `<<` delimiter NEWLINE
289
- * body… NEWLINE delimiter NEWLINE? (end of input). No marker → the whole
290
- * remainder must be safe redirects.
291
- */
262
+ /** Literal suffixes may add safe redirects or a quoted data-only heredoc. */
292
263
  function isInertLiteralRemainder(remainder) {
293
264
  if (remainder.trim().length === 0)
294
265
  return true;
295
266
  const heredocIdx = findTopLevelHeredoc(remainder);
296
267
  const pre = heredocIdx >= 0 ? remainder.slice(0, heredocIdx) : remainder;
297
- // Before the heredoc (or the whole remainder): every token must be a
298
- // redirect operator (target-bearing ok — the rung's own shape), and
299
- // WITHOUT a heredoc the redirects must additionally be safe ones
300
- // (fd merges / /dev/null) — a bare `literal > file` is a longer command
301
- // with a write target, not a decoration.
268
+ // Output targets belong to the approved literal, never to a varying suffix.
302
269
  const parsed = shellParse(pre);
303
270
  if (!parsed.every((t) => typeof t === "object" && t.op === "redirect"))
304
271
  return false;
272
+ if (!parsed.every((t) => isSafeRedirect(t)))
273
+ return false;
305
274
  if (heredocIdx < 0)
306
- return parsed.every((t) => isSafeRedirect(t));
275
+ return true;
307
276
  // STRICT heredoc tail: from the marker to end of input, the text must be
308
277
  // exactly [ws] << [-~]? [ws] DELIM rest-of-line \n …body… \n DELIM [ws] \n?
309
278
  // — nothing on the marker's line after the delimiter word, and nothing
310
279
  // non-blank after the closing delimiter line.
311
280
  const tail = remainder.slice(heredocIdx);
312
281
  const m = tail.match(/^<<-?~?\s*(["']?)(\w+)\1[ \t]*\r?\n/);
313
- if (!m)
314
- return false; // marker not at end of line (or no newline): refuse
282
+ if (!m || !m[1])
283
+ return false; // Unquoted heredocs execute substitutions.
315
284
  const delimiter = m[2];
316
285
  const lines = tail.slice(m[0].length).split("\n");
317
286
  // The closer: the first line equal to the delimiter (possibly with
@@ -365,6 +334,8 @@ export function matchesGrant(command, grants, repoKey) {
365
334
  if (grant.literal) {
366
335
  if (grant.pattern.length === 0 && command.trim().startsWith(grant.literal)) {
367
336
  const remainder = command.trim().slice(grant.literal.length);
337
+ if (findTopLevelHeredoc(remainder) >= 0 && !heredocBodyIsData(grant.literal))
338
+ continue;
368
339
  if (isInertLiteralRemainder(remainder))
369
340
  return grant;
370
341
  }
@@ -384,6 +355,37 @@ export function matchesGrant(command, grants, repoKey) {
384
355
  }
385
356
  return null;
386
357
  }
358
+ /** Only plain cat consumes a variable heredoc as data, never as a program. */
359
+ function heredocBodyIsData(prefix) {
360
+ const parsed = shellParse(prefix);
361
+ if (parsed.some((token) => typeof token !== "string" && token.op !== "semi" && !(token.op === "redirect" && token.direction === "out")))
362
+ return false;
363
+ const segments = splitSubcommandsQuoted(prefix);
364
+ if (segments.length === 0 || segments.slice(0, -1).some((segment) => !isAssignmentOnly(segment)))
365
+ return false;
366
+ const words = shellParse(segments[segments.length - 1]).filter((token) => typeof token === "string");
367
+ return words.length === 1 && words[0] === "cat";
368
+ }
369
+ /** Sequence grants are valid only when every unchanged-scope segment is covered. */
370
+ export function matchesCompoundGrants(command, grants, repoKey, policy) {
371
+ const parsed = shellParse(command);
372
+ const operators = parsed.filter((token) => typeof token !== "string");
373
+ if (operators.length === 0 || operators.some((token) => !["and", "or", "semi"].includes(token.op)))
374
+ return false;
375
+ const segments = splitSubcommandsQuoted(command);
376
+ return segments.length > 1 && segments.every((segment) => {
377
+ const first = tokenize(canonicalizeForGrants(segment))[0];
378
+ if (["cd", "pushd", "popd", "export", "unset", "set", "shopt", "alias", "unalias", "hash", "enable", "umask", "ulimit"].includes(first))
379
+ return false;
380
+ try {
381
+ const match = matchesGrant(segment, grants, repoKey);
382
+ return classifyCommand(segment, policy).decision !== "forbidden" && Boolean(match && match.pattern.length > 0);
383
+ }
384
+ catch {
385
+ return false;
386
+ }
387
+ });
388
+ }
387
389
  /**
388
390
  * Grant-time validation: only offer/accept a grant when the current command
389
391
  * would actually auto-run under it — classification is "prompt" AND the
@@ -412,41 +414,12 @@ export function describePrefix(pattern) {
412
414
  return `${pattern.join(" ")} …`;
413
415
  }
414
416
  // --- Escape-flow derivation (the unsandboxed-retry consent ladder) ---
415
- /** Claude's suggestionForExactCommand: stable prefix before a heredoc
416
- * operator — heredoc bodies change every invocation, so the exact command
417
- * would be a dead rule; the pre-<< prefix is the remember unit.
418
- *
419
- * OUR refinement over Claude: when the pre-<< prefix carries an OUTPUT
420
- * REDIRECT (`cat > "$S/r1.txt" <<EOF`), the redirect TARGET varies between
421
- * invocations as much as the body does (r1.txt, r2.txt, …) — so the prefix
422
- * cuts at the redirect operator. `cat >` seeds `…\ncat`; a redirect-free
423
- * heredoc (`gh api … <<EOF`) keeps Claude's full pre-<< prefix. The
424
- * editable field is the narrowing control on top of either. */
417
+ /** Preserve the command and output target before a heredoc marker. */
425
418
  export function heredocPrefix(command) {
426
419
  const m = command.match(/^([\s\S]*?)<<[-~]?\s*(["']?)(\w+)\2/);
427
420
  if (!m)
428
421
  return null;
429
- let prefix = m[1].trimEnd();
430
- if (prefix.length === 0)
431
- return null;
432
- // cut at the first top-level (unquoted, uncommented) output-redirect
433
- // operator — the target varies between invocations as much as the body
434
- // does (`r1.txt`, `r2.txt`, …). Quote-aware char scan (not the tokenizer,
435
- // which normalizes quotes and loses position); the literal seed preserves
436
- // the ORIGINAL formatting because literal matching is verbatim startsWith.
437
- let inSingle = false;
438
- let inDouble = false;
439
- for (let i = 0; i < prefix.length; i++) {
440
- const ch = prefix[i];
441
- if (ch === "'" && !inDouble)
442
- inSingle = !inSingle;
443
- else if (ch === '"' && !inSingle)
444
- inDouble = !inDouble;
445
- else if (ch === ">" && !inSingle && !inDouble) {
446
- prefix = prefix.slice(0, i).trimEnd();
447
- break;
448
- }
449
- }
422
+ const prefix = m[1].trimEnd();
450
423
  return prefix.length > 0 ? prefix : null;
451
424
  }
452
425
  /**
@@ -26,7 +26,7 @@
26
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
- import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
@@ -435,6 +435,7 @@ export function registerPermissionGate(pi, deps = {}) {
435
435
  // injection) author its own grants and self-authorize within the same
436
436
  // session. New grants from concurrent sessions apply at next launch — the
437
437
  // startup load is the trust boundary (PR #1698 review).
438
+ const compoundPrefixGrantsEnabled = process.env.YAGNI_COMPOUND_PREFIX_GRANTS !== "0";
438
439
  const grants = [...(deps.grants ?? [])];
439
440
  // Keyed by cwd: a session can change working directory (cd, /go worktrees),
440
441
  // and a repoKey memoized from the first cwd would let repo-A grants match
@@ -1002,7 +1003,7 @@ export function registerPermissionGate(pi, deps = {}) {
1002
1003
  // decideGate already returned block for those.
1003
1004
  if (modeAtEntry === "auto" && command) {
1004
1005
  const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
1005
- if (grant) {
1006
+ if (grant || (compoundPrefixGrantsEnabled && matchesCompoundGrants(command, grants, resolveRepoKeyFor(cwd), effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY))) {
1006
1007
  emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
1007
1008
  return {};
1008
1009
  }
@@ -38,17 +38,10 @@ export interface PrefixConsultResult {
38
38
  /** True when the model answered command_injection_detected (the candidate
39
39
  * is null but the outcome class is injection, not a plain decline). */
40
40
  injection?: boolean;
41
- /**
42
- * Scrubbed + capped copy of the unparseable model output (malformed shape),
43
- * so the sink and Sentry can see the exact failure. Never the raw command.
44
- */
45
- rawOutput?: string;
46
41
  /** Error CLASS only (constructor name) when the consult threw — never the
47
42
  * thrown message (it can carry command content or provider payloads). */
48
43
  errorClass?: string;
49
44
  }
50
- /** Cap on the malformed-output capture (same size discipline as the Guardian). */
51
- export declare const PREFIX_RAW_OUTPUT_CAP = 1024;
52
45
  /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
53
46
  export declare const PREFIX_MODEL_TIER = "efficient";
54
47
  /**
@@ -137,8 +130,6 @@ export interface PrefixDiagnosticEvent {
137
130
  tier?: string;
138
131
  /** Debug-only: segment hash for correlation (never the segment text). */
139
132
  segmentHash?: string;
140
- /** Scrubbed + capped copy of the unparseable model output (malformed shape). */
141
- rawOutput?: string;
142
133
  /** Error CLASS only (constructor name) when the consult threw — never the
143
134
  * thrown message (it can carry command content or provider payloads). */
144
135
  errorClass?: string;
@@ -147,7 +138,6 @@ export declare function buildPrefixDiagnosticEvent(outcome: PrefixDiagnosticEven
147
138
  durationMs?: number;
148
139
  tier?: string;
149
140
  segmentHash?: string;
150
- rawOutput?: string;
151
141
  errorClass?: string;
152
142
  debug?: boolean;
153
143
  }): PrefixDiagnosticEvent;
@@ -24,10 +24,7 @@
24
24
  * is capped at "wasted a suggestion".
25
25
  */
26
26
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
27
- import { scrubSecrets } from "../pipeline/scrubSecrets.js";
28
27
  import { BANNED_PREFIXES, canonicalizeForGrants, matchesGrant, } from "./approvedPrefixes.js";
29
- /** Cap on the malformed-output capture (same size discipline as the Guardian). */
30
- export const PREFIX_RAW_OUTPUT_CAP = 1024;
31
28
  /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
32
29
  export const PREFIX_MODEL_TIER = "efficient";
33
30
  /**
@@ -176,8 +173,6 @@ export function buildPrefixDiagnosticEvent(outcome, opts) {
176
173
  ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
177
174
  ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
178
175
  };
179
- if (opts.rawOutput !== undefined)
180
- ev.rawOutput = opts.rawOutput;
181
176
  if (opts.errorClass !== undefined)
182
177
  ev.errorClass = opts.errorClass;
183
178
  if (opts.debug) {
@@ -229,7 +224,6 @@ export async function consultPrefix(segment, deps) {
229
224
  candidate: null,
230
225
  error: "malformed",
231
226
  cost,
232
- rawOutput: scrubSecrets(output).slice(0, PREFIX_RAW_OUTPUT_CAP),
233
227
  };
234
228
  }
235
229
  const verdict = validatePrefixAnswer(parsed, segment);
@@ -310,7 +304,6 @@ export async function consultPrefixMemoized(segment, deps) {
310
304
  deps.onEvent?.(buildPrefixDiagnosticEvent(outcome, {
311
305
  durationMs: Date.now() - started,
312
306
  tier: deps.modelTier ?? PREFIX_MODEL_TIER,
313
- ...(res.rawOutput ? { rawOutput: res.rawOutput } : {}),
314
307
  ...(res.errorClass ? { errorClass: res.errorClass } : {}),
315
308
  }));
316
309
  if (res.error && res.error !== "malformed") {
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
23
23
  import * as os from "node:os";
24
24
  import * as path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { sessionExecArgv } from "../systemCa.js";
26
27
  import { trackChild } from "./childRegistry.js";
27
28
  import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
28
29
  import { logEvent } from "../errorSink.js";
@@ -159,8 +160,11 @@ export async function runStage(stage, ctx, deps) {
159
160
  YAGNI_CALLER: callerLabel,
160
161
  ...(deps.attribution?.runId ? { YAGNI_RUN_ID: deps.attribution.runId } : {}),
161
162
  };
163
+ // The child uses this exact Node binary. Forward only CA trust, never
164
+ // unrelated parent loaders/inspectors or a flag in inherited NODE_OPTIONS.
165
+ const nodeArgs = [...sessionExecArgv(childEnv), ...argv];
162
166
  const exitCode = await new Promise((resolve) => {
163
- const proc = spawnFn(process.execPath, argv, {
167
+ const proc = spawnFn(process.execPath, nodeArgs, {
164
168
  cwd: deps.cwd,
165
169
  env: childEnv,
166
170
  shell: false,
@@ -108,14 +108,13 @@ export function makeRecordDecisionTool(opts) {
108
108
  const data = outcome.json;
109
109
  if (data?.deduped) {
110
110
  // The backend matched an existing active decision and inserted
111
- // nothing; surface it so the agent leans on the recorded judgment.
111
+ // nothing. A duplicate may still be unconfirmed; do not inject it as guidance.
112
112
  const existing = data.existing;
113
- const summary = existing?.decision ? ` ${existing.decision}` : "";
114
113
  return {
115
114
  content: [
116
115
  {
117
116
  type: "text",
118
- text: `An equivalent decision is already recorded; nothing new was banked.${summary}`,
117
+ text: `An equivalent decision is already recorded; nothing new was banked. Review and confirm it in Decisions before using it as guidance.`,
119
118
  },
120
119
  ],
121
120
  details: { id: existing?.id ?? null, spooled: false },
@@ -0,0 +1,5 @@
1
+ /** Kept in sync with the standalone CLI by its cross-package parity test. */
2
+ export declare function sessionExecArgv(env: NodeJS.ProcessEnv, execArgv?: readonly string[], allowedFlags?: {
3
+ has(flag: string): boolean;
4
+ }): string[];
5
+ //# sourceMappingURL=systemCa.d.ts.map
@@ -0,0 +1,10 @@
1
+ /** Kept in sync with the standalone CLI by its cross-package parity test. */
2
+ export function sessionExecArgv(env, execArgv = process.execArgv, allowedFlags = process.allowedNodeEnvironmentFlags) {
3
+ const flag = "--use-system-ca";
4
+ if (!allowedFlags.has(flag) || env.YAGNI_SYSTEM_CA === "0" || env.NODE_USE_SYSTEM_CA === "0")
5
+ return [];
6
+ if (execArgv.includes("--no-use-system-ca") || /(?:^|\s)["']?--no-use-system-ca["']?(?:\s|$)/.test(env.NODE_OPTIONS ?? ""))
7
+ return [];
8
+ return execArgv.includes(flag) || env.NODE_USE_SYSTEM_CA === "1" || env.YAGNI_SYSTEM_CA === "1" ? [flag] : [];
9
+ }
10
+ //# sourceMappingURL=systemCa.js.map
@@ -185,4 +185,8 @@ export declare function tlsRemediation(platform?: NodeJS.Platform, nodeVersion?:
185
185
  * just isn't the host we asked for, so adding CAs changes nothing.
186
186
  */
187
187
  export declare function altNameRemediation(): string[];
188
+ /** Only this same-binary child gets the flag; never widen NODE_OPTIONS. */
189
+ export declare function sessionExecArgv(env: NodeJS.ProcessEnv, execArgv?: readonly string[], allowedFlags?: {
190
+ has(flag: string): boolean;
191
+ }): string[];
188
192
  //# sourceMappingURL=tlsTrust.d.ts.map
package/dist/tlsTrust.js CHANGED
@@ -153,7 +153,7 @@ export function redactCredentials(text) {
153
153
  /** Whether the env var asks for the OS store (which some Node versions ignore). */
154
154
  export function systemCaEnabled(env) {
155
155
  const value = env[SYSTEM_CA_ENV];
156
- return value !== undefined && value !== "" && value !== "0";
156
+ return value === "1";
157
157
  }
158
158
  /**
159
159
  * Whether this Node honours `NODE_USE_SYSTEM_CA`. Added in v22.19.0 and
@@ -372,4 +372,14 @@ export function altNameRemediation() {
372
372
  "",
373
373
  ];
374
374
  }
375
+ /** Only this same-binary child gets the flag; never widen NODE_OPTIONS. */
376
+ export function sessionExecArgv(env, execArgv = process.execArgv, allowedFlags) {
377
+ if (!systemCaFlagSupported(allowedFlags) || systemCaOptedOut(env) || env[SYSTEM_CA_ENV] === "0")
378
+ return [];
379
+ if (execArgv.includes("--no-use-system-ca") || /(?:^|\s)[\"\']?--no-use-system-ca(?:[\"\']?)(?:\s|$)/.test(env.NODE_OPTIONS ?? ""))
380
+ return [];
381
+ return execArgv.includes(SYSTEM_CA_FLAG) || systemCaEnabled(env) || systemCaOptedIn(env)
382
+ ? [SYSTEM_CA_FLAG]
383
+ : [];
384
+ }
375
385
  //# sourceMappingURL=tlsTrust.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.2-staging.1364.1",
3
+ "version": "1.1.2-staging.1365.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": "2e7b08bce22637cd7dd696a547bcf0cfd3fa806b"
61
+ "yagniSourceSha": "696c88e118688cb45dca88c0b821ff90e1db2c4c"
62
62
  }