@warlock.js/ai-workspace 4.14.0 → 4.16.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 +9 -0
- package/cjs/index.cjs +242 -39
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +82 -16
- package/esm/backends/local.mjs.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +9 -4
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/errors.d.mts +8 -2
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -0
- package/esm/errors.mjs.map +1 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +54 -10
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/policy.d.mts +9 -2
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +18 -9
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/tokenize-command.mjs +78 -0
- package/esm/policy/tokenize-command.mjs.map +1 -0
- package/esm/tools/run-tests.d.mts +6 -3
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +13 -4
- package/esm/tools/run-tests.mjs.map +1 -1
- package/llms-full.txt +22 -3
- package/llms.txt +1 -1
- package/package.json +5 -5
- package/skills/use-a-workspace/SKILL.md +22 -3
package/esm/backends/local.mjs
CHANGED
|
@@ -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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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,
|
|
135
|
-
* those are reported through the resolved
|
|
136
|
-
* so the ops layer can surface them
|
|
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
|
|
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:
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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;
|
|
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
|
/**
|
package/esm/errors.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.mts","names":[],"sources":["../../../../../../ai-workspace/src/errors.ts"],"mappings":";;;;;
|
|
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
package/esm/errors.mjs.map
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;
|
|
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":";;;;;;;;;
|
|
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++)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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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"}
|
package/esm/policy/policy.d.mts
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
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":";;;;;
|
|
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"}
|
package/esm/policy/policy.mjs
CHANGED
|
@@ -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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
|
136
|
-
*
|
|
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
|
|
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);
|