@yagni-app/code 1.0.5 → 1.0.7
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 +89 -27
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -3
- package/dist/claudePlugins.js +129 -19
- package/dist/cli.js +28 -4
- package/dist/doctor.d.ts +42 -4
- package/dist/doctor.js +150 -15
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +18 -5
- package/dist/extension/index.js +319 -72
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +153 -0
- package/dist/extension/mcp/config.js +381 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +100 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +33 -0
- package/dist/extension/mcp/panel.js +268 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +152 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +104 -2
- package/dist/extension/permission/gate.js +387 -31
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +122 -0
- package/dist/mcpCommand.js +787 -0
- package/dist/otel.d.ts +69 -63
- package/dist/otel.js +154 -119
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +20 -3
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash-tool sandbox wiring: decides whether a bash command runs
|
|
3
|
+
* sandboxed, and provides the spawnHook that wraps the command with the OS
|
|
4
|
+
* sandbox (srt) while pi's local exec backend keeps owning process
|
|
5
|
+
* lifecycle, timeout, and abort handling.
|
|
6
|
+
*
|
|
7
|
+
* The wrap uses pi's BashToolOptions.spawnHook (command/cwd/env rewrite
|
|
8
|
+
* before spawn) rather than replacing BashOperations wholesale — pi's
|
|
9
|
+
* battle-tested exec backend stays in charge; only the command string and
|
|
10
|
+
* proxy env change. user_bash (! commands) routes through the same wrap.
|
|
11
|
+
*
|
|
12
|
+
`dangerouslyDisableSandbox` mirrors Claude Code's bash tool parameter: the
|
|
13
|
+
* model may set it when it has evidence a sandbox restriction caused a
|
|
14
|
+
* failure; the parameter NEVER skips the permission gate (gate.ts routes the
|
|
15
|
+
* retry through the normal execPolicy/Guardian flow) — it only skips the OS
|
|
16
|
+
* wrap, and only when sandbox.allowUnsandboxedCommands is true.
|
|
17
|
+
*
|
|
18
|
+
* Child-process stdio-grace handling derived from pi-sandbox (MIT,
|
|
19
|
+
* carderne/pi-sandbox src/sandbox-runtime.ts) — not needed here because pi's
|
|
20
|
+
* local backend owns the child; the reference is credited for the operational
|
|
21
|
+
* lessons (process-group kill, stdio release).
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
function stripLeadingSafeEnvVars(command) {
|
|
25
|
+
// Best-effort normalization for excludedCommands matching (not a security
|
|
26
|
+
// boundary — the permission gate is). Strips leading VAR=val pairs whose
|
|
27
|
+
// name can't affect binary resolution.
|
|
28
|
+
const SAFE = /^[A-Z_][A-Z0-9_]*=(?:[A-Za-z0-9_@./:+~-]+|"[^"\n]*"|'[^'\n]*')/;
|
|
29
|
+
let out = command.trimStart();
|
|
30
|
+
for (;;) {
|
|
31
|
+
const m = out.match(SAFE);
|
|
32
|
+
if (!m)
|
|
33
|
+
return out;
|
|
34
|
+
out = out.slice(m[0].length).trimStart();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function commandSegments(command) {
|
|
38
|
+
// Split on shell separators for excludedCommands matching against compound
|
|
39
|
+
// commands. Deliberately naive (no quote-awareness beyond the safe-env
|
|
40
|
+
// strip): excludedCommands is a convenience, never a security boundary.
|
|
41
|
+
// & (background) joins ;, &&, ||, | — a compound rider must not smuggle an
|
|
42
|
+
// excluded command's segment past the match.
|
|
43
|
+
return command
|
|
44
|
+
.split(/(?:\n|&&|\|\||;|\||&)/)
|
|
45
|
+
.map((s) => stripLeadingSafeEnvVars(s).trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
function matchesExcludedPattern(pattern, candidate) {
|
|
49
|
+
if (pattern.endsWith(":*")) {
|
|
50
|
+
const prefix = pattern.slice(0, -2);
|
|
51
|
+
return candidate === prefix || candidate.startsWith(prefix + " ");
|
|
52
|
+
}
|
|
53
|
+
return candidate === pattern;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Does an excludedCommands pattern match any segment of the command?
|
|
57
|
+
* (Compound-aware: "echo hi && docker ps" must not ride an "echo:*" exclude.)
|
|
58
|
+
*/
|
|
59
|
+
export function commandHasExcludedSegment(command, excludedPatterns) {
|
|
60
|
+
if (excludedPatterns.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
const segments = commandSegments(command);
|
|
63
|
+
return segments.some((seg) => excludedPatterns.some((p) => matchesExcludedPattern(p, seg)));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Should this bash tool call run inside the OS sandbox?
|
|
67
|
+
* Pure — reads the decision inputs, no I/O.
|
|
68
|
+
*/
|
|
69
|
+
export function shouldUseSandbox(input, manager, settings) {
|
|
70
|
+
if (!manager.initialized)
|
|
71
|
+
return false;
|
|
72
|
+
if (!input.command)
|
|
73
|
+
return false;
|
|
74
|
+
if (input.dangerouslyDisableSandbox && settings.allowUnsandboxedCommands !== false)
|
|
75
|
+
return false;
|
|
76
|
+
if (commandHasExcludedSegment(input.command, settings.excludedCommands ?? []))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build the spawnHook that wraps the command with the sandbox. The hook is
|
|
82
|
+
* async-ignoring by design: spawnHook is sync in pi's interface, so the
|
|
83
|
+
* command wrap must already be resolved — use preWrappedCommand() below to
|
|
84
|
+
* pre-wrap and stash, or build the hook from a pre-wrapped string.
|
|
85
|
+
*
|
|
86
|
+
* In practice we pre-wrap in tool_call (async, where we can await the wrap)
|
|
87
|
+
* by mutating event.input.command to the wrapped string, and the spawnHook
|
|
88
|
+
* only injects proxy env. That keeps a single code path for tool calls.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Env additions every sandboxed process needs (srt injects proxy vars into
|
|
92
|
+
* the WRAPPED command's env; the spawnHook passes them through).
|
|
93
|
+
*/
|
|
94
|
+
export function sandboxSpawnEnv(manager) {
|
|
95
|
+
const env = {};
|
|
96
|
+
// Node >= 22.21 / >= 24: make undici honor proxy env vars, so fetch() in
|
|
97
|
+
// sandboxed node processes routes through the srt proxy like curl/git do.
|
|
98
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
99
|
+
if ((major === 22 && minor >= 21) || major >= 24) {
|
|
100
|
+
env.NODE_USE_ENV_PROXY = "1";
|
|
101
|
+
}
|
|
102
|
+
return env;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* spawnHook for the sandboxed bash tool: injects proxy-support env vars.
|
|
106
|
+
* The command itself was already wrapped (pre-wrap in tool_call, async).
|
|
107
|
+
*/
|
|
108
|
+
export function makeSandboxSpawnHook(manager) {
|
|
109
|
+
const extraEnv = sandboxSpawnEnv(manager);
|
|
110
|
+
return (ctx) => ({
|
|
111
|
+
...ctx,
|
|
112
|
+
env: { ...ctx.env, ...extraEnv },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Wrap a raw command for OS-sandboxed execution. Returns the original string
|
|
117
|
+
* when the manager is not initialized (fail-open to plain execution — the
|
|
118
|
+
* permission gate still ran; the sandbox simply is not active).
|
|
119
|
+
*/
|
|
120
|
+
export async function preWrappedCommand(manager, command, binShell) {
|
|
121
|
+
if (!manager.initialized)
|
|
122
|
+
return command;
|
|
123
|
+
return manager.wrapWithSandbox(command, binShell);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Detect "Operation not permitted" style sandbox denials in bash output so
|
|
127
|
+
* callers (tool_result) can annotate and the model can react. Returns the
|
|
128
|
+
* offending path when found (pi-sandbox's regex, credited MIT).
|
|
129
|
+
*/
|
|
130
|
+
export function extractBlockedWritePath(output) {
|
|
131
|
+
const match = output.match(/(?:\/bin\/bash|bash|sh): (?:line \d+: )?(\/[^\s:]+): Operation not permitted/);
|
|
132
|
+
return match ? match[1] : null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wrap decision for user_bash (! commands): same rules as the tool, minus
|
|
136
|
+
* the dangerouslyDisableSandbox parameter (users running ! commands are
|
|
137
|
+
* already the principal, not the model).
|
|
138
|
+
*/
|
|
139
|
+
export function shouldUseSandboxForUserCommand(command, manager, settings) {
|
|
140
|
+
if (!manager.initialized)
|
|
141
|
+
return false;
|
|
142
|
+
if (!command)
|
|
143
|
+
return false;
|
|
144
|
+
return !commandHasExcludedSegment(command, settings.excludedCommands ?? []);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Post-execution violation annotation: annotate a command's combined output
|
|
148
|
+
* with sandbox violation details when the sandbox is active. Returns the
|
|
149
|
+
* annotated output (or the original when nothing to annotate).
|
|
150
|
+
*/
|
|
151
|
+
export function annotateCommandOutput(manager, command, output) {
|
|
152
|
+
if (!manager.initialized)
|
|
153
|
+
return output;
|
|
154
|
+
if (!output.includes("Operation not permitted"))
|
|
155
|
+
return output;
|
|
156
|
+
return manager.annotateStderrWithSandboxFailures(command, output);
|
|
157
|
+
}
|
|
158
|
+
/** cwd must exist before spawn (guard borrowed from pi's local ops). */
|
|
159
|
+
export function assertSpawnableCwd(cwd) {
|
|
160
|
+
if (!existsSync(cwd))
|
|
161
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The gate's sandbox auto-allow decision — the production predicate index.ts
|
|
165
|
+
* wires into registerPermissionGate's sandboxAutoAllow dep. Exported so the
|
|
166
|
+
* precedence table is pinned by real tests, not mirrored tautologies.
|
|
167
|
+
*
|
|
168
|
+
* Declines (returns false): non-bash tools, autoAllow disabled by setting,
|
|
169
|
+
* manager not initialized, empty command, and — critically —
|
|
170
|
+
* dangerouslyDisableSandbox retries that will run UNSANDBOXED (they must pay
|
|
171
|
+
* the normal permission price; only strict-mode-forced sandboxing rides the
|
|
172
|
+
* auto-allow). Otherwise: shouldUseSandbox's decision (excludedCommands
|
|
173
|
+
* included — an excluded command runs unsandboxed through the ordinary flow).
|
|
174
|
+
*/
|
|
175
|
+
export function sandboxAutoAllowDecision(toolName, params, manager, settings) {
|
|
176
|
+
if (toolName !== "bash")
|
|
177
|
+
return false;
|
|
178
|
+
if (settings.autoAllowBashIfSandboxed === false)
|
|
179
|
+
return false;
|
|
180
|
+
if (!manager.initialized)
|
|
181
|
+
return false;
|
|
182
|
+
const command = typeof params.command === "string" ? params.command : "";
|
|
183
|
+
if (!command)
|
|
184
|
+
return false;
|
|
185
|
+
const retryUnsandboxed = params.dangerouslyDisableSandbox === true && settings.allowUnsandboxedCommands !== false;
|
|
186
|
+
if (retryUnsandboxed)
|
|
187
|
+
return false;
|
|
188
|
+
return shouldUseSandbox({ command, dangerouslyDisableSandbox: params.dangerouslyDisableSandbox === true }, manager, settings);
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=bash.js.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox settings schema + layered config load (user + project), shaped after
|
|
3
|
+
* Claude Code's SandboxSettingsSchema subset so a Claude `sandbox` block
|
|
4
|
+
* copied verbatim parses and resolves identically.
|
|
5
|
+
*
|
|
6
|
+
* Files: the `sandbox` key in ~/.yagni-code/config.json (user) and
|
|
7
|
+
* .yagni-code/config.json (project) — the same pair permissionRules reads,
|
|
8
|
+
* so one settings file drives both the permission layer and the sandbox.
|
|
9
|
+
*
|
|
10
|
+
* Merge semantics (Claude parity): scalars — project wins over user over
|
|
11
|
+
* defaults; arrays — union + dedupe across sources (lists merge, never
|
|
12
|
+
* replace). Fail-soft: a malformed file is skipped whole with a warning
|
|
13
|
+
* (same posture as permissionRules/loadConfig.ts).
|
|
14
|
+
*
|
|
15
|
+
* Read/write filesystem semantics (srt, verified in the M0 spike): reads are
|
|
16
|
+
* allowed by default with denyRead regions + allowRead re-allow; writes are
|
|
17
|
+
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
18
|
+
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
19
|
+
* — loopback bypasses the proxy via no_proxy).
|
|
20
|
+
*/
|
|
21
|
+
import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
22
|
+
export interface SandboxNetworkSettings {
|
|
23
|
+
allowedDomains?: string[];
|
|
24
|
+
deniedDomains?: string[];
|
|
25
|
+
allowUnixSockets?: string[];
|
|
26
|
+
allowAllUnixSockets?: boolean;
|
|
27
|
+
allowLocalBinding?: boolean;
|
|
28
|
+
httpProxyPort?: number;
|
|
29
|
+
socksProxyPort?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface SandboxFilesystemSettings {
|
|
32
|
+
allowRead?: string[];
|
|
33
|
+
denyRead?: string[];
|
|
34
|
+
allowWrite?: string[];
|
|
35
|
+
denyWrite?: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface SandboxSettings {
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
/** Sandboxed bash auto-allows (explicit deny/ask rules still win). Default true. */
|
|
40
|
+
autoAllowBashIfSandboxed?: boolean;
|
|
41
|
+
/** false = strict mode: dangerouslyDisableSandbox is ignored entirely. Default true. */
|
|
42
|
+
allowUnsandboxedCommands?: boolean;
|
|
43
|
+
/** Exit-with-error posture when enabled but deps/platform unavailable. Default false. */
|
|
44
|
+
failIfUnavailable?: boolean;
|
|
45
|
+
excludedCommands?: string[];
|
|
46
|
+
network?: SandboxNetworkSettings;
|
|
47
|
+
filesystem?: SandboxFilesystemSettings;
|
|
48
|
+
ignoreViolations?: Record<string, string[]>;
|
|
49
|
+
enableWeakerNestedSandbox?: boolean;
|
|
50
|
+
enableWeakerNetworkIsolation?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface SandboxConfigDiagnostics {
|
|
53
|
+
warnings: string[];
|
|
54
|
+
unknownKeys: string[];
|
|
55
|
+
}
|
|
56
|
+
export interface LoadedSandboxConfig {
|
|
57
|
+
settings: SandboxSettings;
|
|
58
|
+
diagnostics: SandboxConfigDiagnostics;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the `sandbox` object from one config file. Returns undefined when the
|
|
62
|
+
* file is missing, unreadable, malformed, or has no sandbox key. Collects
|
|
63
|
+
* unknown keys for diagnostics (ignored, never fatal).
|
|
64
|
+
*/
|
|
65
|
+
export declare function readSandboxSettingsFromFile(configPath: string, warnings: string[], unknownKeys: string[]): SandboxSettings | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Load + merge sandbox settings from both config files. Scalars: project
|
|
68
|
+
* beats user; arrays: union. Defaults for scalars land here too (Claude
|
|
69
|
+
* parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands true,
|
|
70
|
+
* everything else unset/false.
|
|
71
|
+
*/
|
|
72
|
+
export declare function loadSandboxSettings(opts?: {
|
|
73
|
+
cwd?: string;
|
|
74
|
+
env?: NodeJS.ProcessEnv;
|
|
75
|
+
userHome?: string;
|
|
76
|
+
stateHomeOverride?: string | null;
|
|
77
|
+
}): LoadedSandboxConfig;
|
|
78
|
+
export interface SandboxRuntimePaths {
|
|
79
|
+
cwd: string;
|
|
80
|
+
/** ~/.yagni-code (user `/`-anchor base + protected paths). */
|
|
81
|
+
userStateHome: string;
|
|
82
|
+
/** Project root; null outside a repo. */
|
|
83
|
+
projectRoot: string | null;
|
|
84
|
+
homeDir?: string;
|
|
85
|
+
}
|
|
86
|
+
export interface SandboxRuntimeMerge {
|
|
87
|
+
network: SandboxNetworkSettings & {
|
|
88
|
+
allowedDomains: string[];
|
|
89
|
+
deniedDomains: string[];
|
|
90
|
+
};
|
|
91
|
+
filesystem: SandboxFilesystemSettings & {
|
|
92
|
+
allowRead: string[];
|
|
93
|
+
denyRead: string[];
|
|
94
|
+
allowWrite: string[];
|
|
95
|
+
denyWrite: string[];
|
|
96
|
+
};
|
|
97
|
+
/** Permission rules that could not map into sandbox restrictions (surfaced
|
|
98
|
+
* in diagnostics — never silently dropped). */
|
|
99
|
+
droppedRules: string[];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge sandbox settings + permission rules into srt's effective filesystem /
|
|
103
|
+
* network restriction sets. Rule mapping (Claude parity):
|
|
104
|
+
* Edit(path) allow -> allowWrite
|
|
105
|
+
* Edit(path) deny -> denyWrite
|
|
106
|
+
* Read(path) deny -> denyRead
|
|
107
|
+
* WebFetch(domain:) allow/deny -> allowedDomains / deniedDomains
|
|
108
|
+
*
|
|
109
|
+
* Rule paths use the permission-rule anchors (~/, //, settings-relative /,
|
|
110
|
+
* cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
|
|
111
|
+
* path semantics (/ = absolute) exactly like Claude's two resolvers.
|
|
112
|
+
*/
|
|
113
|
+
export declare function mergeRulesIntoSandbox(settings: SandboxSettings, rules: readonly PermissionRule[], base: SandboxRuntimePaths): SandboxRuntimeMerge;
|
|
114
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox settings schema + layered config load (user + project), shaped after
|
|
3
|
+
* Claude Code's SandboxSettingsSchema subset so a Claude `sandbox` block
|
|
4
|
+
* copied verbatim parses and resolves identically.
|
|
5
|
+
*
|
|
6
|
+
* Files: the `sandbox` key in ~/.yagni-code/config.json (user) and
|
|
7
|
+
* .yagni-code/config.json (project) — the same pair permissionRules reads,
|
|
8
|
+
* so one settings file drives both the permission layer and the sandbox.
|
|
9
|
+
*
|
|
10
|
+
* Merge semantics (Claude parity): scalars — project wins over user over
|
|
11
|
+
* defaults; arrays — union + dedupe across sources (lists merge, never
|
|
12
|
+
* replace). Fail-soft: a malformed file is skipped whole with a warning
|
|
13
|
+
* (same posture as permissionRules/loadConfig.ts).
|
|
14
|
+
*
|
|
15
|
+
* Read/write filesystem semantics (srt, verified in the M0 spike): reads are
|
|
16
|
+
* allowed by default with denyRead regions + allowRead re-allow; writes are
|
|
17
|
+
* allow-only (default-deny, /tmp included). Network: proxy-enforced
|
|
18
|
+
* allowlist; loopback needs allowLocalBinding (allowedDomains cannot open it
|
|
19
|
+
* — loopback bypasses the proxy via no_proxy).
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { codeStateHome } from "../stateHome.js";
|
|
25
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
26
|
+
"enabled",
|
|
27
|
+
"autoAllowBashIfSandboxed",
|
|
28
|
+
"allowUnsandboxedCommands",
|
|
29
|
+
"failIfUnavailable",
|
|
30
|
+
"excludedCommands",
|
|
31
|
+
"network",
|
|
32
|
+
"filesystem",
|
|
33
|
+
"ignoreViolations",
|
|
34
|
+
"enableWeakerNestedSandbox",
|
|
35
|
+
"enableWeakerNetworkIsolation",
|
|
36
|
+
]);
|
|
37
|
+
const KNOWN_NETWORK_KEYS = new Set([
|
|
38
|
+
"allowedDomains",
|
|
39
|
+
"deniedDomains",
|
|
40
|
+
"allowUnixSockets",
|
|
41
|
+
"allowAllUnixSockets",
|
|
42
|
+
"allowLocalBinding",
|
|
43
|
+
"httpProxyPort",
|
|
44
|
+
"socksProxyPort",
|
|
45
|
+
]);
|
|
46
|
+
const KNOWN_FILESYSTEM_KEYS = new Set(["allowRead", "denyRead", "allowWrite", "denyWrite"]);
|
|
47
|
+
const ARRAY_KEYS_BY_SECTION = {
|
|
48
|
+
"": ["excludedCommands"],
|
|
49
|
+
network: ["allowedDomains", "deniedDomains", "allowUnixSockets"],
|
|
50
|
+
filesystem: ["allowRead", "denyRead", "allowWrite", "denyWrite"],
|
|
51
|
+
};
|
|
52
|
+
function isPlainObject(v) {
|
|
53
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
54
|
+
}
|
|
55
|
+
function stringArray(v) {
|
|
56
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the `sandbox` object from one config file. Returns undefined when the
|
|
60
|
+
* file is missing, unreadable, malformed, or has no sandbox key. Collects
|
|
61
|
+
* unknown keys for diagnostics (ignored, never fatal).
|
|
62
|
+
*/
|
|
63
|
+
export function readSandboxSettingsFromFile(configPath, warnings, unknownKeys) {
|
|
64
|
+
if (!existsSync(configPath))
|
|
65
|
+
return undefined;
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
warnings.push(`sandbox: could not parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (!isPlainObject(parsed) || parsed.sandbox === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (!isPlainObject(parsed.sandbox)) {
|
|
77
|
+
warnings.push(`sandbox: "sandbox" in ${configPath} must be an object — ignored`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const out = {};
|
|
81
|
+
const src = parsed.sandbox;
|
|
82
|
+
for (const [k, v] of Object.entries(src)) {
|
|
83
|
+
if (k === "network") {
|
|
84
|
+
if (isPlainObject(v)) {
|
|
85
|
+
const net = {};
|
|
86
|
+
for (const [nk, nv] of Object.entries(v)) {
|
|
87
|
+
if (!KNOWN_NETWORK_KEYS.has(nk)) {
|
|
88
|
+
unknownKeys.push(`network.${nk}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if ((nk === "allowAllUnixSockets" || nk === "allowLocalBinding") && typeof nv === "boolean") {
|
|
92
|
+
net[nk] = nv;
|
|
93
|
+
}
|
|
94
|
+
else if ((nk === "httpProxyPort" || nk === "socksProxyPort") && typeof nv === "number") {
|
|
95
|
+
net[nk] = nv;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const arr = stringArray(nv);
|
|
99
|
+
if (arr === undefined) {
|
|
100
|
+
warnings.push(`sandbox: network.${nk} has an unsupported value type — ignored`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
net[nk] = arr;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
out.network = net;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
warnings.push(`sandbox: network in ${configPath} must be an object — ignored`);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (k === "filesystem") {
|
|
115
|
+
if (isPlainObject(v)) {
|
|
116
|
+
const fs = {};
|
|
117
|
+
for (const [fk, fv] of Object.entries(v)) {
|
|
118
|
+
if (!KNOWN_FILESYSTEM_KEYS.has(fk)) {
|
|
119
|
+
unknownKeys.push(`filesystem.${fk}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const arr = stringArray(fv);
|
|
123
|
+
if (arr === undefined) {
|
|
124
|
+
warnings.push(`sandbox: filesystem.${fk} has an unsupported value type — ignored`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
fs[fk] = arr;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out.filesystem = fs;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
warnings.push(`sandbox: filesystem in ${configPath} must be an object — ignored`);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!KNOWN_TOP_LEVEL_KEYS.has(k)) {
|
|
138
|
+
unknownKeys.push(k);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (k === "ignoreViolations") {
|
|
142
|
+
if (isPlainObject(v)) {
|
|
143
|
+
const iv = {};
|
|
144
|
+
for (const [pat, arr] of Object.entries(v)) {
|
|
145
|
+
const list = stringArray(arr);
|
|
146
|
+
if (list === undefined) {
|
|
147
|
+
warnings.push(`sandbox: ignoreViolations["${pat}"] must be a string array — ignored`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
iv[pat] = list;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
out.ignoreViolations = iv;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
warnings.push(`sandbox: ignoreViolations in ${configPath} must be an object — ignored`);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (k === "excludedCommands") {
|
|
161
|
+
const list = stringArray(v);
|
|
162
|
+
if (list === undefined)
|
|
163
|
+
warnings.push(`sandbox: excludedCommands must be a string array — ignored`);
|
|
164
|
+
else
|
|
165
|
+
out.excludedCommands = list;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (typeof v === "boolean")
|
|
169
|
+
out[k] = v;
|
|
170
|
+
else
|
|
171
|
+
warnings.push(`sandbox: ${k} must be a boolean — ignored`);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function union(...lists) {
|
|
176
|
+
const present = lists.filter((l) => Array.isArray(l));
|
|
177
|
+
if (present.length === 0)
|
|
178
|
+
return undefined;
|
|
179
|
+
return [...new Set(present.flat())];
|
|
180
|
+
}
|
|
181
|
+
function mergeNetwork(user, project) {
|
|
182
|
+
const u = user ?? {};
|
|
183
|
+
const p = project ?? {};
|
|
184
|
+
return {
|
|
185
|
+
allowedDomains: union(u.allowedDomains, p.allowedDomains) ?? [],
|
|
186
|
+
deniedDomains: union(u.deniedDomains, p.deniedDomains) ?? [],
|
|
187
|
+
allowUnixSockets: union(u.allowUnixSockets, p.allowUnixSockets),
|
|
188
|
+
allowAllUnixSockets: p.allowAllUnixSockets ?? u.allowAllUnixSockets,
|
|
189
|
+
allowLocalBinding: p.allowLocalBinding ?? u.allowLocalBinding,
|
|
190
|
+
httpProxyPort: p.httpProxyPort ?? u.httpProxyPort,
|
|
191
|
+
socksProxyPort: p.socksProxyPort ?? u.socksProxyPort,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function mergeFilesystem(user, project) {
|
|
195
|
+
const u = user ?? {};
|
|
196
|
+
const p = project ?? {};
|
|
197
|
+
return {
|
|
198
|
+
allowRead: union(u.allowRead, p.allowRead),
|
|
199
|
+
denyRead: union(u.denyRead, p.denyRead) ?? [],
|
|
200
|
+
allowWrite: union(u.allowWrite, p.allowWrite) ?? [],
|
|
201
|
+
denyWrite: union(u.denyWrite, p.denyWrite) ?? [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Load + merge sandbox settings from both config files. Scalars: project
|
|
206
|
+
* beats user; arrays: union. Defaults for scalars land here too (Claude
|
|
207
|
+
* parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands true,
|
|
208
|
+
* everything else unset/false.
|
|
209
|
+
*/
|
|
210
|
+
export function loadSandboxSettings(opts = {}) {
|
|
211
|
+
const warnings = [];
|
|
212
|
+
const unknownKeys = [];
|
|
213
|
+
const stateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
|
|
214
|
+
const userPath = join(stateHome, "config.json");
|
|
215
|
+
const projectPath = join(opts.cwd ?? process.cwd(), ".yagni-code", "config.json");
|
|
216
|
+
const userSettings = readSandboxSettingsFromFile(userPath, warnings, unknownKeys);
|
|
217
|
+
const projectSettings = readSandboxSettingsFromFile(projectPath, warnings, unknownKeys);
|
|
218
|
+
const settings = {
|
|
219
|
+
enabled: projectSettings?.enabled ?? userSettings?.enabled ?? false,
|
|
220
|
+
autoAllowBashIfSandboxed: projectSettings?.autoAllowBashIfSandboxed ?? userSettings?.autoAllowBashIfSandboxed ?? true,
|
|
221
|
+
allowUnsandboxedCommands: projectSettings?.allowUnsandboxedCommands ?? userSettings?.allowUnsandboxedCommands ?? true,
|
|
222
|
+
failIfUnavailable: projectSettings?.failIfUnavailable ?? userSettings?.failIfUnavailable ?? false,
|
|
223
|
+
excludedCommands: union(userSettings?.excludedCommands, projectSettings?.excludedCommands) ?? [],
|
|
224
|
+
network: mergeNetwork(userSettings?.network, projectSettings?.network),
|
|
225
|
+
filesystem: mergeFilesystem(userSettings?.filesystem, projectSettings?.filesystem),
|
|
226
|
+
ignoreViolations: projectSettings?.ignoreViolations ?? userSettings?.ignoreViolations,
|
|
227
|
+
enableWeakerNestedSandbox: projectSettings?.enableWeakerNestedSandbox ?? userSettings?.enableWeakerNestedSandbox,
|
|
228
|
+
enableWeakerNetworkIsolation: projectSettings?.enableWeakerNetworkIsolation ?? userSettings?.enableWeakerNetworkIsolation,
|
|
229
|
+
};
|
|
230
|
+
return { settings, diagnostics: { warnings, unknownKeys } };
|
|
231
|
+
}
|
|
232
|
+
function expandHome(p, homeDir) {
|
|
233
|
+
if (p === "~")
|
|
234
|
+
return homeDir;
|
|
235
|
+
if (p.startsWith("~/"))
|
|
236
|
+
return join(homeDir, p.slice(2));
|
|
237
|
+
return p;
|
|
238
|
+
}
|
|
239
|
+
function resolveSandboxPath(pattern, base) {
|
|
240
|
+
const homeDir = base.homeDir ?? homeDirDefault();
|
|
241
|
+
if (pattern.startsWith("//"))
|
|
242
|
+
return pattern.slice(1); // absolute from root (Claude legacy)
|
|
243
|
+
if (pattern.startsWith("/")) {
|
|
244
|
+
// Claude permission-rule convention: settings-dir-relative. User rules
|
|
245
|
+
// resolve against ~/.yagni-code, project rules against the project root.
|
|
246
|
+
return join(base.userStateHome, pattern.slice(1));
|
|
247
|
+
}
|
|
248
|
+
return expandHome(pattern, homeDir);
|
|
249
|
+
}
|
|
250
|
+
function resolveSandboxFsPath(pattern, base) {
|
|
251
|
+
// sandbox.filesystem.* uses STANDARD path semantics (Claude #30067):
|
|
252
|
+
// /path -> absolute (as written)
|
|
253
|
+
// //path -> absolute (legacy permission-rule escape, kept for compat)
|
|
254
|
+
// ~/path, ./path, path -> expanded / settings-dir-relative
|
|
255
|
+
const homeDir = base.homeDir ?? homeDirDefault();
|
|
256
|
+
if (pattern.startsWith("//"))
|
|
257
|
+
return pattern.slice(1);
|
|
258
|
+
if (pattern.startsWith("/"))
|
|
259
|
+
return pattern;
|
|
260
|
+
return expandHome(pattern, homeDir);
|
|
261
|
+
}
|
|
262
|
+
let homeDirDefault = () => homeDirImpl();
|
|
263
|
+
import { homedir as homeDirImpl } from "node:os";
|
|
264
|
+
function homeDirDefaultFn() { return homeDirImpl(); }
|
|
265
|
+
homeDirDefault = homeDirDefaultFn;
|
|
266
|
+
/**
|
|
267
|
+
* Merge sandbox settings + permission rules into srt's effective filesystem /
|
|
268
|
+
* network restriction sets. Rule mapping (Claude parity):
|
|
269
|
+
* Edit(path) allow -> allowWrite
|
|
270
|
+
* Edit(path) deny -> denyWrite
|
|
271
|
+
* Read(path) deny -> denyRead
|
|
272
|
+
* WebFetch(domain:) allow/deny -> allowedDomains / deniedDomains
|
|
273
|
+
*
|
|
274
|
+
* Rule paths use the permission-rule anchors (~/, //, settings-relative /,
|
|
275
|
+
* cwd-relative bare) resolved per-source; sandbox.filesystem.* uses standard
|
|
276
|
+
* path semantics (/ = absolute) exactly like Claude's two resolvers.
|
|
277
|
+
*/
|
|
278
|
+
export function mergeRulesIntoSandbox(settings, rules, base) {
|
|
279
|
+
const homeDir = base.homeDir ?? homeDirDefault();
|
|
280
|
+
const allowWrite = new Set([base.cwd, tmpdir()]);
|
|
281
|
+
const denyWrite = new Set();
|
|
282
|
+
const denyRead = new Set();
|
|
283
|
+
const allowRead = new Set();
|
|
284
|
+
const allowedDomains = new Set(settings.network?.allowedDomains ?? []);
|
|
285
|
+
const deniedDomains = new Set(settings.network?.deniedDomains ?? []);
|
|
286
|
+
/** Permission rules that could not map into sandbox restrictions —
|
|
287
|
+
* surfaced to the caller (diagnostics), never silently dropped. */
|
|
288
|
+
const droppedRules = [];
|
|
289
|
+
// Permission rules: user rules resolve /-anchors against the user state
|
|
290
|
+
// home, project rules against the project root (pathRules semantics).
|
|
291
|
+
for (const rule of rules) {
|
|
292
|
+
const anchorBase = {
|
|
293
|
+
...base,
|
|
294
|
+
userStateHome: rule.source === "user" ? base.userStateHome : (base.projectRoot ?? base.userStateHome),
|
|
295
|
+
};
|
|
296
|
+
if (rule.toolName === "edit" || rule.toolName === "write") {
|
|
297
|
+
if (!rule.ruleContent)
|
|
298
|
+
continue;
|
|
299
|
+
const p = resolveSandboxPath(rule.ruleContent, anchorBase);
|
|
300
|
+
if (rule.behavior === "allow")
|
|
301
|
+
allowWrite.add(p);
|
|
302
|
+
// ask and deny BOTH deny in the sandbox: an ask rule's contract is
|
|
303
|
+
// "consult the user for writes here", and sandbox auto-allow would
|
|
304
|
+
// silently skip that consultation for bash — degrading ask to allow.
|
|
305
|
+
// denyWrite blocks bash at the OS level; the file tools still honor
|
|
306
|
+
// ask through the ordinary gate flow.
|
|
307
|
+
else if (rule.behavior === "deny" || rule.behavior === "ask")
|
|
308
|
+
denyWrite.add(p);
|
|
309
|
+
}
|
|
310
|
+
else if (rule.toolName === "read") {
|
|
311
|
+
if (!rule.ruleContent)
|
|
312
|
+
continue;
|
|
313
|
+
const p = resolveSandboxPath(rule.ruleContent, anchorBase);
|
|
314
|
+
if (rule.behavior === "deny")
|
|
315
|
+
denyRead.add(p);
|
|
316
|
+
}
|
|
317
|
+
else if (rule.toolName === "web_fetch") {
|
|
318
|
+
const m = rule.ruleContent?.match(/^domain:(.*)$/);
|
|
319
|
+
if (!m) {
|
|
320
|
+
// Non-domain web_fetch rules (bare tool-name form) cannot map into
|
|
321
|
+
// the network lists — surface instead of silently dropping.
|
|
322
|
+
droppedRules.push(rule.raw);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const domain = m[1];
|
|
326
|
+
if (rule.behavior === "allow")
|
|
327
|
+
allowedDomains.add(domain);
|
|
328
|
+
else if (rule.behavior === "deny")
|
|
329
|
+
deniedDomains.add(domain);
|
|
330
|
+
else if (rule.behavior === "ask")
|
|
331
|
+
deniedDomains.add(domain); // same ask-wins reasoning
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// sandbox.filesystem.*: standard path semantics (NOT settings-relative).
|
|
335
|
+
for (const p of settings.filesystem?.allowWrite ?? [])
|
|
336
|
+
allowWrite.add(resolveSandboxFsPath(p, base));
|
|
337
|
+
for (const p of settings.filesystem?.denyWrite ?? [])
|
|
338
|
+
denyWrite.add(resolveSandboxFsPath(p, base));
|
|
339
|
+
for (const p of settings.filesystem?.denyRead ?? [])
|
|
340
|
+
denyRead.add(resolveSandboxFsPath(p, base));
|
|
341
|
+
for (const p of settings.filesystem?.allowRead ?? [])
|
|
342
|
+
allowRead.add(resolveSandboxFsPath(p, base));
|
|
343
|
+
// Protected paths: always denyWrite, never exempted (Claude parity + our
|
|
344
|
+
// own surfaces). Note srt denyWrite also denies read-of-ignored writes.
|
|
345
|
+
denyWrite.add(join(base.userStateHome, "config.json"));
|
|
346
|
+
if (base.projectRoot)
|
|
347
|
+
denyWrite.add(join(base.projectRoot, ".yagni-code", "config.json"));
|
|
348
|
+
denyWrite.add(base.userStateHome);
|
|
349
|
+
if (base.projectRoot)
|
|
350
|
+
denyWrite.add(join(base.projectRoot, ".yagni-code"));
|
|
351
|
+
return {
|
|
352
|
+
network: {
|
|
353
|
+
...settings.network,
|
|
354
|
+
allowedDomains: [...allowedDomains],
|
|
355
|
+
deniedDomains: [...deniedDomains],
|
|
356
|
+
},
|
|
357
|
+
filesystem: {
|
|
358
|
+
allowRead: [...allowRead],
|
|
359
|
+
denyRead: [...denyRead],
|
|
360
|
+
allowWrite: [...allowWrite],
|
|
361
|
+
denyWrite: [...denyWrite],
|
|
362
|
+
},
|
|
363
|
+
droppedRules,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=config.js.map
|