@warlock.js/ai-workspace 4.15.0 → 5.0.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.
@@ -1,3 +1,4 @@
1
+ import { tokenizeCommand } from "../policy/tokenize-command.mjs";
1
2
  import { realpath } from "node:fs/promises";
2
3
  import { fs } from "@warlock.js/fs";
3
4
  import { spawn } from "node:child_process";
@@ -30,11 +31,11 @@ function pushCapped(chunks, total, chunk) {
30
31
  /**
31
32
  * Force-kill a spawned command and its entire process tree.
32
33
  *
33
- * With `shell: true` the command runs under an intermediary shell
34
- * (`cmd.exe` on Windows, `/bin/sh` elsewhere), so signalling the direct
35
- * child only reaps the shell a long-running grandchild (e.g. `node`)
36
- * would survive, leaving the `exec` promise unsettled. We therefore kill
37
- * the whole group:
34
+ * The direct child may have grandchildren (on Windows it is the `cmd.exe`
35
+ * wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn
36
+ * further processes), so signalling the direct child alone could leave a
37
+ * long-running grandchild alive and the `exec` promise unsettled. We
38
+ * therefore kill the whole group:
38
39
  * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.
39
40
  * - **POSIX** — the child is spawned `detached`, becoming its own process
40
41
  * group leader, so `process.kill(-pid)` SIGKILLs the group.
@@ -60,6 +61,39 @@ function killTree(pid, child) {
60
61
  }
61
62
  }
62
63
  /**
64
+ * Argument bytes `cmd.exe` cannot carry safely even inside double quotes:
65
+ * an embedded quote breaks out of the quoted span, `%` triggers variable
66
+ * expansion regardless of quoting, and newlines end the command line. An
67
+ * argv containing any of these is refused rather than risked (the
68
+ * BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).
69
+ */
70
+ const WIN32_UNSAFE_ARGUMENT = /["%\r\n]/;
71
+ /**
72
+ * Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims
73
+ * (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell
74
+ * (Node rejects them since CVE-2024-27980), so the argv is run through
75
+ * `cmd.exe /d /s /c` with every element individually double-quoted —
76
+ * quoted spans are literal to cmd's parser, so pipes/ampersands inside an
77
+ * argument stay argument data. Returns `null` when an element contains a
78
+ * byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).
79
+ *
80
+ * The caller must spawn with `windowsVerbatimArguments: true` so Node does
81
+ * not re-quote the already-quoted command line.
82
+ */
83
+ function toWin32CmdInvocation(argv) {
84
+ if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) return null;
85
+ const commandLine = argv.map((element) => `"${element}"`).join(" ");
86
+ return {
87
+ file: process.env.ComSpec ?? "cmd.exe",
88
+ args: [
89
+ "/d",
90
+ "/s",
91
+ "/c",
92
+ `"${commandLine}"`
93
+ ]
94
+ };
95
+ }
96
+ /**
63
97
  * The real-disk executor: every filesystem method delegates to
64
98
  * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
65
99
  * process via `node:child_process`. It is deliberately **policy-agnostic** —
@@ -124,24 +158,56 @@ var LocalBackend = class {
124
158
  return realpath(absPath);
125
159
  }
126
160
  /**
127
- * Run a command and capture its outcome. The command line is executed
128
- * through the platform shell (`shell: true`) so pipes/operators behave as a
129
- * caller would expect; `cwd`, `env`, and the timeout are taken verbatim from
130
- * the ops layer (the environment is NOT merged with `process.env`). On
131
- * timeout the process is SIGKILLed and `timedOut` is set. `stdout`/`stderr`
132
- * are captured and byte-capped per {@link MAX_STREAM_BYTES}.
161
+ * Run a command and capture its outcome. The command line is tokenized
162
+ * into an argv (quotes respected, NO shell semantics see
163
+ * `tokenizeCommand`) and spawned **without a shell**, so metacharacters
164
+ * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra
165
+ * commands past the ops layer's allowlist; a command they appear
166
+ * unquoted in is refused with exit code 127. On Windows the argv runs
167
+ * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
168
+ * cannot be spawned shell-less) with every element individually quoted.
169
+ * `cwd`, `env`, and the timeout are taken verbatim from the ops layer
170
+ * (the environment is NOT merged with `process.env`). On timeout the
171
+ * process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are
172
+ * captured and byte-capped per {@link MAX_STREAM_BYTES}.
133
173
  *
134
- * Never rejects for a non-zero exit, a missing executable, or a timeout —
135
- * those are reported through the resolved {@link WorkspaceBackendExecResult}
136
- * so the ops layer can surface them as tool-error data.
174
+ * Never rejects for a non-zero exit, a missing executable, a refused
175
+ * command line, or a timeout — those are reported through the resolved
176
+ * {@link WorkspaceBackendExecResult} so the ops layer can surface them
177
+ * as tool-error data.
137
178
  */
138
179
  exec(command, opts = {}) {
139
180
  return new Promise((resolve) => {
140
- const child = spawn(command, {
181
+ const refuse = (stderr) => resolve({
182
+ exitCode: 127,
183
+ stdout: "",
184
+ stderr,
185
+ timedOut: false
186
+ });
187
+ const argv = tokenizeCommand(command);
188
+ if (argv === null) {
189
+ refuse("Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time.");
190
+ return;
191
+ }
192
+ let file = argv[0];
193
+ let args = argv.slice(1);
194
+ let windowsVerbatimArguments = false;
195
+ if (platform === "win32") {
196
+ const invocation = toWin32CmdInvocation(argv);
197
+ if (invocation === null) {
198
+ refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
199
+ return;
200
+ }
201
+ file = invocation.file;
202
+ args = invocation.args;
203
+ windowsVerbatimArguments = true;
204
+ }
205
+ const child = spawn(file, args, {
141
206
  cwd: opts.cwd,
142
207
  env: opts.env,
143
- shell: true,
208
+ shell: false,
144
209
  windowsHide: true,
210
+ windowsVerbatimArguments,
145
211
  detached: platform !== "win32"
146
212
  });
147
213
  const stdoutChunks = [];
@@ -1 +1 @@
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 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 * With `shell: true` the command runs under an intermediary shell\n * (`cmd.exe` on Windows, `/bin/sh` elsewhere), so signalling the direct\n * child only reaps the shell — a long-running grandchild (e.g. `node`)\n * would survive, leaving the `exec` promise unsettled. We therefore kill\n * 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 * 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 executed\n * through the platform shell (`shell: true`) so pipes/operators behave as a\n * caller would expect; `cwd`, `env`, and the timeout are taken verbatim from\n * the ops layer (the environment is NOT merged with `process.env`). On\n * timeout the process is SIGKILLed and `timedOut` is set. `stdout`/`stderr`\n * are captured and byte-capped per {@link MAX_STREAM_BYTES}.\n *\n * Never rejects for a non-zero exit, a missing executable, or a timeout —\n * those are reported through the resolved {@link WorkspaceBackendExecResult}\n * so the ops layer can surface them 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 child = spawn(command, {\n cwd: opts.cwd,\n env: opts.env,\n shell: true,\n windowsHide: true,\n // POSIX: own process group so a timeout SIGKILL reaps the whole\n // shell tree, not just the shell. Harmless on Windows (ignored;\n // 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 shell itself is missing) surfaces as an\n // error event with no exit; report it as a conventional shell\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
+ {"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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"}
@@ -12,10 +12,15 @@ type WorkspaceBackendType = "local" | "mock";
12
12
  * Shell-execution sub-policy for `run_shell` / `run_tests` and the
13
13
  * `exec()` direct method.
14
14
  *
15
- * **Command gating.** Each command's leading executable basename is
16
- * matched against `allow` / `deny`; **deny wins** when a name appears
17
- * in both. An empty/absent `allow` means no command is permitted unless
18
- * the policy explicitly opts in the workspace is fail-closed.
15
+ * **Command gating.** Each command is tokenized into an argv (quotes
16
+ * respected, NO shell semantics) and executed without a shell; a command
17
+ * containing unquoted shell metacharacters (`;`, `&`, `|`, backticks,
18
+ * `$`, redirection, parentheses) is denied outright, so metacharacters
19
+ * cannot chain extra commands past the gate. The argv's leading
20
+ * executable basename is matched against `allow` / `deny`; **deny wins**
21
+ * when a name appears in both. An empty/absent `allow` means no command
22
+ * is permitted unless the policy explicitly opts in — the workspace is
23
+ * fail-closed.
19
24
  *
20
25
  * **Environment.** The spawned process does NOT inherit `process.env`.
21
26
  * The effective environment is `{ ...pick(process.env, inheritEnv), ...env }`
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;AAQA;;;;AAAgC;AAgBhC;;KAhBY,oBAAA;;;;;;;;;;AA+BA;AAMZ;;;;UArBiB,oBAAA;EA+CA;EA7Cf,KAAA;;EAEA,IAAA;EAqDO;EAnDP,SAAA;EAqD8B;EAnD9B,cAAA;EAyCA;EAvCA,GAAA,GAAM,MAAM;EA2CZ;;;;EAtCA,UAAA;AAAA;;;AA4C8B;UAtCf,mBAAA;;EAEf,QAAA;;EAEA,YAAY;AAAA;;;;;;;;;;;;;;;;;;;;UAsBG,eAAA;;EAEf,GAAA;;EAEA,UAAA;;EAEA,SAAA;;EAEA,KAAA,GAAQ,oBAAA;;EAER,IAAA,GAAO,mBAAA;;EAEP,OAAA,GAAU,oBAAA;AAAA"}
1
+ {"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;AAQA;;;;AAAgC;AAqBhC;;KArBY,oBAAA;;;;;;;;;;AAoCA;AAMZ;;;;AAIc;AAsBd;;;;UA/CiB,oBAAA;EA2DL;EAzDV,KAAA;EAyD8B;EAvD9B,IAAA;EA+CA;EA7CA,SAAA;EAiDA;EA/CA,cAAA;EAiDA;EA/CA,GAAA,GAAM,MAAM;EAiDZ;;;AAA8B;EA5C9B,UAAA;AAAA;;;;UAMe,mBAAA;;EAEf,QAAA;;EAEA,YAAY;AAAA;;;;;;;;;;;;;;;;;;;;UAsBG,eAAA;;EAEf,GAAA;;EAEA,UAAA;;EAEA,SAAA;;EAEA,KAAA,GAAQ,oBAAA;;EAER,IAAA,GAAO,mBAAA;;EAEP,OAAA,GAAU,oBAAA;AAAA"}
package/esm/errors.d.mts CHANGED
@@ -8,8 +8,11 @@ import { AIError, AIErrorOptions } from "@warlock.js/ai";
8
8
  * `allowPaths` root), or matched a `denyPaths` glob.
9
9
  * - `"denied-command"` — a shell command's leading executable basename
10
10
  * was not in `shell.allow`, or was explicitly in `shell.deny`.
11
+ * - `"unsafe-pattern"` — a `grep` pattern was too long or matched a
12
+ * catastrophic-backtracking shape (nested quantifiers) that could hang
13
+ * the process (ReDoS).
11
14
  */
12
- type WorkspacePolicyViolation = "path-escape" | "denied-command";
15
+ type WorkspacePolicyViolation = "path-escape" | "denied-command" | "unsafe-pattern";
13
16
  /**
14
17
  * Options for {@link WorkspacePolicyError} — the structured `type`
15
18
  * discriminator plus, where relevant, the offending path or command for
@@ -18,7 +21,8 @@ type WorkspacePolicyViolation = "path-escape" | "denied-command";
18
21
  type WorkspacePolicyErrorOptions = AIErrorOptions & {
19
22
  /** Which policy rule was violated. */type: WorkspacePolicyViolation; /** The offending workspace-relative path (for `"path-escape"`). */
20
23
  path?: string; /** The offending command line (for `"denied-command"`). */
21
- command?: string;
24
+ command?: string; /** The offending regex pattern (for `"unsafe-pattern"`). */
25
+ pattern?: string;
22
26
  };
23
27
  /**
24
28
  * The workspace policy engine refused an operation — a path escaped the
@@ -44,6 +48,8 @@ declare class WorkspacePolicyError extends AIError {
44
48
  readonly path?: string;
45
49
  /** The offending command, when the violation was a denied command. */
46
50
  readonly command?: string;
51
+ /** The offending regex pattern, when the violation was `"unsafe-pattern"`. */
52
+ readonly pattern?: string;
47
53
  constructor(message: string, options: WorkspacePolicyErrorOptions);
48
54
  }
49
55
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.mts","names":[],"sources":["../../../../../../ai-workspace/src/errors.ts"],"mappings":";;;;;AAUA;;;;AAAoC;AAOpC;KAPY,wBAAA;;;;;;KAOA,2BAAA,GAA8B,cAAA;EAMxC,sCAJA,IAAA,EAAM,wBAAwB,EAIvB;EAFP,IAAA,WAsBgC;EApBhC,OAAA;AAAA;;;;;;;;;;;;;;;AA4BwE;AAmB1E;;cA3Ba,oBAAA,SAA6B,OAAA;EA2BV;EAAA,SAzBd,IAAA,EAAM,wBAAA;EAgCZ;EAAA,SA9BM,IAAA;;WAEA,OAAA;cAEG,OAAA,UAAiB,OAAA,EAAS,2BAAA;AAAA;;;;;;;AAoCnC;AAiBZ;;KAlCY,oBAAA;;;;;;KAOA,yBAAA,GAA4B,cAAA;EA6BtB,iCA3BhB,IAAA,EAAM,oBAAoB,EA6BV;EA3BhB,IAAA,UA+BgB;EA7BhB,OAAA;EAEA,YAAA,WA+B6C;EA7B7C,UAAA;AAAA;AA6BsE;;;;;;;;;;;;;;AAAA,cAZ3D,kBAAA,SAA2B,OAAA;;WAEtB,IAAA,EAAM,oBAAA;;WAEN,IAAA;;WAEA,OAAA;;WAEA,YAAA;;WAEA,UAAA;cAEG,OAAA,UAAiB,OAAA,EAAS,yBAAA;AAAA"}
1
+ {"version":3,"file":"errors.d.mts","names":[],"sources":["../../../../../../ai-workspace/src/errors.ts"],"mappings":";;;;;AAaA;;;;AAAoC;AAOpC;;;;KAPY,wBAAA;;;;;;KAOA,2BAAA,GAA8B,cAAA;EAQjC,sCANP,IAAA,EAAM,wBAAwB,EA0BE;EAxBhC,IAAA,WA0BsB;EAxBtB,OAAA,WAsBwC;EApBxC,OAAA;AAAA;;;;;;;;;;;;AA8BwE;AAoB1E;;;;AAAgC;cA9BnB,oBAAA,SAA6B,OAAA;EAqCL;EAAA,SAnCnB,IAAA,EAAM,wBAAA;EAqCI;EAAA,SAnCV,IAAA;EAmChB;EAAA,SAjCgB,OAAA;EAmChB;EAAA,SAjCgB,OAAA;cAEG,OAAA,UAAiB,OAAA,EAAS,2BAAA;AAAA;;AAqCnC;AAiBZ;;;;;;;KAlCY,oBAAA;;;;;;KAOA,yBAAA,GAA4B,cAAA;EAqCtB,iCAnChB,IAAA,EAAM,oBAAoB,EAqCP;EAnCnB,IAAA,UAmCoC;EAjCpC,OAAA,WAiCsE;EA/BtE,YAAA;EAEA,UAAA;AAAA;;;;;;;;;;;;;;;cAiBW,kBAAA,SAA2B,OAAA;;WAEtB,IAAA,EAAM,oBAAA;;WAEN,IAAA;;WAEA,OAAA;;WAEA,YAAA;;WAEA,UAAA;cAEG,OAAA,UAAiB,OAAA,EAAS,yBAAA;AAAA"}
package/esm/errors.mjs CHANGED
@@ -25,6 +25,7 @@ var WorkspacePolicyError = class extends AIError {
25
25
  this.type = options.type;
26
26
  this.path = options.path;
27
27
  this.command = options.command;
28
+ this.pattern = options.pattern;
28
29
  }
29
30
  };
30
31
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../ai-workspace/src/errors.ts"],"sourcesContent":["import { AIError, type AIErrorOptions } from \"@warlock.js/ai\";\n\n/**\n * Why a workspace policy check rejected an operation.\n *\n * - `\"path-escape\"` — a resolved path fell outside the `cwd` jail (or an\n * `allowPaths` root), or matched a `denyPaths` glob.\n * - `\"denied-command\"` — a shell command's leading executable basename\n * was not in `shell.allow`, or was explicitly in `shell.deny`.\n */\nexport type WorkspacePolicyViolation = \"path-escape\" | \"denied-command\";\n\n/**\n * Options for {@link WorkspacePolicyError} — the structured `type`\n * discriminator plus, where relevant, the offending path or command for\n * branchable diagnostics without parsing the message.\n */\nexport type WorkspacePolicyErrorOptions = AIErrorOptions & {\n /** Which policy rule was violated. */\n type: WorkspacePolicyViolation;\n /** The offending workspace-relative path (for `\"path-escape\"`). */\n path?: string;\n /** The offending command line (for `\"denied-command\"`). */\n command?: string;\n};\n\n/**\n * The workspace policy engine refused an operation — a path escaped the\n * jail (or hit a deny glob), or a shell command's executable was not\n * allowed.\n *\n * **Surface.** This is returned to the agent as tool-error *data*, never\n * a thrown run-killer — the agent reads the failure and self-corrects.\n * Extends the framework `AIError` (category `\"tool\"`, code\n * `TOOL_EXEC_FAILED`) so it flows through the same typed error contract\n * as every other AI error; branch on `error.type` for the specific\n * violation.\n *\n * @example\n * if (error instanceof WorkspacePolicyError && error.type === \"denied-command\") {\n * console.warn(`Blocked command: ${error.command}`);\n * }\n */\nexport class WorkspacePolicyError extends AIError {\n /** Which policy rule was violated. */\n public readonly type: WorkspacePolicyViolation;\n /** The offending path, when the violation was a path escape. */\n public readonly path?: string;\n /** The offending command, when the violation was a denied command. */\n public readonly command?: string;\n\n public constructor(message: string, options: WorkspacePolicyErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspacePolicyError\";\n this.type = options.type;\n this.path = options.path;\n this.command = options.command;\n }\n}\n\n/**\n * Why an edit was rejected.\n *\n * - `\"not-found\"` — the `oldString` did not appear in the file.\n * - `\"not-unique\"` — `oldString` matched more than once and `replaceAll`\n * was not set, so the edit is ambiguous.\n * - `\"stale-hash\"` — the file's current hash did not match the supplied\n * `expectHash`; the file changed since it was read.\n */\nexport type WorkspaceEditFailure = \"not-found\" | \"not-unique\" | \"stale-hash\";\n\n/**\n * Options for {@link WorkspaceEditError} — the structured `type`\n * discriminator plus optional match-count / hash context for the\n * `\"not-unique\"` and `\"stale-hash\"` cases.\n */\nexport type WorkspaceEditErrorOptions = AIErrorOptions & {\n /** Why the edit was rejected. */\n type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n path: string;\n /** How many times `oldString` matched (for `\"not-unique\"`). */\n matches?: number;\n /** The hash the caller expected (for `\"stale-hash\"`). */\n expectedHash?: string;\n /** The file's actual current hash (for `\"stale-hash\"`). */\n actualHash?: string;\n};\n\n/**\n * An `editFile` operation was rejected by the read-before-edit guard:\n * the `oldString` was absent, matched non-uniquely without `replaceAll`,\n * or the file's hash no longer matched the supplied `expectHash`.\n *\n * **Surface.** Like {@link WorkspacePolicyError}, returned to the agent\n * as tool-error *data* so it can re-read and retry. Extends `AIError`\n * (category `\"tool\"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.\n *\n * @example\n * if (error instanceof WorkspaceEditError && error.type === \"stale-hash\") {\n * // re-read the file and retry the edit with the fresh hash\n * }\n */\nexport class WorkspaceEditError extends AIError {\n /** Why the edit was rejected. */\n public readonly type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n public readonly path: string;\n /** How many times `oldString` matched, for the `\"not-unique\"` case. */\n public readonly matches?: number;\n /** The hash the caller expected, for the `\"stale-hash\"` case. */\n public readonly expectedHash?: string;\n /** The file's actual current hash, for the `\"stale-hash\"` case. */\n public readonly actualHash?: string;\n\n public constructor(message: string, options: WorkspaceEditErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceEditError\";\n this.type = options.type;\n this.path = options.path;\n this.matches = options.matches;\n this.expectedHash = options.expectedHash;\n this.actualHash = options.actualHash;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2CA,IAAa,uBAAb,cAA0C,QAAQ;CAQhD,AAAO,YAAY,SAAiB,SAAsC;EACxE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;CACzB;AACF;;;;;;;;;;;;;;;AA6CA,IAAa,qBAAb,cAAwC,QAAQ;CAY9C,AAAO,YAAY,SAAiB,SAAoC;EACtE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,eAAe,QAAQ;EAC5B,KAAK,aAAa,QAAQ;CAC5B;AACF"}
1
+ {"version":3,"file":"errors.mjs","names":[],"sources":["../../../../../../ai-workspace/src/errors.ts"],"sourcesContent":["import { AIError, type AIErrorOptions } from \"@warlock.js/ai\";\n\n/**\n * Why a workspace policy check rejected an operation.\n *\n * - `\"path-escape\"` — a resolved path fell outside the `cwd` jail (or an\n * `allowPaths` root), or matched a `denyPaths` glob.\n * - `\"denied-command\"` — a shell command's leading executable basename\n * was not in `shell.allow`, or was explicitly in `shell.deny`.\n * - `\"unsafe-pattern\"` — a `grep` pattern was too long or matched a\n * catastrophic-backtracking shape (nested quantifiers) that could hang\n * the process (ReDoS).\n */\nexport type WorkspacePolicyViolation = \"path-escape\" | \"denied-command\" | \"unsafe-pattern\";\n\n/**\n * Options for {@link WorkspacePolicyError} — the structured `type`\n * discriminator plus, where relevant, the offending path or command for\n * branchable diagnostics without parsing the message.\n */\nexport type WorkspacePolicyErrorOptions = AIErrorOptions & {\n /** Which policy rule was violated. */\n type: WorkspacePolicyViolation;\n /** The offending workspace-relative path (for `\"path-escape\"`). */\n path?: string;\n /** The offending command line (for `\"denied-command\"`). */\n command?: string;\n /** The offending regex pattern (for `\"unsafe-pattern\"`). */\n pattern?: string;\n};\n\n/**\n * The workspace policy engine refused an operation — a path escaped the\n * jail (or hit a deny glob), or a shell command's executable was not\n * allowed.\n *\n * **Surface.** This is returned to the agent as tool-error *data*, never\n * a thrown run-killer — the agent reads the failure and self-corrects.\n * Extends the framework `AIError` (category `\"tool\"`, code\n * `TOOL_EXEC_FAILED`) so it flows through the same typed error contract\n * as every other AI error; branch on `error.type` for the specific\n * violation.\n *\n * @example\n * if (error instanceof WorkspacePolicyError && error.type === \"denied-command\") {\n * console.warn(`Blocked command: ${error.command}`);\n * }\n */\nexport class WorkspacePolicyError extends AIError {\n /** Which policy rule was violated. */\n public readonly type: WorkspacePolicyViolation;\n /** The offending path, when the violation was a path escape. */\n public readonly path?: string;\n /** The offending command, when the violation was a denied command. */\n public readonly command?: string;\n /** The offending regex pattern, when the violation was `\"unsafe-pattern\"`. */\n public readonly pattern?: string;\n\n public constructor(message: string, options: WorkspacePolicyErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspacePolicyError\";\n this.type = options.type;\n this.path = options.path;\n this.command = options.command;\n this.pattern = options.pattern;\n }\n}\n\n/**\n * Why an edit was rejected.\n *\n * - `\"not-found\"` — the `oldString` did not appear in the file.\n * - `\"not-unique\"` — `oldString` matched more than once and `replaceAll`\n * was not set, so the edit is ambiguous.\n * - `\"stale-hash\"` — the file's current hash did not match the supplied\n * `expectHash`; the file changed since it was read.\n */\nexport type WorkspaceEditFailure = \"not-found\" | \"not-unique\" | \"stale-hash\";\n\n/**\n * Options for {@link WorkspaceEditError} — the structured `type`\n * discriminator plus optional match-count / hash context for the\n * `\"not-unique\"` and `\"stale-hash\"` cases.\n */\nexport type WorkspaceEditErrorOptions = AIErrorOptions & {\n /** Why the edit was rejected. */\n type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n path: string;\n /** How many times `oldString` matched (for `\"not-unique\"`). */\n matches?: number;\n /** The hash the caller expected (for `\"stale-hash\"`). */\n expectedHash?: string;\n /** The file's actual current hash (for `\"stale-hash\"`). */\n actualHash?: string;\n};\n\n/**\n * An `editFile` operation was rejected by the read-before-edit guard:\n * the `oldString` was absent, matched non-uniquely without `replaceAll`,\n * or the file's hash no longer matched the supplied `expectHash`.\n *\n * **Surface.** Like {@link WorkspacePolicyError}, returned to the agent\n * as tool-error *data* so it can re-read and retry. Extends `AIError`\n * (category `\"tool\"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.\n *\n * @example\n * if (error instanceof WorkspaceEditError && error.type === \"stale-hash\") {\n * // re-read the file and retry the edit with the fresh hash\n * }\n */\nexport class WorkspaceEditError extends AIError {\n /** Why the edit was rejected. */\n public readonly type: WorkspaceEditFailure;\n /** The edited file's workspace-relative path. */\n public readonly path: string;\n /** How many times `oldString` matched, for the `\"not-unique\"` case. */\n public readonly matches?: number;\n /** The hash the caller expected, for the `\"stale-hash\"` case. */\n public readonly expectedHash?: string;\n /** The file's actual current hash, for the `\"stale-hash\"` case. */\n public readonly actualHash?: string;\n\n public constructor(message: string, options: WorkspaceEditErrorOptions) {\n super(\"TOOL_EXEC_FAILED\", message, options);\n\n this.name = \"WorkspaceEditError\";\n this.type = options.type;\n this.path = options.path;\n this.matches = options.matches;\n this.expectedHash = options.expectedHash;\n this.actualHash = options.actualHash;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAgDA,IAAa,uBAAb,cAA0C,QAAQ;CAUhD,AAAO,YAAY,SAAiB,SAAsC;EACxE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,UAAU,QAAQ;CACzB;AACF;;;;;;;;;;;;;;;AA6CA,IAAa,qBAAb,cAAwC,QAAQ;CAY9C,AAAO,YAAY,SAAiB,SAAoC;EACtE,MAAM,oBAAoB,SAAS,OAAO;EAE1C,KAAK,OAAO;EACZ,KAAK,OAAO,QAAQ;EACpB,KAAK,OAAO,QAAQ;EACpB,KAAK,UAAU,QAAQ;EACvB,KAAK,eAAe,QAAQ;EAC5B,KAAK,aAAa,QAAQ;CAC5B;AACF"}
package/esm/ops.d.mts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ops.d.mts","names":[],"sources":["../../../../../../ai-workspace/src/ops.ts"],"mappings":";;;;;;;;;AAqbA;;;;;;;;;;;;iBAAgB,SAAA,CACd,OAAA,EAAS,gBAAA,EACT,MAAA,EAAQ,eAAA,GACP,YAAA"}
1
+ {"version":3,"file":"ops.d.mts","names":[],"sources":["../../../../../../ai-workspace/src/ops.ts"],"mappings":";;;;;;;;;AAkfA;;;;;;;;;;;;iBAAgB,SAAA,CACd,OAAA,EAAS,gBAAA,EACT,MAAA,EAAQ,eAAA,GACP,YAAA"}
package/esm/ops.mjs CHANGED
@@ -11,6 +11,20 @@ const DEFAULT_MAX_GREP_MATCHES = 1e3;
11
11
  /** Default per-command output byte cap when the policy sets none. */
12
12
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
13
13
  /**
14
+ * Hard ceiling on `grep` pattern length. A model-controlled regex has no
15
+ * legitimate reason to be this long; longer patterns are rejected outright
16
+ * rather than compiled.
17
+ */
18
+ const MAX_GREP_PATTERN_LENGTH = 200;
19
+ /**
20
+ * Hard ceiling on the number of characters of a single line handed to
21
+ * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential
22
+ * in input length, so bounding the input scanned per call bounds the
23
+ * worst-case time a single pathological line can cost — lines longer than
24
+ * this are skipped rather than scanned.
25
+ */
26
+ const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
27
+ /**
14
28
  * Number the lines of `content` `cat -n` style: a right-aligned line
15
29
  * number (min width 6), a tab, then the line. `startLine` is the 1-based
16
30
  * number of the first line in the window.
@@ -63,6 +77,28 @@ function globToRegExp(glob) {
63
77
  return new RegExp(`^${source}$`);
64
78
  }
65
79
  /**
80
+ * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
81
+ */
82
+ const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
83
+ /**
84
+ * Heuristic catastrophic-backtracking detector: flags a quantified group
85
+ * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —
86
+ * the classic exponential-blowup shape. Not a full regex-safety analyzer
87
+ * (it won't catch every ReDoS shape, e.g. quantified alternation like
88
+ * `(a|a)+`), but it rejects the shape an agent is most likely to emit,
89
+ * intentionally or via prompt injection.
90
+ */
91
+ const NESTED_QUANTIFIER_PATTERN = new RegExp(String.raw`\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\)(?:${QUANTIFIER_SOURCE})`);
92
+ /**
93
+ * Whether `pattern` is safe enough to compile and run against workspace
94
+ * content: within the length cap and free of the nested-quantifier shape
95
+ * that causes catastrophic regex backtracking (ReDoS).
96
+ */
97
+ function isSafeGrepPattern(pattern) {
98
+ if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
99
+ return !NESTED_QUANTIFIER_PATTERN.test(pattern);
100
+ }
101
+ /**
66
102
  * The internal, single-instance implementation of {@link WorkspaceOps}.
67
103
  * Holds the backend + policy and is the one place the jail, command
68
104
  * gating, read caps, and the read-before-edit guard are enforced — both
@@ -177,6 +213,10 @@ var Ops = class {
177
213
  };
178
214
  }
179
215
  async grep(pattern, opts) {
216
+ if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
217
+ type: "unsafe-pattern",
218
+ pattern
219
+ });
180
220
  const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
181
221
  const flags = opts?.ignoreCase ? "i" : "";
182
222
  const regex = new RegExp(pattern, flags);
@@ -194,16 +234,20 @@ var Ops = class {
194
234
  continue;
195
235
  }
196
236
  const lines = content.split("\n");
197
- for (let index = 0; index < lines.length; index++) if (regex.test(lines[index])) {
198
- matches.push({
199
- path: relativePath,
200
- line: index + 1,
201
- text: lines[index]
202
- });
203
- if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
204
- matches,
205
- total: matches.length
206
- };
237
+ for (let index = 0; index < lines.length; index++) {
238
+ const line = lines[index];
239
+ if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
240
+ if (regex.test(line)) {
241
+ matches.push({
242
+ path: relativePath,
243
+ line: index + 1,
244
+ text: line
245
+ });
246
+ if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
247
+ matches,
248
+ total: matches.length
249
+ };
250
+ }
207
251
  }
208
252
  }
209
253
  return {
package/esm/ops.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { buildEnv, isCommandAllowed, resolveInJail } from \"./policy/policy\";\nimport type {\n EditFileInput,\n EditFileResult,\n GrepMatch,\n GrepResult,\n RunShellResult,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n\n/**\n * Number the lines of `content` `cat -n` style: a right-aligned line\n * number (min width 6), a tab, then the line. `startLine` is the 1-based\n * number of the first line in the window.\n */\nfunction numberLines(content: string, startLine: number): string {\n const lines = content.split(\"\\n\");\n\n return lines\n .map((line, index) => {\n const lineNumber = startLine + index;\n\n return `${String(lineNumber).padStart(6, \" \")}\\t${line}`;\n })\n .join(\"\\n\");\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).\n * Returns the possibly-clipped string plus whether clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n // Slice on a byte boundary; `toString` tolerates a split multi-byte\n // char at the tail by emitting the replacement character, which is\n // acceptable for a truncated diagnostic stream.\n return { value: bytes.subarray(0, maxBytes).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Minimal glob match over a `/`-separated relative path. Supports `**`\n * (spans separators), `*` (within a segment), and `?` (one non-separator\n * char) — enough for the workspace's `glob`/grep narrowing without\n * pulling in a runtime dependency.\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 source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\nclass Ops implements WorkspaceOps {\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n ) {}\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<{ content: string; hash: string; totalLines: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the content the backend returned (SHA-256 via @warlock.js/fs),\n // not the on-disk file — the mock backend keeps content in memory,\n // and hashing the returned bytes is what the stale-edit guard later\n // compares against, so it must be the SAME source of truth.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + limit);\n const content = numberLines(window.join(\"\\n\"), offset);\n\n return { content, hash, totalLines };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const occurrences = countOccurrences(current, input.oldString);\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `The text to replace was not found in \"${input.path}\".`,\n { type: \"not-found\", path: relativePath || input.path, matches: 0 },\n );\n }\n\n if (occurrences > 1 && !input.replaceAll) {\n throw new WorkspaceEditError(\n `The text to replace is not unique in \"${input.path}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.`,\n { type: \"not-unique\", path: relativePath || input.path, matches: occurrences },\n );\n }\n\n const updated = input.replaceAll\n ? current.split(input.oldString).join(input.newString)\n : replaceFirst(current, input.oldString, input.newString);\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements: input.replaceAll ? occurrences : 1,\n hash: fs.hash.string(updated),\n };\n }\n\n public async exec(\n command: string,\n opts?: { timeoutMs?: number },\n ): Promise<RunShellResult> {\n if (!isCommandAllowed(this.policy, command)) {\n throw new WorkspacePolicyError(\n `Command is not permitted by the workspace shell policy: ${command}`,\n { type: \"denied-command\", command },\n );\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildEnv(this.policy),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut,\n };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n if (regex.test(lines[index])) {\n matches.push({ path: relativePath, line: index + 1, text: lines[index] });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.remove(absolutePath);\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n if (!denyPaths || denyPaths.length === 0) {\n return false;\n }\n\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\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/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n): WorkspaceOps {\n return new Ops(backend, policy);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { buildEnv, isCommandAllowed, resolveInJail } from \"./policy/policy\";\nimport type {\n EditFileInput,\n EditFileResult,\n GrepMatch,\n GrepResult,\n RunShellResult,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n/**\n * Hard ceiling on `grep` pattern length. A model-controlled regex has no\n * legitimate reason to be this long; longer patterns are rejected outright\n * rather than compiled.\n */\nconst MAX_GREP_PATTERN_LENGTH = 200;\n/**\n * Hard ceiling on the number of characters of a single line handed to\n * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential\n * in input length, so bounding the input scanned per call bounds the\n * worst-case time a single pathological line can cost — lines longer than\n * this are skipped rather than scanned.\n */\nconst MAX_GREP_LINE_SCAN_LENGTH = 2000;\n\n/**\n * Number the lines of `content` `cat -n` style: a right-aligned line\n * number (min width 6), a tab, then the line. `startLine` is the 1-based\n * number of the first line in the window.\n */\nfunction numberLines(content: string, startLine: number): string {\n const lines = content.split(\"\\n\");\n\n return lines\n .map((line, index) => {\n const lineNumber = startLine + index;\n\n return `${String(lineNumber).padStart(6, \" \")}\\t${line}`;\n })\n .join(\"\\n\");\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).\n * Returns the possibly-clipped string plus whether clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n // Slice on a byte boundary; `toString` tolerates a split multi-byte\n // char at the tail by emitting the replacement character, which is\n // acceptable for a truncated diagnostic stream.\n return { value: bytes.subarray(0, maxBytes).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Minimal glob match over a `/`-separated relative path. Supports `**`\n * (spans separators), `*` (within a segment), and `?` (one non-separator\n * char) — enough for the workspace's `glob`/grep narrowing without\n * pulling in a runtime dependency.\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 source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.\n */\nconst QUANTIFIER_SOURCE = String.raw`[+*?]|\\{\\d*,?\\d*\\}`;\n\n/**\n * Heuristic catastrophic-backtracking detector: flags a quantified group\n * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —\n * the classic exponential-blowup shape. Not a full regex-safety analyzer\n * (it won't catch every ReDoS shape, e.g. quantified alternation like\n * `(a|a)+`), but it rejects the shape an agent is most likely to emit,\n * intentionally or via prompt injection.\n */\nconst NESTED_QUANTIFIER_PATTERN = new RegExp(\n String.raw`\\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\\)(?:${QUANTIFIER_SOURCE})`,\n);\n\n/**\n * Whether `pattern` is safe enough to compile and run against workspace\n * content: within the length cap and free of the nested-quantifier shape\n * that causes catastrophic regex backtracking (ReDoS).\n */\nfunction isSafeGrepPattern(pattern: string): boolean {\n if (pattern.length > MAX_GREP_PATTERN_LENGTH) {\n return false;\n }\n\n return !NESTED_QUANTIFIER_PATTERN.test(pattern);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\nclass Ops implements WorkspaceOps {\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n ) {}\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<{ content: string; hash: string; totalLines: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the content the backend returned (SHA-256 via @warlock.js/fs),\n // not the on-disk file — the mock backend keeps content in memory,\n // and hashing the returned bytes is what the stale-edit guard later\n // compares against, so it must be the SAME source of truth.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + limit);\n const content = numberLines(window.join(\"\\n\"), offset);\n\n return { content, hash, totalLines };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const occurrences = countOccurrences(current, input.oldString);\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `The text to replace was not found in \"${input.path}\".`,\n { type: \"not-found\", path: relativePath || input.path, matches: 0 },\n );\n }\n\n if (occurrences > 1 && !input.replaceAll) {\n throw new WorkspaceEditError(\n `The text to replace is not unique in \"${input.path}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.`,\n { type: \"not-unique\", path: relativePath || input.path, matches: occurrences },\n );\n }\n\n const updated = input.replaceAll\n ? current.split(input.oldString).join(input.newString)\n : replaceFirst(current, input.oldString, input.newString);\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements: input.replaceAll ? occurrences : 1,\n hash: fs.hash.string(updated),\n };\n }\n\n public async exec(\n command: string,\n opts?: { timeoutMs?: number },\n ): Promise<RunShellResult> {\n if (!isCommandAllowed(this.policy, command)) {\n throw new WorkspacePolicyError(\n `Command is not permitted by the workspace shell policy: ${command}`,\n { type: \"denied-command\", command },\n );\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildEnv(this.policy),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut,\n };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n if (!isSafeGrepPattern(pattern)) {\n throw new WorkspacePolicyError(\n `Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`,\n { type: \"unsafe-pattern\", pattern },\n );\n }\n\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n\n // Cap the input size a single `RegExp#test` call scans: backtracking\n // cost is exponential in input length, so this bounds the worst-case\n // time even a pathological (but length/shape-allowed) pattern can\n // burn on any one line.\n if (line.length > MAX_GREP_LINE_SCAN_LENGTH) {\n continue;\n }\n\n if (regex.test(line)) {\n matches.push({ path: relativePath, line: index + 1, text: line });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.remove(absolutePath);\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n if (!denyPaths || denyPaths.length === 0) {\n return false;\n }\n\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\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/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n): WorkspaceOps {\n return new Ops(backend, policy);\n}\n"],"mappings":"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"}
@@ -38,8 +38,14 @@ declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Prom
38
38
  /**
39
39
  * Whether a shell command is permitted by the policy's `shell` sub-policy.
40
40
  *
41
- * The command's leading executable basename is matched against
42
- * `shell.deny` then `shell.allow`. **Deny always wins.** When
41
+ * The command is first tokenized via {@link tokenizeCommand} — a command
42
+ * that cannot be represented as a single argv (unbalanced quotes, or
43
+ * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
44
+ * redirection) is denied outright. The backend spawns the argv directly
45
+ * with no shell, so such a command has no meaning here — and unquoted
46
+ * metacharacters were exactly how an injected command chain used to ride
47
+ * past the allowlist. The resolved `argv[0]` basename is then matched
48
+ * against `shell.deny` then `shell.allow`. **Deny always wins.** When
43
49
  * `shell.allow` is set, the executable MUST appear in it (fail-closed
44
50
  * allowlist); when `allow` is absent/empty, any non-denied command is
45
51
  * permitted. An absent `shell` block means no command may run at all.
@@ -51,6 +57,7 @@ declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Prom
51
57
  * @example
52
58
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
53
59
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
60
+ * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
54
61
  */
55
62
  declare function isCommandAllowed(policy: WorkspacePolicy, command: string): boolean;
56
63
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"mappings":";;;;;AAWA;;;UAAiB,YAAA;EAQH;EANZ,YAAA;EAkJiC;;;;;EA5IjC,YAAY;AAAA;;;;;;;AA+IS;AA+EvB;;;;;;;;AAEiB;AAyCjB;;iBA7HsB,aAAA,CACpB,MAAA,EAAQ,eAAA,EACR,SAAA,WACC,OAAA,CAAQ,YAAA;;;;;;AA0H8C;;;;;;;;;;;;iBA3CzC,gBAAA,CACd,MAAA,EAAQ,eAAe,EACvB,OAAA;;;;;;;;;;;;;iBAyCc,QAAA,CAAS,MAAA,EAAQ,eAAA,GAAkB,MAAM"}
1
+ {"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"mappings":";;;;;AAYA;;;UAAiB,YAAA;EAQH;EANZ,YAAA;EAkJiC;;;;;EA5IjC,YAAY;AAAA;;;;;;;AA+IS;AAoFvB;;;;;;;;AAEiB;AA+CjB;;iBAxIsB,aAAA,CACpB,MAAA,EAAQ,eAAA,EACR,SAAA,WACC,OAAA,CAAQ,YAAA;;;;;;AAqI8C;;;;;;;;;;;;;;;;;;;iBAjDzC,gBAAA,CACd,MAAA,EAAQ,eAAe,EACvB,OAAA;;;;;;;;;;;;;iBA+Cc,QAAA,CAAS,MAAA,EAAQ,eAAA,GAAkB,MAAM"}
@@ -1,4 +1,5 @@
1
1
  import { WorkspacePolicyError } from "../errors.mjs";
2
+ import { tokenizeCommand } from "./tokenize-command.mjs";
2
3
  import path from "node:path";
3
4
  import { realpath } from "node:fs/promises";
4
5
 
@@ -120,20 +121,25 @@ async function resolveInJail(policy, inputPath) {
120
121
  };
121
122
  }
122
123
  /**
123
- * Extract the leading executable basename from a command line the
124
- * token the shell allow/deny policy is keyed on. `"npm run build"` →
125
- * `"npm"`; `"/usr/bin/node app.js"` `"node"`; `"node.exe app"`
126
- * `"node"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).
124
+ * Reduce an argv's first element to the basename the allow/deny policy is
125
+ * keyed on. `"npm"` `"npm"`; `"/usr/bin/node"` `"node"`; `"node.exe"`
126
+ * `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
127
+ * stripped).
127
128
  */
128
- function leadingExecutable(command) {
129
- const firstToken = command.trim().split(/\s+/)[0] ?? "";
129
+ function executableBasename(firstToken) {
130
130
  return path.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
131
131
  }
132
132
  /**
133
133
  * Whether a shell command is permitted by the policy's `shell` sub-policy.
134
134
  *
135
- * The command's leading executable basename is matched against
136
- * `shell.deny` then `shell.allow`. **Deny always wins.** When
135
+ * The command is first tokenized via {@link tokenizeCommand} — a command
136
+ * that cannot be represented as a single argv (unbalanced quotes, or
137
+ * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
138
+ * redirection) is denied outright. The backend spawns the argv directly
139
+ * with no shell, so such a command has no meaning here — and unquoted
140
+ * metacharacters were exactly how an injected command chain used to ride
141
+ * past the allowlist. The resolved `argv[0]` basename is then matched
142
+ * against `shell.deny` then `shell.allow`. **Deny always wins.** When
137
143
  * `shell.allow` is set, the executable MUST appear in it (fail-closed
138
144
  * allowlist); when `allow` is absent/empty, any non-denied command is
139
145
  * permitted. An absent `shell` block means no command may run at all.
@@ -145,11 +151,14 @@ function leadingExecutable(command) {
145
151
  * @example
146
152
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
147
153
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
154
+ * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
148
155
  */
149
156
  function isCommandAllowed(policy, command) {
150
157
  const shell = policy.shell;
151
158
  if (!shell) return false;
152
- const executable = leadingExecutable(command);
159
+ const argv = tokenizeCommand(command);
160
+ if (argv === null) return false;
161
+ const executable = executableBasename(argv[0]);
153
162
  if (executable === "") return false;
154
163
  if (shell.deny && shell.deny.includes(executable)) return false;
155
164
  if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);