@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 +8 -1
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +4 -4
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +23 -11
- package/dist/extension/attribution.d.ts +1 -1
- package/dist/extension/attribution.js +1 -1
- package/dist/extension/index.js +1 -2
- package/dist/extension/permission/approvedPrefixes.d.ts +3 -10
- package/dist/extension/permission/approvedPrefixes.js +42 -69
- package/dist/extension/permission/gate.js +3 -2
- package/dist/extension/permission/prefixExtract.d.ts +0 -10
- package/dist/extension/permission/prefixExtract.js +0 -7
- package/dist/extension/pipeline/runner.js +5 -1
- package/dist/extension/recordDecisionTool.js +2 -3
- package/dist/extension/systemCa.d.ts +5 -0
- package/dist/extension/systemCa.js +10 -0
- package/dist/tlsTrust.d.ts +4 -0
- package/dist/tlsTrust.js +11 -1
- package/package.json +2 -2
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
|
|
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 =
|
|
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
|
|
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:
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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 = "
|
|
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 = "
|
|
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,
|
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
//
|
|
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
|
|
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; //
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
package/dist/tlsTrust.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "696c88e118688cb45dca88c0b821ff90e1db2c4c"
|
|
62
62
|
}
|