@yagni-app/code-staging 1.1.1-staging.1352.1 → 1.1.1-staging.1358.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/bin.d.ts +24 -0
- package/dist/bin.js +73 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +11 -3
- package/dist/crashReport.d.ts +7 -0
- package/dist/crashReport.js +10 -1
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +21 -0
- package/dist/extension/crashReport.d.ts +7 -0
- package/dist/extension/crashReport.js +11 -2
- package/dist/extension/permission/approvedPrefixes.d.ts +3 -1
- package/dist/extension/permission/approvedPrefixes.js +140 -21
- package/dist/extension/permission/gate.js +59 -19
- package/dist/extension/sandbox/bash.js +4 -4
- package/dist/extension/sandbox/config.d.ts +13 -2
- package/dist/extension/sandbox/config.js +49 -6
- package/dist/extension/sandbox/panel.d.ts +5 -2
- package/dist/extension/sandbox/panel.js +4 -4
- package/dist/nodeVersion.d.ts +53 -0
- package/dist/nodeVersion.js +79 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -23,6 +23,9 @@ correct, autonomous work than a coding agent that starts blank.
|
|
|
23
23
|
npm install -g @yagni-app/code
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Requires Node.js 22.19 or newer (`node --version`); an older Node stops at
|
|
27
|
+
launch with an upgrade message instead of crashing mid-session.
|
|
28
|
+
|
|
26
29
|
The command it installs is `yagni`:
|
|
27
30
|
|
|
28
31
|
```bash
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* yagni — the published bin entry.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately tiny: the only static imports are the dependency-free Node
|
|
6
|
+
* version gate and the (equally dependency-free) distribution record that
|
|
7
|
+
* names the installed channel. Everything else (`cli.js` and the module graph
|
|
8
|
+
* behind it) is loaded dynamically AFTER the check passes, so an old Node
|
|
9
|
+
* prints one clear message and exits instead of blowing up inside a
|
|
10
|
+
* dependency — or failing to parse one. `cli.js` stays directly runnable
|
|
11
|
+
* (`node dist/cli.js`) for the e2e lanes; its own entrypoint guard is false
|
|
12
|
+
* when this shim is argv[1], so the shim calls `runAsEntrypoint()` explicitly.
|
|
13
|
+
*
|
|
14
|
+
* Both failure paths set `process.exitCode` and return instead of calling
|
|
15
|
+
* `process.exit()`: stderr on a pipe is asynchronous on Windows, and an
|
|
16
|
+
* immediate exit can truncate the very diagnostic this shim exists to print.
|
|
17
|
+
* Letting the event loop drain flushes it.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral coverage lives in `test/bin.test.ts`, which spawns this file
|
|
20
|
+
* under a faked-old `process.versions.node` and against a stub launcher that
|
|
21
|
+
* fails to load.
|
|
22
|
+
*/
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=bin.d.ts.map
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* yagni — the published bin entry.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately tiny: the only static imports are the dependency-free Node
|
|
6
|
+
* version gate and the (equally dependency-free) distribution record that
|
|
7
|
+
* names the installed channel. Everything else (`cli.js` and the module graph
|
|
8
|
+
* behind it) is loaded dynamically AFTER the check passes, so an old Node
|
|
9
|
+
* prints one clear message and exits instead of blowing up inside a
|
|
10
|
+
* dependency — or failing to parse one. `cli.js` stays directly runnable
|
|
11
|
+
* (`node dist/cli.js`) for the e2e lanes; its own entrypoint guard is false
|
|
12
|
+
* when this shim is argv[1], so the shim calls `runAsEntrypoint()` explicitly.
|
|
13
|
+
*
|
|
14
|
+
* Both failure paths set `process.exitCode` and return instead of calling
|
|
15
|
+
* `process.exit()`: stderr on a pipe is asynchronous on Windows, and an
|
|
16
|
+
* immediate exit can truncate the very diagnostic this shim exists to print.
|
|
17
|
+
* Letting the event loop drain flushes it.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral coverage lives in `test/bin.test.ts`, which spawns this file
|
|
20
|
+
* under a faked-old `process.versions.node` and against a stub launcher that
|
|
21
|
+
* fails to load.
|
|
22
|
+
*/
|
|
23
|
+
import { DISTRIBUTION } from "./distribution.js";
|
|
24
|
+
import { nodeCheckSkipped, nodeVersionProblem } from "./nodeVersion.js";
|
|
25
|
+
function debugEnabled() {
|
|
26
|
+
const value = process.env.YAGNI_DEBUG;
|
|
27
|
+
return value !== undefined && value !== "" && value !== "0";
|
|
28
|
+
}
|
|
29
|
+
async function main() {
|
|
30
|
+
const problem = nodeCheckSkipped() ? null : nodeVersionProblem(process.versions.node, DISTRIBUTION);
|
|
31
|
+
if (problem !== null) {
|
|
32
|
+
process.stderr.write(`${problem}\n`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// A launcher that fails to LOAD (corrupt or partial install, a dependency
|
|
37
|
+
// missing from node_modules) would otherwise die as a raw unhandled
|
|
38
|
+
// rejection before the launcher's crash handlers exist — the same opaque
|
|
39
|
+
// failure this shim is here to prevent. No crash report is possible at this
|
|
40
|
+
// point (the reporter is part of what failed to load), so say what to do
|
|
41
|
+
// instead. The message carries the error class and message; YAGNI_DEBUG=1
|
|
42
|
+
// adds the full stack (resolution chain, parse location) for a support thread.
|
|
43
|
+
let launcher;
|
|
44
|
+
try {
|
|
45
|
+
launcher = await import("./cli.js");
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const name = err instanceof Error && err.name ? err.name : "Error";
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
const lines = [
|
|
51
|
+
`${DISTRIBUTION.displayName} failed to load: ${name}: ${message}`,
|
|
52
|
+
"",
|
|
53
|
+
"The install looks incomplete or corrupt. Reinstall it:",
|
|
54
|
+
"",
|
|
55
|
+
` npm install -g ${DISTRIBUTION.packageName}`,
|
|
56
|
+
"",
|
|
57
|
+
];
|
|
58
|
+
if (debugEnabled()) {
|
|
59
|
+
const stack = err instanceof Error && err.stack ? err.stack : "(no stack)";
|
|
60
|
+
const cause = err instanceof Error && err.cause !== undefined ? `\ncause: ${String(err.cause)}` : "";
|
|
61
|
+
lines.push(`${stack}${cause}`, "");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push("(YAGNI_DEBUG=1 prints the full stack.)", "");
|
|
65
|
+
}
|
|
66
|
+
process.stderr.write(lines.join("\n"));
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
launcher.runAsEntrypoint();
|
|
71
|
+
}
|
|
72
|
+
await main();
|
|
73
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/cli.d.ts
CHANGED
|
@@ -166,5 +166,11 @@ export declare function main(argv: string[]): Promise<number>;
|
|
|
166
166
|
* test import (argv[1] points at the test runner) does not.
|
|
167
167
|
*/
|
|
168
168
|
export declare function isEntrypoint(argv1: string | undefined, moduleUrl: string): boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Run the launcher as the process entrypoint. Called by the published bin
|
|
171
|
+
* shim (`bin.js`, after the Node version gate) and by the guard below when
|
|
172
|
+
* this module is executed directly (`node dist/cli.js`, the e2e lanes).
|
|
173
|
+
*/
|
|
174
|
+
export declare function runAsEntrypoint(): void;
|
|
169
175
|
export {};
|
|
170
176
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/cli.js
CHANGED
|
@@ -889,9 +889,12 @@ export function isEntrypoint(argv1, moduleUrl) {
|
|
|
889
889
|
};
|
|
890
890
|
return resolve(argv1) === resolve(fileURLToPath(moduleUrl));
|
|
891
891
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Run the launcher as the process entrypoint. Called by the published bin
|
|
894
|
+
* shim (`bin.js`, after the Node version gate) and by the guard below when
|
|
895
|
+
* this module is executed directly (`node dist/cli.js`, the e2e lanes).
|
|
896
|
+
*/
|
|
897
|
+
export function runAsEntrypoint() {
|
|
895
898
|
// Crash reporting for the LAUNCHER process only (pi runs as a child and the
|
|
896
899
|
// extension covers the session side). Fire-and-forget, sanitized, bounded;
|
|
897
900
|
// YAGNI_DISABLE_CRASH_REPORTS=1 turns it off. Registered before main() so a
|
|
@@ -904,4 +907,9 @@ if (isEntrypoint(process.argv[1], import.meta.url)) {
|
|
|
904
907
|
process.exit(1);
|
|
905
908
|
});
|
|
906
909
|
}
|
|
910
|
+
// Only auto-run when invoked as the CLI entry, so tests can import this module
|
|
911
|
+
// (e.g. to exercise wantsHelp) without spawning the agent.
|
|
912
|
+
if (isEntrypoint(process.argv[1], import.meta.url)) {
|
|
913
|
+
runAsEntrypoint();
|
|
914
|
+
}
|
|
907
915
|
//# sourceMappingURL=cli.js.map
|
package/dist/crashReport.d.ts
CHANGED
|
@@ -77,6 +77,13 @@ export interface SanitizedCrash {
|
|
|
77
77
|
* payloads…) are never touched.
|
|
78
78
|
*/
|
|
79
79
|
export declare function sanitizeCrashError(err: unknown, opts?: SanitizeCrashOptions): SanitizedCrash;
|
|
80
|
+
/**
|
|
81
|
+
* OS, arch AND the Node version. The runtime is a first-class crash cause
|
|
82
|
+
* (an old Node dies inside undici on `zlib.createZstdDecompress`), and it
|
|
83
|
+
* rides the existing `platform` field so the backend and its Sentry tag need
|
|
84
|
+
* no change. Mirrored in `pi-extension-yagni/src/crashReport.ts`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function platformLabel(): string;
|
|
80
87
|
export type CrashClient = "cli" | "desktop" | "desktop-driver";
|
|
81
88
|
export interface CrashReportInput {
|
|
82
89
|
client: CrashClient;
|
package/dist/crashReport.js
CHANGED
|
@@ -177,6 +177,15 @@ export function sanitizeCrashError(err, opts = {}) {
|
|
|
177
177
|
...(cappedStack !== undefined ? { stack: cappedStack } : {}),
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* OS, arch AND the Node version. The runtime is a first-class crash cause
|
|
182
|
+
* (an old Node dies inside undici on `zlib.createZstdDecompress`), and it
|
|
183
|
+
* rides the existing `platform` field so the backend and its Sentry tag need
|
|
184
|
+
* no change. Mirrored in `pi-extension-yagni/src/crashReport.ts`.
|
|
185
|
+
*/
|
|
186
|
+
export function platformLabel() {
|
|
187
|
+
return `${process.platform} ${process.arch} node ${process.version}`;
|
|
188
|
+
}
|
|
180
189
|
/**
|
|
181
190
|
* Sanitize + POST one crash report from pre-extracted fields. Resolves on
|
|
182
191
|
* every outcome — timeout, network error, non-2xx, disabled — and never
|
|
@@ -199,7 +208,7 @@ export async function sendCrashReport(input) {
|
|
|
199
208
|
const payload = {
|
|
200
209
|
client: input.client,
|
|
201
210
|
clientVersion: input.clientVersion,
|
|
202
|
-
platform:
|
|
211
|
+
platform: platformLabel(),
|
|
203
212
|
errorClass: sanitizeCrashText(input.errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
|
|
204
213
|
message: sanitizeCrashText(input.message, opts).slice(0, MAX_CRASH_MESSAGE),
|
|
205
214
|
...(stack !== undefined ? { stack } : {}),
|
package/dist/doctor.d.ts
CHANGED
|
@@ -50,6 +50,13 @@ export type BackendProbe = {
|
|
|
50
50
|
} | {
|
|
51
51
|
kind: "network";
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* The Node floor, first in the list because every other check is moot
|
|
55
|
+
* without it: an old Node dies inside pi's HTTP client mid-request (Sentry
|
|
56
|
+
* YAGNI-BACKEND-4S: `zlib.createZstdDecompress is not a function`). The bin
|
|
57
|
+
* shim refuses to launch below the floor; doctor explains it in the same terms.
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkNodeVersion(version: string): CheckResult;
|
|
53
60
|
export declare function checkPiEngine(probe: PiEngineProbe): CheckResult;
|
|
54
61
|
export declare function checkExtension(probe: ExtensionProbe): CheckResult;
|
|
55
62
|
export declare function checkProfileToken(profile: Pick<Profile, "name" | "token">): CheckResult;
|
|
@@ -115,6 +122,8 @@ export declare function buildDoctorReport(checks: CheckResult[]): DoctorReport;
|
|
|
115
122
|
export declare function formatDoctorReport(report: DoctorReport): string;
|
|
116
123
|
export interface DoctorDeps {
|
|
117
124
|
now?: () => number;
|
|
125
|
+
/** Running Node version (defaults to process.versions.node). */
|
|
126
|
+
nodeVersion?: string;
|
|
118
127
|
probePiEngine?: () => PiEngineProbe;
|
|
119
128
|
probeExtension?: () => ExtensionProbe;
|
|
120
129
|
readActiveProfile?: () => Promise<Profile>;
|
package/dist/doctor.js
CHANGED
|
@@ -21,7 +21,27 @@ import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
|
|
|
21
21
|
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
|
|
22
22
|
import { readActiveProfile } from "./profiles.js";
|
|
23
23
|
import { resolveMcpConfigPath } from "./mcpCommand.js";
|
|
24
|
+
import { MIN_NODE_VERSION, nodeVersionSatisfies } from "./nodeVersion.js";
|
|
24
25
|
// ── Pure check builders ─────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* The Node floor, first in the list because every other check is moot
|
|
28
|
+
* without it: an old Node dies inside pi's HTTP client mid-request (Sentry
|
|
29
|
+
* YAGNI-BACKEND-4S: `zlib.createZstdDecompress is not a function`). The bin
|
|
30
|
+
* shim refuses to launch below the floor; doctor explains it in the same terms.
|
|
31
|
+
*/
|
|
32
|
+
export function checkNodeVersion(version) {
|
|
33
|
+
const shown = version.startsWith("v") ? version : `v${version}`;
|
|
34
|
+
if (!nodeVersionSatisfies(version, MIN_NODE_VERSION)) {
|
|
35
|
+
return {
|
|
36
|
+
name: "node",
|
|
37
|
+
status: "fail",
|
|
38
|
+
detail: `${shown} is older than the ${MIN_NODE_VERSION} floor`,
|
|
39
|
+
hint: "upgrade Node.js (https://nodejs.org or `nvm install 22`); older Node crashes mid-request on missing zlib APIs",
|
|
40
|
+
required: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { name: "node", status: "ok", detail: `${shown} (needs ${MIN_NODE_VERSION}+)`, required: true };
|
|
44
|
+
}
|
|
25
45
|
export function checkPiEngine(probe) {
|
|
26
46
|
if (!probe.binPath || !probe.binExists) {
|
|
27
47
|
return {
|
|
@@ -512,6 +532,7 @@ export async function gatherChecks(deps = {}) {
|
|
|
512
532
|
const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
|
|
513
533
|
const probeLatestVersion = deps.probeLatestVersion ?? (() => fetchLatestVersion());
|
|
514
534
|
const checks = [];
|
|
535
|
+
checks.push(checkNodeVersion(deps.nodeVersion ?? process.versions.node));
|
|
515
536
|
checks.push(checkPiEngine(probePiEngine()));
|
|
516
537
|
checks.push(checkExtension(probeExtension()));
|
|
517
538
|
// win32 only, and skipped means NOT SHOWN: on macOS/Linux there is nothing
|
|
@@ -72,6 +72,13 @@ export interface CrashReporterOpts {
|
|
|
72
72
|
timeoutMs?: number;
|
|
73
73
|
}
|
|
74
74
|
export type CrashReporter = (error: unknown, context?: string, repoRoot?: string) => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* OS, arch AND the Node version: the runtime is a first-class crash cause (an
|
|
77
|
+
* old Node dies inside undici on `zlib.createZstdDecompress`), and it rides
|
|
78
|
+
* the existing `platform` field so the backend and its Sentry tag need no
|
|
79
|
+
* change. Mirrored in `yagni-code-cli/src/crashReport.ts`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function platformLabel(): string;
|
|
75
82
|
/**
|
|
76
83
|
* Build the fail-soft reporter. The extension runs inside pi's process, so
|
|
77
84
|
* the client label follows the surface: `desktop` under the desktop shell
|
|
@@ -145,6 +145,15 @@ export function sanitizeCrashError(err, opts = {}) {
|
|
|
145
145
|
...(cappedStack !== undefined ? { stack: cappedStack } : {}),
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* OS, arch AND the Node version: the runtime is a first-class crash cause (an
|
|
150
|
+
* old Node dies inside undici on `zlib.createZstdDecompress`), and it rides
|
|
151
|
+
* the existing `platform` field so the backend and its Sentry tag need no
|
|
152
|
+
* change. Mirrored in `yagni-code-cli/src/crashReport.ts`.
|
|
153
|
+
*/
|
|
154
|
+
export function platformLabel() {
|
|
155
|
+
return `${process.platform} ${process.arch} node ${process.version}`;
|
|
156
|
+
}
|
|
148
157
|
/**
|
|
149
158
|
* Build the fail-soft reporter. The extension runs inside pi's process, so
|
|
150
159
|
* the client label follows the surface: `desktop` under the desktop shell
|
|
@@ -163,7 +172,7 @@ export function makeCrashReporter(opts) {
|
|
|
163
172
|
const payload = {
|
|
164
173
|
client: isDesktopSurface() ? "desktop" : "cli",
|
|
165
174
|
clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
|
|
166
|
-
platform:
|
|
175
|
+
platform: platformLabel(),
|
|
167
176
|
...sanitized,
|
|
168
177
|
...(context !== undefined ? { context } : {}),
|
|
169
178
|
timestamp: new Date().toISOString(),
|
|
@@ -234,7 +243,7 @@ export function reportFatalCrash(error, opts, context) {
|
|
|
234
243
|
const payload = {
|
|
235
244
|
client: isDesktopSurface() ? "desktop" : "cli",
|
|
236
245
|
clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
|
|
237
|
-
platform:
|
|
246
|
+
platform: platformLabel(),
|
|
238
247
|
...sanitized,
|
|
239
248
|
...(context !== undefined ? { context } : {}),
|
|
240
249
|
timestamp: new Date().toISOString(),
|
|
@@ -51,6 +51,8 @@ export interface ApprovedPrefixFile {
|
|
|
51
51
|
version: 1;
|
|
52
52
|
grants: ApprovedPrefixGrant[];
|
|
53
53
|
}
|
|
54
|
+
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
55
|
+
export declare const MULTI_SUBCOMMAND_TOOLS: Set<string>;
|
|
54
56
|
/**
|
|
55
57
|
* Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
|
|
56
58
|
* line, plus the destruction family we keep fenced beyond it).
|
|
@@ -152,7 +154,7 @@ export declare function heredocPrefix(command: string): string | null;
|
|
|
152
154
|
* Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
|
|
153
155
|
* seeds are hypothetical until the user picks remember.
|
|
154
156
|
*/
|
|
155
|
-
export declare function deriveRememberSeeds(command: string,
|
|
157
|
+
export declare function deriveRememberSeeds(command: string, uncoveredSegments: readonly string[], repoKey?: string): {
|
|
156
158
|
seeds: ApprovedPrefixGrant[];
|
|
157
159
|
description: string;
|
|
158
160
|
} | null;
|
|
@@ -29,11 +29,11 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPolicy.js";
|
|
32
|
-
import { SAFE_ENV_VARS
|
|
32
|
+
import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
|
|
33
33
|
import { codeStateHome } from "../stateHome.js";
|
|
34
34
|
// --- Derivation ---
|
|
35
35
|
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
36
|
-
const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
36
|
+
export const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
37
37
|
"git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
|
|
38
38
|
]);
|
|
39
39
|
/**
|
|
@@ -211,13 +211,35 @@ export function storagePrefix(command) {
|
|
|
211
211
|
function hasGitPushRefspecDanger(tokens) {
|
|
212
212
|
return tokens.slice(2).some((t) => t.startsWith("+") || (!t.startsWith("-") && t.includes(":")));
|
|
213
213
|
}
|
|
214
|
+
/** The push shapes a grant pattern covers: a two-token [git, push] grant
|
|
215
|
+
* covers only `git push …`; a single-token [git] grant (the flag-first
|
|
216
|
+
* fallback — `git -C repo push …`, or a user-typed bare `git` in the custom
|
|
217
|
+
* field) covers EVERY subcommand, push included. The push fence must fire
|
|
218
|
+
* for both shapes or the fencing invariant is defeated by the shorter
|
|
219
|
+
* pattern. Deliberate deviation from Claude Code: their Bash(git:*) also
|
|
220
|
+
* matches `git push --force`, but their allow rules never grant UNSANDBOXED
|
|
221
|
+
* execution (permission and sandbox-wrap are orthogonal — an allowed
|
|
222
|
+
* command still runs sandboxed). Our escape-flow grants are standing
|
|
223
|
+
* unsandboxed-run authorizations, so the fence is load-bearing for us. */
|
|
224
|
+
function grantCoversPush(pattern, tokens) {
|
|
225
|
+
if (tokens[0] !== "git")
|
|
226
|
+
return false;
|
|
227
|
+
if (pattern.length >= 2)
|
|
228
|
+
return pattern[1] === "push";
|
|
229
|
+
// single-token [git]: covers a push when the COMMAND is one — any bare
|
|
230
|
+
// `push` word in token position (not a flag value), which also catches
|
|
231
|
+
// flag-first shapes (`git -C /x push --force-with-lease …`). Over-fencing
|
|
232
|
+
// in the safe direction: a benign `git push -c push=…`-style flag value
|
|
233
|
+
// reads as covered-but-fenced only for that command shape, not a grant.
|
|
234
|
+
return tokens.some((t) => t === "push");
|
|
235
|
+
}
|
|
214
236
|
/**
|
|
215
237
|
* Flags that must never ride a grant even though the exec policy leaves them
|
|
216
238
|
* in the prompt band (e.g. --force-with-lease is Guardian-reviewable but a
|
|
217
239
|
* standing grant for it would be a silent force-push license).
|
|
218
240
|
*/
|
|
219
241
|
function hasGrantFencedFlag(pattern, tokens) {
|
|
220
|
-
if (pattern
|
|
242
|
+
if (grantCoversPush(pattern, tokens)) {
|
|
221
243
|
return tokens.some((t) => t.startsWith("--force") || t === "-f");
|
|
222
244
|
}
|
|
223
245
|
return false;
|
|
@@ -341,7 +363,7 @@ export function matchesGrant(command, grants, repoKey) {
|
|
|
341
363
|
continue;
|
|
342
364
|
if (hasGrantFencedFlag(grant.pattern, tokens))
|
|
343
365
|
continue;
|
|
344
|
-
if (grant.pattern
|
|
366
|
+
if (grantCoversPush(grant.pattern, tokens) && hasGitPushRefspecDanger(tokens))
|
|
345
367
|
continue;
|
|
346
368
|
return grant;
|
|
347
369
|
}
|
|
@@ -412,6 +434,17 @@ export function heredocPrefix(command) {
|
|
|
412
434
|
}
|
|
413
435
|
return prefix.length > 0 ? prefix : null;
|
|
414
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Self-match proof for a literal seed: does the seed, as a grant, cover the
|
|
439
|
+
* very command it was derived from? The single gate every literal rung
|
|
440
|
+
* shares — a seed that fails it would hand the user a "don't ask again"
|
|
441
|
+
* option that never works (the dead-rule bug: the multiline -Atc "\"SQL class
|
|
442
|
+
* matched nothing, ever, and re-asked every invocation). Pure.
|
|
443
|
+
*/
|
|
444
|
+
function literalSeedSelfMatches(command, literal) {
|
|
445
|
+
const grant = { pattern: [], literal, repoKey: "", addedAt: "", cwd: "" };
|
|
446
|
+
return matchesGrant(command, [grant], "") !== null;
|
|
447
|
+
}
|
|
415
448
|
/**
|
|
416
449
|
* The FULL remember ladder for one ask, in order (all five rungs — a shape
|
|
417
450
|
* never asks twice):
|
|
@@ -424,18 +457,27 @@ export function heredocPrefix(command) {
|
|
|
424
457
|
* Returns grants WITHOUT repoKey/addedAt/cwd (the gate fills them in) —
|
|
425
458
|
* seeds are hypothetical until the user picks remember.
|
|
426
459
|
*/
|
|
427
|
-
export function deriveRememberSeeds(command,
|
|
428
|
-
|
|
460
|
+
export function deriveRememberSeeds(command, uncoveredSegments, repoKey) {
|
|
461
|
+
// Rung 1 derives and self-matches on the CANONICAL form of each raw
|
|
462
|
+
// segment (the anti-dead-rule invariant — the same normalization
|
|
463
|
+
// matchesGrant applies at match time). The raw segments arrive from
|
|
464
|
+
// evaluateCompoundForEscape's splitSubcommandsQuoted, which preserves
|
|
465
|
+
// quoting so a quoted arg body (a `;` inside `-c "…SQL…"`) reads as ONE
|
|
466
|
+
// argument; canonicalization then strips safe decorations the same way
|
|
467
|
+
// matching does, keeping the token rung reachable for exactly the plain
|
|
468
|
+
// single commands it exists for (the psql case).
|
|
469
|
+
const canonicalSegments = uncoveredSegments.map((seg) => canonicalizeForGrants(seg));
|
|
470
|
+
const derivable = canonicalSegments
|
|
429
471
|
.map((seg) => derivePrefix(seg))
|
|
430
472
|
.filter((p) => p !== null);
|
|
431
473
|
// Rung 1: every uncovered segment token-derivable AND every seed
|
|
432
474
|
// self-matches its own segment (the fencing proof: a `git push +x:y` shape
|
|
433
475
|
// derives [git, push] but the fenced command never matches it, so the
|
|
434
476
|
// remember option is never offered for a shape the grant can't cover).
|
|
435
|
-
if (derivable.length ===
|
|
477
|
+
if (derivable.length === canonicalSegments.length && derivable.length > 0) {
|
|
436
478
|
const key = repoKey ?? "";
|
|
437
479
|
const seeds = derivable.map((pattern) => ({ pattern, repoKey: key, addedAt: "", cwd: "" }));
|
|
438
|
-
const allSelfMatch =
|
|
480
|
+
const allSelfMatch = canonicalSegments.every((seg, i) => matchesGrant(seg, [seeds[i]], key) !== null);
|
|
439
481
|
if (allSelfMatch) {
|
|
440
482
|
return {
|
|
441
483
|
seeds: derivable.map((pattern) => ({ pattern, repoKey: "", addedAt: "", cwd: "" })),
|
|
@@ -445,9 +487,13 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
|
|
|
445
487
|
// A fenced segment kills the token rung — fall through to the literal
|
|
446
488
|
// rungs below (a narrower prefix may still be rememberable).
|
|
447
489
|
}
|
|
448
|
-
// Rung 2: heredoc prefix
|
|
490
|
+
// Rung 2: heredoc prefix — self-match checked (a literal seed is offered
|
|
491
|
+
// ONLY when it provably matches the very command being asked about; the
|
|
492
|
+
// heredoc rung's remainder is its heredoc tail, which the inert-remainder
|
|
493
|
+
// check admits, so this proof is structural — but it runs anyway: a
|
|
494
|
+
// shape change upstream must never resurrect a dead seed).
|
|
449
495
|
const hp = heredocPrefix(command);
|
|
450
|
-
if (hp) {
|
|
496
|
+
if (hp && literalSeedSelfMatches(command, hp)) {
|
|
451
497
|
return {
|
|
452
498
|
seeds: [{ pattern: [], literal: hp, repoKey: "", addedAt: "", cwd: "" }],
|
|
453
499
|
description: `${truncateSeedLabel(hp)} …`,
|
|
@@ -455,10 +501,15 @@ export function deriveRememberSeeds(command, uncoveredCanonicalSegments, repoKey
|
|
|
455
501
|
}
|
|
456
502
|
// Rung 3: first line of a multiline command — but NEVER an assignment-only
|
|
457
503
|
// first line (`X=…\nreal cmd`): that literal is a dead rule (it matches the
|
|
458
|
-
// assignment, grants nothing about the real segment).
|
|
504
|
+
// assignment, grants nothing about the real segment). Self-match checked
|
|
505
|
+
// too: a first-line prefix whose remainder is NOT inert (the multiline
|
|
506
|
+
// `-Atc "` SQL body class) matches nothing, ever — offering it would hand
|
|
507
|
+
// the user a remember option that never works (the dead-rule bug).
|
|
459
508
|
if (command.includes("\n")) {
|
|
460
509
|
const firstLine = command.split("\n")[0].trim();
|
|
461
|
-
if (firstLine.length > 0 &&
|
|
510
|
+
if (firstLine.length > 0 &&
|
|
511
|
+
!/^([A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(firstLine) &&
|
|
512
|
+
literalSeedSelfMatches(command, firstLine)) {
|
|
462
513
|
return {
|
|
463
514
|
seeds: [{ pattern: [], literal: firstLine, repoKey: "", addedAt: "", cwd: "" }],
|
|
464
515
|
description: `${truncateSeedLabel(firstLine)} …`,
|
|
@@ -517,30 +568,89 @@ export function validateGrantForEscape(command, policy, repoKey) {
|
|
|
517
568
|
// seed every uncovered one — the compound remember unit. A compound whose
|
|
518
569
|
// uncovered segments are all token-derivable AND self-matching gets the
|
|
519
570
|
// multi-grant; otherwise the literal rungs apply to the FULL command.
|
|
571
|
+
// (uncovered now carries the RAW quote-preserving segments — see
|
|
572
|
+
// evaluateCompoundForEscape — so the token rung sees quoted arg bodies
|
|
573
|
+
// as ONE plain command, not a dequoted compound.)
|
|
520
574
|
const compound = evaluateCompoundForEscape(command, [], repoKey, policy);
|
|
521
575
|
if (!compound.forbidden && compound.uncovered.length > 0) {
|
|
522
576
|
const res = deriveRememberSeeds(command, compound.uncovered, repoKey);
|
|
523
577
|
if (res && res.seeds.length > 0)
|
|
524
578
|
return res;
|
|
525
579
|
}
|
|
526
|
-
|
|
527
|
-
|
|
580
|
+
// Single-command token rung — derived and self-matched against the RAW
|
|
581
|
+
// command (the quote-preserving form; the canonical form drops quoting
|
|
582
|
+
// and makes single commands look compound — the dead-literal bug).
|
|
583
|
+
const single = isSinglePlainCommand(command);
|
|
528
584
|
if (single) {
|
|
529
|
-
const pattern = derivePrefix(
|
|
585
|
+
const pattern = derivePrefix(command);
|
|
530
586
|
if (pattern) {
|
|
531
587
|
const candidate = { pattern, repoKey, addedAt: new Date().toISOString(), cwd: "" };
|
|
532
|
-
if (matchesGrant(
|
|
588
|
+
if (matchesGrant(command, [candidate], repoKey)) {
|
|
533
589
|
return { seeds: [candidate], description: describePrefix(pattern) };
|
|
534
590
|
}
|
|
535
591
|
}
|
|
536
592
|
}
|
|
537
|
-
// literal rungs (heredoc/first-line/full) —
|
|
538
|
-
//
|
|
539
|
-
|
|
593
|
+
// literal rungs (heredoc/first-line/full) — each rung self-match-proved
|
|
594
|
+
// by deriveRememberSeeds; a rung that cannot cover THIS command is not
|
|
595
|
+
// offered (null = no remember option at all).
|
|
596
|
+
return deriveRememberSeeds(command, [command]);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Quote-preserving segment splitter for the ESCAPE flow (a local, faithful
|
|
600
|
+
* counterpart to shellRules' splitSubcommands — which rejoins tokens with
|
|
601
|
+
* plain spaces and LOSES the quoting: a `;` inside `-c "…SQL…"` comes back
|
|
602
|
+
* out as a bare unquoted `;`, and every downstream pass then reads a
|
|
603
|
+
* single command as a compound — the dead-literal bug). Here, string tokens
|
|
604
|
+
* carrying shell metacharacters are re-quoted on the join so the segment
|
|
605
|
+
* string parses back to the SAME tokens; redirect-out operators keep their
|
|
606
|
+
* fd and target (2>&1, >>file) and background renders as `&`. Known lossy
|
|
607
|
+
* renderings, none grant-relevant: an input redirect renders as a bare
|
|
608
|
+
* `<` (target dropped — a construct, never a command word; the segment
|
|
609
|
+
* falls to the literal rungs, fail-closed), and tokens containing a single
|
|
610
|
+
* quote use the bash `'''` idiom our own shellParse does not
|
|
611
|
+
* implement on re-parse — the command WORD never carries one, so the
|
|
612
|
+
* derived prefix is unaffected (fail-closed at the rung if it ever did).
|
|
613
|
+
*/
|
|
614
|
+
function splitSubcommandsQuoted(command) {
|
|
615
|
+
const tokens = shellParse(command);
|
|
616
|
+
const segments = [];
|
|
617
|
+
let current = [];
|
|
618
|
+
const flush = () => {
|
|
619
|
+
if (current.length > 0) {
|
|
620
|
+
segments.push(current.join(" "));
|
|
621
|
+
current = [];
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const renderString = (t) => /[\s;|&<>'"\\$`]/.test(t) ? `'${t.replace(/'/g, `'\\''`)}'` : t;
|
|
625
|
+
const renderToken = (t) => {
|
|
626
|
+
if (typeof t === "string")
|
|
627
|
+
return renderString(t);
|
|
628
|
+
if (t.op === "redirect") {
|
|
629
|
+
if (t.direction === "out") {
|
|
630
|
+
const fd = t.fd === "stderr" ? "2" : "1";
|
|
631
|
+
return `${fd}${t.append ? ">>" : ">"}${renderString(t.target)}`;
|
|
632
|
+
}
|
|
633
|
+
return "<";
|
|
634
|
+
}
|
|
635
|
+
if (t.op === "background")
|
|
636
|
+
return "&";
|
|
637
|
+
return ""; // pipe/and/or/semi/substitution are boundaries — never rendered
|
|
638
|
+
};
|
|
639
|
+
for (const t of tokens) {
|
|
640
|
+
if (typeof t === "object" && "op" in t && (t.op === "pipe" || t.op === "and" || t.op === "or" || t.op === "semi" || t.op === "substitution")) {
|
|
641
|
+
flush();
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const rendered = renderToken(t);
|
|
645
|
+
if (rendered !== "")
|
|
646
|
+
current.push(rendered);
|
|
647
|
+
}
|
|
648
|
+
flush();
|
|
649
|
+
return segments;
|
|
540
650
|
}
|
|
541
651
|
export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
|
|
542
652
|
const isHeredoc = /<<[-~]?\s*(["']?)(\w+)\1/.test(command);
|
|
543
|
-
const rawSegments = isHeredoc ? [command] :
|
|
653
|
+
const rawSegments = isHeredoc ? [command] : splitSubcommandsQuoted(command);
|
|
544
654
|
const segments = [];
|
|
545
655
|
const uncovered = [];
|
|
546
656
|
let forbidden = false;
|
|
@@ -579,7 +689,16 @@ export function evaluateCompoundForEscape(command, grants, repoKey, policy) {
|
|
|
579
689
|
continue;
|
|
580
690
|
}
|
|
581
691
|
segments.push({ segment: canonical, kind: "uncovered" });
|
|
582
|
-
|
|
692
|
+
// The seed ladder consumes the RAW trimmed segment, not the canonical
|
|
693
|
+
// form: canonicalizeForGrants runs the quote-aware tokenizer, and its
|
|
694
|
+
// re-join DROPS the quoting (a `;` inside `-c "…SQL…"` comes back out as
|
|
695
|
+
// a bare unquoted `;`) — derivePrefix's isSinglePlainCommand then sees a
|
|
696
|
+
// compound and kills the token rung, so flag-first single commands
|
|
697
|
+
// (psql -h … -Atc "…") fall to the dead first-line literal rung
|
|
698
|
+
// (`… -Atc "` — a rule that matches nothing, ever). The RAW segment
|
|
699
|
+
// preserves the quoting the tokenizer needs to see the command as ONE
|
|
700
|
+
// plain command and derive ["psql"].
|
|
701
|
+
uncovered.push(trimmed);
|
|
583
702
|
}
|
|
584
703
|
return { forbidden, uncovered, segments };
|
|
585
704
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* When the mode leaves plan, stale plan-context messages are filtered out of
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
|
-
import { describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
29
|
+
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
30
30
|
import { logEvent } from "../errorSink.js";
|
|
31
31
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
32
32
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
@@ -505,6 +505,28 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
505
505
|
// escape_ask_headless_blocked, escape_aborted, escape_ask_failed — policy,
|
|
506
506
|
// infra, and abort outcomes never read as a user action in the trail.
|
|
507
507
|
};
|
|
508
|
+
/** Persist a grant, fail-soft WITH a trail: the in-memory grant still
|
|
509
|
+
* applies for this session, but a silent persist failure would leave the
|
|
510
|
+
* user believing "don't ask again" survived the restart while the same
|
|
511
|
+
* dialog re-appears next session with zero trace — one warn sink line
|
|
512
|
+
* (error class only, never the thrown message: appendGrant failures can
|
|
513
|
+
* carry file paths, and the sink's content discipline is class-level)
|
|
514
|
+
* makes it observable. */
|
|
515
|
+
const persistGrantFailSoft = (grant) => {
|
|
516
|
+
try {
|
|
517
|
+
deps.persistGrant?.(grant);
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
logEvent({
|
|
521
|
+
source: "permission-rules",
|
|
522
|
+
level: "warn",
|
|
523
|
+
event: "grant_persist_failed",
|
|
524
|
+
fields: {
|
|
525
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
};
|
|
508
530
|
const emitGateEvent = (slot, event) => {
|
|
509
531
|
const mapped = GUARDIAN_OUTCOME_SOURCE[event.outcome];
|
|
510
532
|
if (mapped)
|
|
@@ -1152,12 +1174,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1152
1174
|
addedAt: new Date().toISOString(),
|
|
1153
1175
|
};
|
|
1154
1176
|
grants.push(grantRecord);
|
|
1155
|
-
|
|
1156
|
-
deps.persistGrant?.(grantRecord);
|
|
1157
|
-
}
|
|
1158
|
-
catch {
|
|
1159
|
-
// Fail-soft: the in-memory grant still applies this session.
|
|
1160
|
-
}
|
|
1177
|
+
persistGrantFailSoft(grantRecord);
|
|
1161
1178
|
emitGateEvent(slot, {
|
|
1162
1179
|
...eventBase,
|
|
1163
1180
|
outcome: "ask_approved_remembered",
|
|
@@ -1536,15 +1553,41 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1536
1553
|
}
|
|
1537
1554
|
if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
|
|
1538
1555
|
const trimmedCustom = custom.trim();
|
|
1539
|
-
// The custom
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1542
|
-
|
|
1556
|
+
// The custom field accepts two shapes:
|
|
1557
|
+
// (a) a clean command word (`npx`, `psql`) — the first-word rung of
|
|
1558
|
+
// the ladder — validated by derivePrefix's shape check and
|
|
1559
|
+
// self-matched as a TOKEN grant (covers every later `npx …` /
|
|
1560
|
+
// `psql …` regardless of arguments; Claude's `psql:*` semantics).
|
|
1561
|
+
// A bare word can never pass the literal-remainder check (the
|
|
1562
|
+
// words after it are "not inert"), which is why the field used
|
|
1563
|
+
// to reject exactly this input with a dead-rule warning. The
|
|
1564
|
+
// git-push force/refspec fence is pattern-length-aware (a
|
|
1565
|
+
// bare `git` grant covers pushes too) — see matchesGrant.
|
|
1566
|
+
// (b) anything longer — a literal string prefix, validated as
|
|
1567
|
+
// before (must cover THIS command, banned/fenced shapes
|
|
1568
|
+
// refused the same way every rung does).
|
|
1569
|
+
const tokenPattern = trimmedCustom.includes(" ") || trimmedCustom.includes("\n")
|
|
1570
|
+
? null
|
|
1571
|
+
: derivePrefix(trimmedCustom);
|
|
1572
|
+
const customGrant = tokenPattern
|
|
1573
|
+
? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
|
|
1574
|
+
: { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
|
|
1575
|
+
// Validation must be compound-aware: the escaped command is often a
|
|
1576
|
+
// compound (`S=…; curl … && echo done`), and matchesGrant alone can
|
|
1577
|
+
// never match a single token grant against a compound. A bare-word
|
|
1578
|
+
// token grant is valid when EVERY segment of the compound is covered
|
|
1579
|
+
// — by the token grant or an existing grant — exactly the ladder's
|
|
1580
|
+
// rung-1 self-match, applied to the user's custom word.
|
|
1581
|
+
const customValid = tokenPattern
|
|
1582
|
+
? (() => {
|
|
1583
|
+
const evalPolicy = deps.policy?.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
1584
|
+
const ev = evaluateCompoundForEscape(command, [...grants, customGrant], repoKey, evalPolicy);
|
|
1585
|
+
return !ev.forbidden && ev.uncovered.length === 0;
|
|
1586
|
+
})()
|
|
1587
|
+
: matchesGrant(command, [customGrant], repoKey);
|
|
1588
|
+
if (customValid) {
|
|
1543
1589
|
grants.push(customGrant);
|
|
1544
|
-
|
|
1545
|
-
deps.persistGrant?.(customGrant);
|
|
1546
|
-
}
|
|
1547
|
-
catch { /* fail-soft */ }
|
|
1590
|
+
persistGrantFailSoft(customGrant);
|
|
1548
1591
|
rememberApproved(cwd, command);
|
|
1549
1592
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
1550
1593
|
return {};
|
|
@@ -1617,10 +1660,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1617
1660
|
cwd,
|
|
1618
1661
|
};
|
|
1619
1662
|
grants.push(grantRecord);
|
|
1620
|
-
|
|
1621
|
-
deps.persistGrant?.(grantRecord);
|
|
1622
|
-
}
|
|
1623
|
-
catch { /* fail-soft: in-memory grant applies this session */ }
|
|
1663
|
+
persistGrantFailSoft(grantRecord);
|
|
1624
1664
|
}
|
|
1625
1665
|
rememberApproved(cwd, command);
|
|
1626
1666
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
@@ -299,7 +299,7 @@ export function networkDenialHint(output) {
|
|
|
299
299
|
return {
|
|
300
300
|
cls: "ipc-listen",
|
|
301
301
|
hint: "This looks like a sandbox network-posture denial (a socket bind the sandbox profile denies — test-runner IPC is the usual case). " +
|
|
302
|
-
"The user can
|
|
302
|
+
"The unix-sockets knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
}
|
|
@@ -307,7 +307,7 @@ export function networkDenialHint(output) {
|
|
|
307
307
|
return {
|
|
308
308
|
cls: "tls-trustd",
|
|
309
309
|
hint: "This looks like a sandbox TLS-verification denial (the trustd.agent mach lookup is denied — Go CLIs like gh verify certs through it even on allowlisted domains). " +
|
|
310
|
-
"The user can
|
|
310
|
+
"The trustd knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
// Loopback connect denials: EPERM/not-permitted text with a loopback
|
|
@@ -319,8 +319,8 @@ export function networkDenialHint(output) {
|
|
|
319
319
|
/(connect|curl|psql|wget|fetch|Failed to connect|Couldn't connect)/i.test(output)) {
|
|
320
320
|
return {
|
|
321
321
|
cls: "loopback",
|
|
322
|
-
hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services
|
|
323
|
-
"The user can
|
|
322
|
+
hint: "This looks like a sandbox loopback denial (loopback bypasses the network allowlist, so localhost services need the local-binding knob). " +
|
|
323
|
+
"The local-binding knob is ON by default — this denial means it was turned off; the user can re-enable it in the /sandbox panel (Network tab). Retrying with dangerouslyDisableSandbox is NOT needed once the knob is on.",
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
326
|
return null;
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
19
19
|
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
20
20
|
* — loopback bypasses the proxy via no_proxy).
|
|
21
|
+
*
|
|
22
|
+
* Engineering default (deliberate deviation from Claude Code's opt-in
|
|
23
|
+
* posture): the three network-posture knobs resolve ON when unset —
|
|
24
|
+
* allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
|
|
25
|
+
* posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
|
|
26
|
+
* path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
|
|
27
|
+
* (macOS trustd for Go TLS). An induced knob-off denial makes the model
|
|
28
|
+
* retry with dangerouslyDisableSandbox, and the escaped command runs with
|
|
29
|
+
* full user authority — strictly worse than the sandboxed posture the knob
|
|
30
|
+
* would have allowed. Explicit opt-out (false / []) at any tier still wins.
|
|
21
31
|
*/
|
|
22
32
|
import { type WorktreeGitAccess } from "./worktreeGit.js";
|
|
23
33
|
import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
@@ -68,8 +78,9 @@ export declare function readSandboxSettingsFromFile(configPath: string, warnings
|
|
|
68
78
|
/**
|
|
69
79
|
* Load + merge sandbox settings from all three config files. Scalars: local
|
|
70
80
|
* beats project beats user; arrays: union. Defaults for scalars land here
|
|
71
|
-
* too
|
|
72
|
-
*
|
|
81
|
+
* too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
|
|
82
|
+
* three network-posture knobs ON when unset (the Engineering default — see
|
|
83
|
+
* the file header; an explicit false/[] at any tier still wins).
|
|
73
84
|
*/
|
|
74
85
|
export declare function loadSandboxSettings(opts?: {
|
|
75
86
|
cwd?: string;
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
19
19
|
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
20
20
|
* — loopback bypasses the proxy via no_proxy).
|
|
21
|
+
*
|
|
22
|
+
* Engineering default (deliberate deviation from Claude Code's opt-in
|
|
23
|
+
* posture): the three network-posture knobs resolve ON when unset —
|
|
24
|
+
* allowLocalBinding (loopback bind+connect), the temp-dir unix-socket
|
|
25
|
+
* posture (macOS: ["$TMPDIR"] path-scoped; Linux: allowAllUnixSockets —
|
|
26
|
+
* path-scoped entries cannot exist there), and enableWeakerNetworkIsolation
|
|
27
|
+
* (macOS trustd for Go TLS). An induced knob-off denial makes the model
|
|
28
|
+
* retry with dangerouslyDisableSandbox, and the escaped command runs with
|
|
29
|
+
* full user authority — strictly worse than the sandboxed posture the knob
|
|
30
|
+
* would have allowed. Explicit opt-out (false / []) at any tier still wins.
|
|
21
31
|
*/
|
|
22
32
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
33
|
import { tmpdir } from "node:os";
|
|
@@ -219,16 +229,47 @@ function union(...lists) {
|
|
|
219
229
|
return undefined;
|
|
220
230
|
return [...new Set(present.flat())];
|
|
221
231
|
}
|
|
232
|
+
/** The Engineering network-posture defaults — the single flip point for
|
|
233
|
+
* the default-on decision. If the escape-rate rationale proves wrong in
|
|
234
|
+
* the field, reverting the posture is editing THIS constant (and the
|
|
235
|
+
* enableWeakerNetworkIsolation fallback below), not the merge expressions.
|
|
236
|
+
* Platform-shaped: macOS path-scopes socket binds to the temp dirs;
|
|
237
|
+
* Linux cannot path-scope (seccomp), so its door is the broader allow-all. */
|
|
238
|
+
function ENGINEERING_NETWORK_DEFAULTS(isDarwin) {
|
|
239
|
+
return {
|
|
240
|
+
allowUnixSockets: isDarwin ? ["$TMPDIR"] : [],
|
|
241
|
+
allowAllUnixSockets: !isDarwin,
|
|
242
|
+
allowLocalBinding: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
222
245
|
function mergeNetwork(user, project, local) {
|
|
223
246
|
const u = user ?? {};
|
|
224
247
|
const p = project ?? {};
|
|
225
248
|
const l = local ?? {};
|
|
249
|
+
// Engineering default (deliberate deviation from Claude Code's opt-in
|
|
250
|
+
// posture): when NO tier sets a posture knob, it resolves ON. Rationale —
|
|
251
|
+
// a knob-off default does not buy safety: the induced OS denial makes the
|
|
252
|
+
// model retry with dangerouslyDisableSandbox, and the escaped command runs
|
|
253
|
+
// with FULL user authority (no network allowlist, no filesystem fences) —
|
|
254
|
+
// strictly worse than a sandboxed process that can bind an IPC pipe under
|
|
255
|
+
// $TMPDIR or connect to loopback. An explicit `false` / `[]` from any tier
|
|
256
|
+
// still wins: opt-out is first-class.
|
|
257
|
+
//
|
|
258
|
+
// Platform shape of the DEFAULT socket posture (mirrors the Engineering
|
|
259
|
+
// preset): macOS path-scopes binds to the temp dirs via `allowUnixSockets:
|
|
260
|
+
// ["$TMPDIR"]` with allow-all OFF (srt's allow-all would emit a broader
|
|
261
|
+
// `path-regex ^/` rule that overrides the per-path list); Linux has no
|
|
262
|
+
// path-scoped unix sockets (seccomp cannot path-filter), so its default
|
|
263
|
+
// is the broader `allowAllUnixSockets` — the known, documented cost of the
|
|
264
|
+
// Linux default (it admits /var/run/docker.sock).
|
|
265
|
+
const isDarwin = process.platform === "darwin";
|
|
266
|
+
const defaults = ENGINEERING_NETWORK_DEFAULTS(isDarwin);
|
|
226
267
|
return {
|
|
227
268
|
allowedDomains: union(u.allowedDomains, p.allowedDomains, l.allowedDomains) ?? [],
|
|
228
269
|
deniedDomains: union(u.deniedDomains, p.deniedDomains, l.deniedDomains) ?? [],
|
|
229
|
-
allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets),
|
|
230
|
-
allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets,
|
|
231
|
-
allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding,
|
|
270
|
+
allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets, l.allowUnixSockets) ?? defaults.allowUnixSockets,
|
|
271
|
+
allowAllUnixSockets: l.allowAllUnixSockets ?? p.allowAllUnixSockets ?? u.allowAllUnixSockets ?? defaults.allowAllUnixSockets,
|
|
272
|
+
allowLocalBinding: l.allowLocalBinding ?? p.allowLocalBinding ?? u.allowLocalBinding ?? defaults.allowLocalBinding,
|
|
232
273
|
httpProxyPort: l.httpProxyPort ?? p.httpProxyPort ?? u.httpProxyPort,
|
|
233
274
|
socksProxyPort: l.socksProxyPort ?? p.socksProxyPort ?? u.socksProxyPort,
|
|
234
275
|
};
|
|
@@ -247,8 +288,9 @@ function mergeFilesystem(user, project, local) {
|
|
|
247
288
|
/**
|
|
248
289
|
* Load + merge sandbox settings from all three config files. Scalars: local
|
|
249
290
|
* beats project beats user; arrays: union. Defaults for scalars land here
|
|
250
|
-
* too
|
|
251
|
-
*
|
|
291
|
+
* too: autoAllowBashIfSandboxed true, allowUnsandboxedCommands true, and the
|
|
292
|
+
* three network-posture knobs ON when unset (the Engineering default — see
|
|
293
|
+
* the file header; an explicit false/[] at any tier still wins).
|
|
252
294
|
*/
|
|
253
295
|
export function loadSandboxSettings(opts = {}) {
|
|
254
296
|
const warnings = [];
|
|
@@ -290,7 +332,8 @@ export function loadSandboxSettings(opts = {}) {
|
|
|
290
332
|
userSettings?.enableWeakerNestedSandbox,
|
|
291
333
|
enableWeakerNetworkIsolation: localSettings?.enableWeakerNetworkIsolation ??
|
|
292
334
|
projectSettings?.enableWeakerNetworkIsolation ??
|
|
293
|
-
userSettings?.enableWeakerNetworkIsolation
|
|
335
|
+
userSettings?.enableWeakerNetworkIsolation ??
|
|
336
|
+
true,
|
|
294
337
|
};
|
|
295
338
|
return { settings, diagnostics: { warnings, unknownKeys } };
|
|
296
339
|
}
|
|
@@ -73,8 +73,11 @@ export declare function overrideOptions(current: SandboxOverrideChoice): SelectI
|
|
|
73
73
|
/** Per-mode explanation (Claude's copy, adapted to our surfaces). */
|
|
74
74
|
export declare function modeExplanation(mode: SandboxModeChoice): string;
|
|
75
75
|
export declare function overrideExplanation(choice: SandboxOverrideChoice): string;
|
|
76
|
-
/** The three knobs' resolved state, derived from settings
|
|
77
|
-
*
|
|
76
|
+
/** The three knobs' resolved state, derived from the settings this layer
|
|
77
|
+
* is given. NOTE: the panel is fed the MERGED settings (loadSandboxSettings
|
|
78
|
+
* output — the Engineering default resolves the three knobs ON when unset);
|
|
79
|
+
* a raw `{}` here would read all-off, which never happens in production
|
|
80
|
+
* wiring (session.ts builds the panel state from the merged load). */
|
|
78
81
|
export interface NetworkKnobState {
|
|
79
82
|
trustd: boolean;
|
|
80
83
|
/** macOS: the $TMPDIR entry is present in allowUnixSockets; Linux:
|
|
@@ -101,8 +101,8 @@ export function networkOptions(knobs, platform) {
|
|
|
101
101
|
const items = [
|
|
102
102
|
{
|
|
103
103
|
value: "engineering-preset",
|
|
104
|
-
label: `Engineering
|
|
105
|
-
description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — the
|
|
104
|
+
label: `Engineering defaults: tests + gh + localhost (on by default)${presetApplied(knobs, platform) ? " (applied)" : ""}`,
|
|
105
|
+
description: "Unix sockets in the temp dir (test-runner IPC), macOS TLS chain verification, and loopback connections — ON by default for every session; applying writes the explicit form to this project's local settings",
|
|
106
106
|
},
|
|
107
107
|
];
|
|
108
108
|
if (isMac) {
|
|
@@ -148,12 +148,12 @@ export function networkExplanation(item, platform) {
|
|
|
148
148
|
: "Applies: all unix sockets (no path filtering on Linux — docker.sock included) + localhost connections. Held work (test runs, local DB clients) runs sandboxed instead of escaping.";
|
|
149
149
|
case "trustd":
|
|
150
150
|
case "trustd-off":
|
|
151
|
-
return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service —
|
|
151
|
+
return "Needed for Go TLS verification (gh, gcloud, terraform). Enabling opens a potential data exfiltration vector through the trustd service — on by default; turn it off here if you don't use Go CLIs.";
|
|
152
152
|
case "unix-sockets":
|
|
153
153
|
case "unix-sockets-off":
|
|
154
154
|
return platform === "darwin"
|
|
155
155
|
? "Allows binding unix sockets under $TMPDIR only — every tsx/vitest/node test-runner IPC socket lives there. Writes to $TMPDIR are already sandbox-allowed, so this stays low-risk."
|
|
156
|
-
: "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock.
|
|
156
|
+
: "All-or-nothing on Linux: seccomp cannot filter socket paths, so allowing test-runner IPC also allows docker.sock. On by default (the Engineering default); excludedCommands can keep specific commands out of the sandbox instead.";
|
|
157
157
|
case "local-binding":
|
|
158
158
|
case "local-binding-off":
|
|
159
159
|
return "psql/redis to localhost, dev servers on :4567, and any 127.0.0.1 client. Loopback bypasses the per-domain allowlist — this is all-or-nothing for localhost.";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node.js floor gate.
|
|
3
|
+
*
|
|
4
|
+
* `engines.node` in package.json is advisory: npm prints an EBADENGINE
|
|
5
|
+
* warning on a global install and carries on, so a machine running an older
|
|
6
|
+
* Node ends up with a working `yagni` command that dies deep inside a
|
|
7
|
+
* dependency instead of failing cleanly. The first real report (Sentry
|
|
8
|
+
* YAGNI-BACKEND-4S, 2026-09-09) was pi's bundled undici 8 calling
|
|
9
|
+
* `zlib.createZstdDecompress` — added in Node 22.15 — the moment the backend
|
|
10
|
+
* answered a request with `content-encoding: zstd`. Node's own default fatal
|
|
11
|
+
* printer showed an undici stack trace, and nothing said "your Node is too
|
|
12
|
+
* old".
|
|
13
|
+
*
|
|
14
|
+
* `MIN_NODE_VERSION` mirrors `engines.node` (the manifest test pins the two
|
|
15
|
+
* together). This module is imported by `bin.ts` BEFORE anything else loads,
|
|
16
|
+
* so it must stay dependency-free and use only syntax every Node this could
|
|
17
|
+
* plausibly run under can parse.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MIN_NODE_VERSION = "22.19.0";
|
|
20
|
+
/**
|
|
21
|
+
* Escape hatch for the preflight (`YAGNI_SKIP_NODE_CHECK=1`). Same
|
|
22
|
+
* truthiness rule as the other switches: set and not "" / "0".
|
|
23
|
+
*/
|
|
24
|
+
export declare const NODE_CHECK_SKIP_ENV = "YAGNI_SKIP_NODE_CHECK";
|
|
25
|
+
export declare function nodeCheckSkipped(env?: NodeJS.ProcessEnv): boolean;
|
|
26
|
+
/** `"v22.19.0"` / `"22.19.0-nightly..."` → `[22, 19, 0]`; anything else → null. */
|
|
27
|
+
export declare function parseNodeVersion(raw: string): [number, number, number] | null;
|
|
28
|
+
/**
|
|
29
|
+
* Whether `actual` meets the floor. An unparseable version passes: the gate
|
|
30
|
+
* exists to stop a KNOWN-old Node with a clear message, never to lock out a
|
|
31
|
+
* runtime whose version string we merely failed to read.
|
|
32
|
+
*/
|
|
33
|
+
export declare function nodeVersionSatisfies(actual: string, min?: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The names the message speaks in. The same shim ships on two channels
|
|
36
|
+
* (`@yagni-app/code` as `yagni`, `@yagni-app/code-staging` as
|
|
37
|
+
* `yagni-staging`), so the caller passes the installed distribution
|
|
38
|
+
* (`distribution.ts` owns the names; a `CodeDistribution` satisfies this
|
|
39
|
+
* shape) rather than this module keeping a copy that could drift.
|
|
40
|
+
*/
|
|
41
|
+
export interface NodeGateNames {
|
|
42
|
+
packageName: string;
|
|
43
|
+
commandName: string;
|
|
44
|
+
displayName: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The user-facing explanation when the floor is not met, or null when it is.
|
|
48
|
+
* Names the symptom the gate prevents so someone who already hit the crash
|
|
49
|
+
* recognizes it, and spells out the nvm gotcha (global packages live per
|
|
50
|
+
* Node version there, so the CLI needs a reinstall after switching).
|
|
51
|
+
*/
|
|
52
|
+
export declare function nodeVersionProblem(actual: string, names: NodeGateNames, min?: string): string | null;
|
|
53
|
+
//# sourceMappingURL=nodeVersion.d.ts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node.js floor gate.
|
|
3
|
+
*
|
|
4
|
+
* `engines.node` in package.json is advisory: npm prints an EBADENGINE
|
|
5
|
+
* warning on a global install and carries on, so a machine running an older
|
|
6
|
+
* Node ends up with a working `yagni` command that dies deep inside a
|
|
7
|
+
* dependency instead of failing cleanly. The first real report (Sentry
|
|
8
|
+
* YAGNI-BACKEND-4S, 2026-09-09) was pi's bundled undici 8 calling
|
|
9
|
+
* `zlib.createZstdDecompress` — added in Node 22.15 — the moment the backend
|
|
10
|
+
* answered a request with `content-encoding: zstd`. Node's own default fatal
|
|
11
|
+
* printer showed an undici stack trace, and nothing said "your Node is too
|
|
12
|
+
* old".
|
|
13
|
+
*
|
|
14
|
+
* `MIN_NODE_VERSION` mirrors `engines.node` (the manifest test pins the two
|
|
15
|
+
* together). This module is imported by `bin.ts` BEFORE anything else loads,
|
|
16
|
+
* so it must stay dependency-free and use only syntax every Node this could
|
|
17
|
+
* plausibly run under can parse.
|
|
18
|
+
*/
|
|
19
|
+
export const MIN_NODE_VERSION = "22.19.0";
|
|
20
|
+
/**
|
|
21
|
+
* Escape hatch for the preflight (`YAGNI_SKIP_NODE_CHECK=1`). Same
|
|
22
|
+
* truthiness rule as the other switches: set and not "" / "0".
|
|
23
|
+
*/
|
|
24
|
+
export const NODE_CHECK_SKIP_ENV = "YAGNI_SKIP_NODE_CHECK";
|
|
25
|
+
export function nodeCheckSkipped(env = process.env) {
|
|
26
|
+
const value = env[NODE_CHECK_SKIP_ENV];
|
|
27
|
+
return value !== undefined && value !== "" && value !== "0";
|
|
28
|
+
}
|
|
29
|
+
/** `"v22.19.0"` / `"22.19.0-nightly..."` → `[22, 19, 0]`; anything else → null. */
|
|
30
|
+
export function parseNodeVersion(raw) {
|
|
31
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
|
32
|
+
if (!m)
|
|
33
|
+
return null;
|
|
34
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `actual` meets the floor. An unparseable version passes: the gate
|
|
38
|
+
* exists to stop a KNOWN-old Node with a clear message, never to lock out a
|
|
39
|
+
* runtime whose version string we merely failed to read.
|
|
40
|
+
*/
|
|
41
|
+
export function nodeVersionSatisfies(actual, min = MIN_NODE_VERSION) {
|
|
42
|
+
const a = parseNodeVersion(actual);
|
|
43
|
+
const b = parseNodeVersion(min);
|
|
44
|
+
if (!a || !b)
|
|
45
|
+
return true;
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
if (a[i] !== b[i])
|
|
48
|
+
return a[i] > b[i];
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The user-facing explanation when the floor is not met, or null when it is.
|
|
54
|
+
* Names the symptom the gate prevents so someone who already hit the crash
|
|
55
|
+
* recognizes it, and spells out the nvm gotcha (global packages live per
|
|
56
|
+
* Node version there, so the CLI needs a reinstall after switching).
|
|
57
|
+
*/
|
|
58
|
+
export function nodeVersionProblem(actual, names, min = MIN_NODE_VERSION) {
|
|
59
|
+
if (nodeVersionSatisfies(actual, min))
|
|
60
|
+
return null;
|
|
61
|
+
const shown = actual.startsWith("v") ? actual : `v${actual}`;
|
|
62
|
+
return [
|
|
63
|
+
`${names.displayName} needs Node.js ${min} or newer, but this is Node ${shown}.`,
|
|
64
|
+
"",
|
|
65
|
+
"Older Node is missing APIs the agent's HTTP client relies on (zstd",
|
|
66
|
+
"decompression, for one), so a session would crash mid-request with an",
|
|
67
|
+
'error like "zlib.createZstdDecompress is not a function" instead of',
|
|
68
|
+
"failing cleanly.",
|
|
69
|
+
"",
|
|
70
|
+
"Upgrade Node (https://nodejs.org, or `nvm install 22 && nvm use 22`),",
|
|
71
|
+
`then run \`${names.commandName}\` again. If your Node comes from nvm, reinstall the CLI`,
|
|
72
|
+
"afterwards so it lives under the new version:",
|
|
73
|
+
"",
|
|
74
|
+
` npm install -g ${names.packageName}`,
|
|
75
|
+
"",
|
|
76
|
+
`(${NODE_CHECK_SKIP_ENV}=1 bypasses this check at your own risk.)`,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=nodeVersion.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.1-staging.
|
|
3
|
+
"version": "1.1.1-staging.1358.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"node": ">=22.19.0"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
|
-
"yagni-staging": "dist/
|
|
28
|
+
"yagni-staging": "dist/bin.js"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "d3f818e827670951d1a080060578f482cbdd9fbc"
|
|
62
62
|
}
|