@warlock.js/ai-workspace 5.29.0 → 5.31.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 +68 -0
- package/README.md +40 -21
- package/cjs/index.cjs +3295 -360
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +199 -31
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +12 -6
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +45 -3
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/tool-io.type.d.mts +248 -11
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +78 -2
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +41 -16
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +142 -14
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +75 -22
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +80 -6
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +23 -2
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +13 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +22 -3
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +341 -82
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/jail.mjs +73 -0
- package/esm/policy/jail.mjs.map +1 -0
- package/esm/policy/policy.d.mts +26 -16
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +62 -86
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +64 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +627 -0
- package/esm/policy/shell-check.mjs.map +1 -0
- package/esm/policy/shell-parser.mjs +802 -0
- package/esm/policy/shell-parser.mjs.map +1 -0
- package/esm/policy/tokenize-command.d.mts +63 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +76 -24
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/shell-jobs.d.mts +45 -0
- package/esm/shell-jobs.d.mts.map +1 -0
- package/esm/shell-jobs.mjs +247 -0
- package/esm/shell-jobs.mjs.map +1 -0
- package/esm/todos.d.mts +22 -0
- package/esm/todos.d.mts.map +1 -0
- package/esm/todos.mjs +73 -0
- package/esm/todos.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +1 -1
- package/esm/tools/edit-file.d.mts.map +1 -1
- package/esm/tools/edit-file.mjs +3 -2
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts +1 -1
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +3 -2
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts +1 -1
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +3 -2
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/json-schemas.mjs +175 -0
- package/esm/tools/json-schemas.mjs.map +1 -0
- package/esm/tools/list-dir.d.mts +32 -0
- package/esm/tools/list-dir.d.mts.map +1 -0
- package/esm/tools/list-dir.mjs +50 -0
- package/esm/tools/list-dir.mjs.map +1 -0
- package/esm/tools/multi-edit.d.mts +38 -0
- package/esm/tools/multi-edit.d.mts.map +1 -0
- package/esm/tools/multi-edit.mjs +121 -0
- package/esm/tools/multi-edit.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +5 -5
- package/esm/tools/read-file.d.mts.map +1 -1
- package/esm/tools/read-file.mjs +10 -20
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +39 -13
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +60 -47
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +11 -2
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +15 -7
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +24 -19
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/shell-kill.d.mts +25 -0
- package/esm/tools/shell-kill.d.mts.map +1 -0
- package/esm/tools/shell-kill.mjs +36 -0
- package/esm/tools/shell-kill.mjs.map +1 -0
- package/esm/tools/shell-output.d.mts +30 -0
- package/esm/tools/shell-output.d.mts.map +1 -0
- package/esm/tools/shell-output.mjs +49 -0
- package/esm/tools/shell-output.mjs.map +1 -0
- package/esm/tools/todo.d.mts +43 -0
- package/esm/tools/todo.d.mts.map +1 -0
- package/esm/tools/todo.mjs +126 -0
- package/esm/tools/todo.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +1 -1
- package/esm/tools/write-file.d.mts.map +1 -1
- package/esm/tools/write-file.mjs +3 -2
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +12 -9
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +109 -28
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +192 -35
- package/llms.txt +1 -1
- package/package.json +5 -4
- package/skills/README.md +2 -2
- package/skills/build-loop-agent/SKILL.md +7 -7
- package/skills/index.md +6 -1
- package/skills/use-a-workspace/SKILL.md +185 -28
package/esm/workspace.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.mjs","names":[],"sources":["../../../../../../ai-workspace/src/workspace.ts"],"sourcesContent":["import path from \"node:path\";\nimport { ai, type ToolContract } from \"@warlock.js/ai\";\nimport { createLocalBackend } from \"./backends/local\";\nimport { createMockBackend } from \"./backends/mock\";\nimport { WorkspacePolicyError } from \"./errors\";\nimport { createOps } from \"./ops\";\nimport { makeEditFileTool } from \"./tools/edit-file\";\nimport { makeGlobTool } from \"./tools/glob\";\nimport { makeGrepTool } from \"./tools/grep\";\nimport { makeReadFileTool } from \"./tools/read-file\";\nimport { makeRunShellTool } from \"./tools/run-shell\";\nimport { makeRunTestsTool } from \"./tools/run-tests\";\nimport { makeWriteFileTool } from \"./tools/write-file\";\nimport type {\n EditFileInput,\n EditFileResult,\n GrepResult,\n RunShellResult,\n Workspace,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n WorkspaceToolName,\n WorkspaceTools,\n} from \"./contracts\";\n\n/**\n * The full set of tool names a writable workspace vends, in a stable\n * canonical order so `tools.all()` is deterministic.\n */\nconst ALL_TOOL_NAMES: readonly WorkspaceToolName[] = [\n \"readFile\",\n \"editFile\",\n \"writeFile\",\n \"runShell\",\n \"runTests\",\n \"grep\",\n \"glob\",\n];\n\n/**\n * The subset a {@link Workspace.readonly} projection exposes — the\n * non-mutating tools only. `editFile` / `writeFile` / `runShell` /\n * `runTests` are deliberately omitted so a reviewer agent has no path to\n * change the tree.\n */\nconst READONLY_TOOL_NAMES: readonly WorkspaceToolName[] = [\n \"readFile\",\n \"grep\",\n \"glob\",\n];\n\n/**\n * Choose the dumb IO executor for a policy. `\"mock\"` selects the\n * in-memory backend (hermetic tests); anything else — including the\n * `\"local\"` default and an absent `backend` — selects the real-disk\n * local backend.\n */\nfunction selectBackend(policy: WorkspacePolicy): WorkspaceBackend {\n if (policy.backend === \"mock\") {\n return createMockBackend();\n }\n\n return createLocalBackend();\n}\n\n/**\n * The internal {@link Workspace} implementation. Holds the resolved\n * backend, the policy, and the single shared {@link WorkspaceOps} seam\n * that both the agent-facing `.tools.*` factories and the human-facing\n * direct methods funnel through — one jail, one rule set, two callers.\n *\n * The `allowedTools` set narrows what `tools.*` will vend and which\n * mutating direct methods are permitted: a full workspace allows every\n * name; a {@link WorkspaceImpl.readonly} projection allows only the\n * read/grep/glob subset and rejects writes/edits/shell/mkdir/remove.\n *\n * Constructed via {@link workspace}; the class itself is internal.\n */\nclass WorkspaceImpl implements Workspace {\n /** The shared, policy-enforced operation layer (jail + guards). */\n private readonly ops: WorkspaceOps;\n\n /** Tool names this projection is permitted to vend / mutate through. */\n private readonly allowedTools: ReadonlySet<WorkspaceToolName>;\n\n public readonly policy: WorkspacePolicy;\n\n public readonly tools: WorkspaceTools;\n\n public constructor(\n policy: WorkspacePolicy,\n allowedTools: readonly WorkspaceToolName[] = ALL_TOOL_NAMES,\n ) {\n this.policy = policy;\n this.allowedTools = new Set(allowedTools);\n\n const backend = selectBackend(policy);\n this.ops = createOps(backend, policy);\n this.tools = this.buildTools();\n }\n\n /**\n * Assemble the agent-facing tool namespace. Each factory builds its\n * tool over the shared `ops`; `all()` returns every *allowed* tool in\n * canonical order and `pick(...)` returns the named subset (silently\n * dropping any name this projection does not allow, so a `readonly()`\n * workspace can never be coaxed into vending a mutating tool).\n */\n private buildTools(): WorkspaceTools {\n // Each `make*Tool` returns a precisely-typed\n // `ToolContract<SpecificInput, SpecificOutput>`, but the agent-facing\n // `WorkspaceTools` surface vends the type-erased `ToolContract`\n // (`ToolContract<unknown, unknown>`). Because `ToolContract` puts its\n // input in a contravariant position (`execute(input)` / `action(input)`),\n // a specific contract is not assignable to the erased one — so erase it\n // once, here, through `unknown`. The runtime object is identical; only\n // the static input type is widened for the shared surface.\n const erase = <TInput, TOutput>(\n contract: ToolContract<TInput, TOutput>,\n ): ToolContract => contract as unknown as ToolContract;\n\n const factories: Record<\n WorkspaceToolName,\n (opts?: { name?: string; command?: string }) => ToolContract\n > = {\n readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),\n editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),\n writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),\n runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),\n runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),\n grep: (opts) => erase(makeGrepTool(this.ops, opts)),\n glob: (opts) => erase(makeGlobTool(this.ops, opts)),\n };\n\n const build = (name: WorkspaceToolName, opts?: { name?: string; command?: string }) =>\n factories[name](opts);\n\n return {\n all: () =>\n ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) =>\n build(name),\n ),\n pick: (...names: WorkspaceToolName[]) =>\n names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),\n readFile: (opts) => build(\"readFile\", opts),\n editFile: (opts) => build(\"editFile\", opts),\n writeFile: (opts) => build(\"writeFile\", opts),\n runShell: (opts) => build(\"runShell\", opts),\n runTests: (opts) => build(\"runTests\", opts),\n grep: (opts) => build(\"grep\", opts),\n glob: (opts) => build(\"glob\", opts),\n };\n }\n\n /**\n * Reject a mutating direct method on a read-only projection — surfaced\n * as a {@link WorkspacePolicyError} (the same typed error a denied\n * command produces) so a caller branches on `error.type`.\n */\n private assertWritable(operation: string): void {\n if (this.allowedTools.has(\"writeFile\")) {\n return;\n }\n\n throw new WorkspacePolicyError(\n `Operation \"${operation}\" is not permitted on a read-only workspace.`,\n { type: \"denied-command\", command: operation },\n );\n }\n\n public readFile(\n filePath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<{ content: string; hash: string; totalLines: number }> {\n return this.ops.readFile(filePath, opts);\n }\n\n public async writeFile(\n filePath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n this.assertWritable(\"writeFile\");\n\n return this.ops.writeFile(filePath, content);\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n this.assertWritable(\"editFile\");\n\n return this.ops.editFile(input);\n }\n\n public async exec(command: string, opts?: { timeoutMs?: number }): Promise<RunShellResult> {\n this.assertWritable(\"exec\");\n\n return this.ops.exec(command, opts);\n }\n\n public grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n return this.ops.grep(pattern, opts);\n }\n\n public glob(pattern: string): Promise<string[]> {\n return this.ops.glob(pattern);\n }\n\n public exists(filePath: string): Promise<boolean> {\n return this.ops.exists(filePath);\n }\n\n public async mkdir(filePath: string): Promise<void> {\n this.assertWritable(\"mkdir\");\n\n return this.ops.mkdir(filePath);\n }\n\n public async remove(filePath: string): Promise<void> {\n this.assertWritable(\"remove\");\n\n return this.ops.remove(filePath);\n }\n\n /**\n * A read-only projection over the SAME policy — only the read/grep/glob\n * tools are vended and every mutating direct method rejects with a\n * {@link WorkspacePolicyError}. A fresh ops/backend is built from the\n * identical policy, so the projection sees the same jailed tree.\n */\n public readonly(): Workspace {\n return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);\n }\n\n /**\n * A sub-jailed view rooted at `subdir` (relative to this workspace's\n * `cwd`). Returns a brand-new workspace whose policy is this policy\n * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,\n * same allow/deny/shell/read sub-policies, but a tighter jail root.\n */\n public scope(subdir: string): Workspace {\n return new WorkspaceImpl(\n { ...this.policy, cwd: path.join(this.policy.cwd, subdir) },\n [...this.allowedTools],\n );\n }\n}\n\n/**\n * Build a {@link Workspace} — the integrator that wires a\n * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops\n * layer, and the seven agent-facing tool factories.\n *\n * The backend is chosen from `policy.backend`: `\"mock\"` runs in memory\n * (hermetic tests); the `\"local\"` default (and any absent value) runs\n * over the real disk via `@warlock.js/fs` + `node:child_process`. The\n * returned workspace exposes:\n *\n * - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /\n * `runTests` / `grep` / `glob`, plus `all()` (every tool) and\n * `pick(...)` (a least-privilege subset).\n * - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /\n * `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1\n * to the shared ops layer.\n * - **`readonly()`** — a projection that vends only read/grep/glob and\n * rejects every mutating direct method.\n * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.\n *\n * Available at runtime as `ai.workspace(policy)` once this module is\n * imported (it registers the verb on the shared `ai` object).\n *\n * @param policy - The policy bounding the workspace (its `cwd` is the jail root).\n * @returns A fully-wired {@link Workspace}.\n *\n * @example\n * const ws = workspace({ cwd: \"/srv/acme-api\", shell: { allow: [\"npm\"], inheritEnv: [\"PATH\"] } });\n * const dev = ai.agent({ model, tools: ws.tools.all() });\n * await dev.execute(\"Make the failing cart-total suite green.\");\n *\n * @example\n * // Least-privilege reviewer — no write, no shell.\n * const reviewer = ai.agent({ model, tools: ws.readonly().tools.all() });\n */\nexport function workspace(policy: WorkspacePolicy): Workspace {\n return new WorkspaceImpl(policy);\n}\n\n/**\n * Attach the `workspace` verb to the `ai` namespace via module augmentation,\n * per the `ai.`-namespace convention. `@warlock.js/ai` now exposes a named `Ai`\n * interface for exactly this, so after a bare `import \"@warlock.js/ai-workspace\"`,\n * `ai.workspace(...)` is globally typed — no view/cast needed.\n */\ndeclare module \"@warlock.js/ai\" {\n interface Ai {\n /** Build a policy-jailed filesystem + shell {@link Workspace}. */\n workspace(policy: WorkspacePolicy): Workspace;\n }\n}\n\n// Runtime registration: attach `workspace` onto the shared `ai` object the\n// moment this package is imported (the augmentation above types it).\nai.workspace = workspace;\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA8BA,MAAM,iBAA+C;CACnD;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;AAQA,MAAM,sBAAoD;CACxD;CACA;CACA;AACF;;;;;;;AAQA,SAAS,cAAc,QAA2C;CAChE,IAAI,OAAO,YAAY,QACrB,OAAO,kBAAkB;CAG3B,OAAO,mBAAmB;AAC5B;;;;;;;;;;;;;;AAeA,IAAM,gBAAN,MAAM,cAAmC;CAWvC,AAAO,YACL,QACA,eAA6C,gBAC7C;EACA,KAAK,SAAS;EACd,KAAK,eAAe,IAAI,IAAI,YAAY;EAExC,MAAM,UAAU,cAAc,MAAM;EACpC,KAAK,MAAM,UAAU,SAAS,MAAM;EACpC,KAAK,QAAQ,KAAK,WAAW;CAC/B;;;;;;;;CASA,AAAQ,aAA6B;EASnC,MAAM,SACJ,aACiB;EAEnB,MAAM,YAGF;GACF,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,YAAY,SAAS,MAAM,kBAAkB,KAAK,KAAK,IAAI,CAAC;GAC5D,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,OAAO,SAAS,MAAM,aAAa,KAAK,KAAK,IAAI,CAAC;GAClD,OAAO,SAAS,MAAM,aAAa,KAAK,KAAK,IAAI,CAAC;EACpD;EAEA,MAAM,SAAS,MAAyB,SACtC,UAAU,KAAK,CAAC,IAAI;EAEtB,OAAO;GACL,WACE,eAAe,QAAQ,SAAS,KAAK,aAAa,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,SAChE,MAAM,IAAI,CACZ;GACF,OAAO,GAAG,UACR,MAAM,QAAQ,SAAS,KAAK,aAAa,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS,MAAM,IAAI,CAAC;GAC/E,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,YAAY,SAAS,MAAM,aAAa,IAAI;GAC5C,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,OAAO,SAAS,MAAM,QAAQ,IAAI;GAClC,OAAO,SAAS,MAAM,QAAQ,IAAI;EACpC;CACF;;;;;;CAOA,AAAQ,eAAe,WAAyB;EAC9C,IAAI,KAAK,aAAa,IAAI,WAAW,GACnC;EAGF,MAAM,IAAI,qBACR,cAAc,UAAU,+CACxB;GAAE,MAAM;GAAkB,SAAS;EAAU,CAC/C;CACF;CAEA,AAAO,SACL,UACA,MACgE;EAChE,OAAO,KAAK,IAAI,SAAS,UAAU,IAAI;CACzC;CAEA,MAAa,UACX,UACA,SACiD;EACjD,KAAK,eAAe,WAAW;EAE/B,OAAO,KAAK,IAAI,UAAU,UAAU,OAAO;CAC7C;CAEA,MAAa,SAAS,OAA+C;EACnE,KAAK,eAAe,UAAU;EAE9B,OAAO,KAAK,IAAI,SAAS,KAAK;CAChC;CAEA,MAAa,KAAK,SAAiB,MAAwD;EACzF,KAAK,eAAe,MAAM;EAE1B,OAAO,KAAK,IAAI,KAAK,SAAS,IAAI;CACpC;CAEA,AAAO,KACL,SACA,MACqB;EACrB,OAAO,KAAK,IAAI,KAAK,SAAS,IAAI;CACpC;CAEA,AAAO,KAAK,SAAoC;EAC9C,OAAO,KAAK,IAAI,KAAK,OAAO;CAC9B;CAEA,AAAO,OAAO,UAAoC;EAChD,OAAO,KAAK,IAAI,OAAO,QAAQ;CACjC;CAEA,MAAa,MAAM,UAAiC;EAClD,KAAK,eAAe,OAAO;EAE3B,OAAO,KAAK,IAAI,MAAM,QAAQ;CAChC;CAEA,MAAa,OAAO,UAAiC;EACnD,KAAK,eAAe,QAAQ;EAE5B,OAAO,KAAK,IAAI,OAAO,QAAQ;CACjC;;;;;;;CAQA,AAAO,WAAsB;EAC3B,OAAO,IAAI,cAAc,KAAK,QAAQ,mBAAmB;CAC3D;;;;;;;CAQA,AAAO,MAAM,QAA2B;EACtC,OAAO,IAAI,cACT;GAAE,GAAG,KAAK;GAAQ,KAAK,KAAK,KAAK,KAAK,OAAO,KAAK,MAAM;EAAE,GAC1D,CAAC,GAAG,KAAK,YAAY,CACvB;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,UAAU,QAAoC;CAC5D,OAAO,IAAI,cAAc,MAAM;AACjC;AAiBA,GAAG,YAAY"}
|
|
1
|
+
{"version":3,"file":"workspace.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/workspace.ts"],"sourcesContent":["import path from \"node:path\";\nimport { ai, type ToolContract } from \"@warlock.js/ai\";\nimport { createLocalBackend } from \"./backends/local\";\nimport { createMockBackend } from \"./backends/mock\";\nimport { WorkspacePolicyError } from \"./errors\";\nimport { createOps } from \"./ops\";\nimport { ShellJobs } from \"./shell-jobs\";\nimport { TodoStore } from \"./todos\";\nimport { makeEditFileTool } from \"./tools/edit-file\";\nimport { makeGlobTool } from \"./tools/glob\";\nimport { makeGrepTool } from \"./tools/grep\";\nimport { makeListDirTool } from \"./tools/list-dir\";\nimport { makeMultiEditTool } from \"./tools/multi-edit\";\nimport { makeReadFileTool } from \"./tools/read-file\";\nimport { makeRunShellTool } from \"./tools/run-shell\";\nimport { makeRunTestsTool } from \"./tools/run-tests\";\nimport { makeShellKillTool } from \"./tools/shell-kill\";\nimport { makeShellOutputTool } from \"./tools/shell-output\";\nimport { makeTodoReadTool, makeTodoWriteTool } from \"./tools/todo\";\nimport { makeWriteFileTool } from \"./tools/write-file\";\nimport type {\n EditFileInput,\n EditFileResult,\n ExecOptions,\n GrepResult,\n ListDirOptions,\n ListDirResult,\n MultiEditInput,\n MultiEditResult,\n ReadFileResult,\n RunShellResult,\n ShellJobInfo,\n ShellOutputOptions,\n ShellOutputResult,\n StartShellOptions,\n TodoListResult,\n Workspace,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n WorkspaceTodoInput,\n WorkspaceToolName,\n WorkspaceTools,\n} from \"./contracts\";\n\n/**\n * The full set of tool names a writable workspace vends, in a stable\n * canonical order so `tools.all()` is deterministic.\n */\nconst ALL_TOOL_NAMES: readonly WorkspaceToolName[] = [\n \"readFile\",\n \"editFile\",\n \"multiEdit\",\n \"writeFile\",\n \"listDir\",\n \"runShell\",\n \"shellOutput\",\n \"shellKill\",\n \"runTests\",\n \"grep\",\n \"glob\",\n \"todoWrite\",\n \"todoRead\",\n];\n\n/**\n * The subset a {@link Workspace.readonly} projection exposes — the\n * tools that cannot change the tree. `editFile` / `multiEdit` /\n * `writeFile` / the shell tools / `runTests` are deliberately omitted so a\n * reviewer agent has no path to change it. The todo tools stay: they only\n * touch the in-memory plan.\n */\nconst READONLY_TOOL_NAMES: readonly WorkspaceToolName[] = [\n \"readFile\",\n \"listDir\",\n \"grep\",\n \"glob\",\n \"todoWrite\",\n \"todoRead\",\n];\n\n/** Tools vended only when the policy enables background commands. */\nconst BACKGROUND_TOOL_NAMES: ReadonlySet<WorkspaceToolName> = new Set([\"shellOutput\", \"shellKill\"]);\n\n/**\n * State one workspace shares with every projection made from it: its\n * background commands and its todo list.\n */\ninterface WorkspaceState {\n jobs: ShellJobs;\n todos: TodoStore;\n}\n\n/**\n * Choose the dumb IO executor for a policy. `\"mock\"` selects the\n * in-memory backend (hermetic tests); anything else — including the\n * `\"local\"` default and an absent `backend` — selects the real-disk\n * local backend.\n */\nfunction selectBackend(policy: WorkspacePolicy): WorkspaceBackend {\n if (policy.backend === \"mock\") {\n return createMockBackend();\n }\n\n return createLocalBackend();\n}\n\n/**\n * The internal {@link Workspace} implementation. Holds the resolved\n * backend, the policy, and the single shared {@link WorkspaceOps} seam\n * that both the agent-facing `.tools.*` factories and the human-facing\n * direct methods funnel through — one jail, one rule set, two callers.\n *\n * The `allowedTools` set narrows what `tools.*` will vend and which\n * mutating direct methods are permitted: a full workspace allows every\n * name; a {@link WorkspaceImpl.readonly} projection allows only the\n * read/grep/glob subset and rejects writes/edits/shell/mkdir/remove.\n *\n * Constructed via {@link workspace}; the class itself is internal.\n */\nclass WorkspaceImpl implements Workspace {\n /** The shared, policy-enforced operation layer (jail + guards). */\n private readonly ops: WorkspaceOps;\n\n /** Tool names this projection is permitted to vend / mutate through. */\n private readonly allowedTools: ReadonlySet<WorkspaceToolName>;\n\n public readonly policy: WorkspacePolicy;\n\n public readonly tools: WorkspaceTools;\n\n /** Background commands + todo list, shared with projections. */\n private readonly state: WorkspaceState;\n\n public constructor(\n policy: WorkspacePolicy,\n allowedTools: readonly WorkspaceToolName[] = ALL_TOOL_NAMES,\n state?: WorkspaceState,\n ) {\n this.policy = policy;\n this.allowedTools = new Set(allowedTools);\n this.state = state ?? { jobs: new ShellJobs(), todos: new TodoStore() };\n\n const backend = selectBackend(policy);\n this.ops = createOps(backend, policy, { jobs: this.state.jobs });\n this.tools = this.buildTools();\n }\n\n /**\n * Assemble the agent-facing tool namespace. Each factory builds its\n * tool over the shared `ops`; `all()` returns every *allowed* tool in\n * canonical order and `pick(...)` returns the named subset (silently\n * dropping any name this projection does not allow, so a `readonly()`\n * workspace can never be coaxed into vending a mutating tool).\n */\n private buildTools(): WorkspaceTools {\n // Each `make*Tool` returns a precisely-typed\n // `ToolContract<SpecificInput, SpecificOutput>`, but the agent-facing\n // `WorkspaceTools` surface vends the type-erased `ToolContract`\n // (`ToolContract<unknown, unknown>`). Because `ToolContract` puts its\n // input in a contravariant position (`execute(input)` / `action(input)`),\n // a specific contract is not assignable to the erased one — so erase it\n // once, here, through `unknown`. The runtime object is identical; only\n // the static input type is widened for the shared surface.\n const erase = <TInput, TOutput>(contract: ToolContract<TInput, TOutput>): ToolContract =>\n contract as unknown as ToolContract;\n\n const shell = this.policy.shell;\n const background = shell?.background !== undefined;\n const interpreter = shell?.interpreter ?? \"none\";\n\n const factories: Record<\n WorkspaceToolName,\n (opts?: { name?: string; command?: string }) => ToolContract\n > = {\n readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),\n editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),\n multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),\n writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),\n listDir: (opts) => erase(makeListDirTool(this.ops, opts)),\n runShell: (opts) =>\n erase(\n makeRunShellTool(this.ops, {\n ...(opts?.name !== undefined ? { name: opts.name } : {}),\n ...(shell?.timeoutMs !== undefined ? { maxTimeoutMs: shell.timeoutMs } : {}),\n background,\n }),\n ),\n shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),\n shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),\n runTests: (opts) => erase(makeRunTestsTool(this.ops, { ...opts, interpreter })),\n grep: (opts) => erase(makeGrepTool(this.ops, opts)),\n glob: (opts) => erase(makeGlobTool(this.ops, opts)),\n todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),\n todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts)),\n };\n\n const build = (name: WorkspaceToolName, opts?: { name?: string; command?: string }) =>\n factories[name](opts);\n\n // `shell_output` / `shell_kill` only mean something when background\n // commands can be started, so `all()` leaves them out otherwise.\n const vendedByAll = (name: WorkspaceToolName): boolean =>\n this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));\n\n return {\n all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),\n pick: (...names: WorkspaceToolName[]) =>\n names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),\n readFile: (opts) => build(\"readFile\", opts),\n editFile: (opts) => build(\"editFile\", opts),\n multiEdit: (opts) => build(\"multiEdit\", opts),\n writeFile: (opts) => build(\"writeFile\", opts),\n listDir: (opts) => build(\"listDir\", opts),\n runShell: (opts) => build(\"runShell\", opts),\n shellOutput: (opts) => build(\"shellOutput\", opts),\n shellKill: (opts) => build(\"shellKill\", opts),\n runTests: (opts) => build(\"runTests\", opts),\n grep: (opts) => build(\"grep\", opts),\n glob: (opts) => build(\"glob\", opts),\n todoWrite: (opts) => build(\"todoWrite\", opts),\n todoRead: (opts) => build(\"todoRead\", opts),\n };\n }\n\n /**\n * Reject a mutating direct method on a read-only projection — surfaced\n * as a {@link WorkspacePolicyError} (the same typed error a denied\n * command produces) so a caller branches on `error.type`.\n */\n private assertWritable(operation: string): void {\n if (this.allowedTools.has(\"writeFile\")) {\n return;\n }\n\n throw new WorkspacePolicyError(\n `Operation \"${operation}\" is not permitted on a read-only workspace.`,\n { type: \"denied-command\", command: operation },\n );\n }\n\n public readFile(\n filePath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<ReadFileResult> {\n return this.ops.readFile(filePath, opts);\n }\n\n public async writeFile(\n filePath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n this.assertWritable(\"writeFile\");\n\n return this.ops.writeFile(filePath, content);\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n this.assertWritable(\"editFile\");\n\n return this.ops.editFile(input);\n }\n\n public async multiEdit(input: MultiEditInput): Promise<MultiEditResult> {\n this.assertWritable(\"multiEdit\");\n\n return this.ops.multiEdit(input);\n }\n\n public listDir(dirPath?: string, opts?: ListDirOptions): Promise<ListDirResult> {\n return this.ops.listDir(dirPath, opts);\n }\n\n public async exec(command: string, opts?: ExecOptions): Promise<RunShellResult> {\n this.assertWritable(\"exec\");\n\n return this.ops.exec(command, opts);\n }\n\n public async startShell(command: string, opts?: StartShellOptions): Promise<ShellJobInfo> {\n this.assertWritable(\"startShell\");\n\n return this.ops.startShell(command, opts);\n }\n\n public shellOutput(id: string, opts?: ShellOutputOptions): Promise<ShellOutputResult> {\n return this.ops.shellOutput(id, opts);\n }\n\n public async shellKill(id: string): Promise<ShellJobInfo> {\n this.assertWritable(\"shellKill\");\n\n return this.ops.shellKill(id);\n }\n\n public listShells(): ShellJobInfo[] {\n return this.ops.listShells();\n }\n\n public readTodos(): TodoListResult {\n return this.state.todos.read();\n }\n\n public writeTodos(todos: WorkspaceTodoInput[]): TodoListResult {\n return this.state.todos.write(todos);\n }\n\n public dispose(): Promise<void> {\n return this.ops.dispose();\n }\n\n public grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n return this.ops.grep(pattern, opts);\n }\n\n public glob(pattern: string): Promise<string[]> {\n return this.ops.glob(pattern);\n }\n\n public exists(filePath: string): Promise<boolean> {\n return this.ops.exists(filePath);\n }\n\n public async mkdir(filePath: string): Promise<void> {\n this.assertWritable(\"mkdir\");\n\n return this.ops.mkdir(filePath);\n }\n\n public async remove(filePath: string): Promise<void> {\n this.assertWritable(\"remove\");\n\n return this.ops.remove(filePath);\n }\n\n /**\n * A read-only projection over the SAME policy — only the read / list /\n * grep / glob / todo tools are vended and every mutating direct method\n * rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is\n * built from the identical policy, so the projection sees the same jailed\n * tree; it shares this workspace's background commands and todo list.\n */\n public readonly(): Workspace {\n return new WorkspaceImpl(\n this.policy,\n READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)),\n this.state,\n );\n }\n\n /**\n * A sub-jailed view rooted at `subdir` (relative to this workspace's\n * `cwd`). Returns a brand-new workspace whose policy is this policy\n * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,\n * same allow/deny/shell/read sub-policies, but a tighter jail root. It\n * shares this workspace's background commands and todo list.\n */\n public scope(subdir: string): Workspace {\n return new WorkspaceImpl(\n { ...this.policy, cwd: path.join(this.policy.cwd, subdir) },\n [...this.allowedTools],\n this.state,\n );\n }\n}\n\n/**\n * Build a {@link Workspace} — the integrator that wires a\n * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops\n * layer, and the agent-facing tool factories.\n *\n * The backend is chosen from `policy.backend`: `\"mock\"` runs in memory\n * (hermetic tests); the `\"local\"` default (and any absent value) runs\n * over the real disk via `@warlock.js/fs` + `node:child_process`. The\n * returned workspace exposes:\n *\n * - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /\n * `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /\n * `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)\n * and `pick(...)` (a least-privilege subset).\n * - **direct methods** — `readFile` / `writeFile` / `editFile` /\n * `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /\n * `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /\n * `remove` / `readTodos` / `writeTodos`, each delegating to the shared\n * ops layer, and `dispose()`.\n * - **`readonly()`** — a projection that vends only the read-side tools and\n * rejects every mutating direct method.\n * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.\n *\n * Available at runtime as `ai.workspace(policy)` once this module is\n * imported (it registers the verb on the shared `ai` object).\n *\n * @param policy - The policy bounding the workspace (its `cwd` is the jail root).\n * @returns A fully-wired {@link Workspace}.\n *\n * @example\n * const ws = workspace({ cwd: \"/srv/acme-api\", shell: { allow: [\"npm\"], inheritEnv: [\"PATH\"] } });\n * const dev = ai.agent({ model, tools: ws.tools.all() });\n * await dev.execute(\"Make the failing cart-total suite green.\");\n *\n * @example\n * // Least-privilege reviewer — no write, no shell.\n * const reviewer = ai.agent({ model, tools: ws.readonly().tools.all() });\n */\nexport function workspace(policy: WorkspacePolicy): Workspace {\n return new WorkspaceImpl(policy);\n}\n\n/**\n * Attach the `workspace` verb to the `ai` namespace via module augmentation,\n * per the `ai.`-namespace convention. `@warlock.js/ai` now exposes a named `Ai`\n * interface for exactly this, so after a bare `import \"@warlock.js/ai-workspace\"`,\n * `ai.workspace(...)` is globally typed — no view/cast needed.\n */\ndeclare module \"@warlock.js/ai\" {\n interface Ai {\n /** Build a policy-jailed filesystem + shell {@link Workspace}. */\n workspace(policy: WorkspacePolicy): Workspace;\n }\n}\n\n// Runtime registration: attach `workspace` onto the shared `ai` object the\n// moment this package is imported (the augmentation above types it).\nai.workspace = workspace;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,MAAM,iBAA+C;CACnD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;AASA,MAAM,sBAAoD;CACxD;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAM,wBAAwD,IAAI,IAAI,CAAC,eAAe,WAAW,CAAC;;;;;;;AAiBlG,SAAS,cAAc,QAA2C;CAChE,IAAI,OAAO,YAAY,QACrB,OAAO,kBAAkB;CAG3B,OAAO,mBAAmB;AAC5B;;;;;;;;;;;;;;AAeA,IAAM,gBAAN,MAAM,cAAmC;CAcvC,AAAO,YACL,QACA,eAA6C,gBAC7C,OACA;EACA,KAAK,SAAS;EACd,KAAK,eAAe,IAAI,IAAI,YAAY;EACxC,KAAK,QAAQ,SAAS;GAAE,MAAM,IAAI,UAAU;GAAG,OAAO,IAAI,UAAU;EAAE;EAEtE,MAAM,UAAU,cAAc,MAAM;EACpC,KAAK,MAAM,UAAU,SAAS,QAAQ,EAAE,MAAM,KAAK,MAAM,KAAK,CAAC;EAC/D,KAAK,QAAQ,KAAK,WAAW;CAC/B;;;;;;;;CASA,AAAQ,aAA6B;EASnC,MAAM,SAA0B,aAC9B;EAEF,MAAM,QAAQ,KAAK,OAAO;EAC1B,MAAM,aAAa,OAAO,eAAe;EACzC,MAAM,cAAc,OAAO,eAAe;EAE1C,MAAM,YAGF;GACF,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK,IAAI,CAAC;GAC1D,YAAY,SAAS,MAAM,kBAAkB,KAAK,KAAK,IAAI,CAAC;GAC5D,YAAY,SAAS,MAAM,kBAAkB,KAAK,KAAK,IAAI,CAAC;GAC5D,UAAU,SAAS,MAAM,gBAAgB,KAAK,KAAK,IAAI,CAAC;GACxD,WAAW,SACT,MACE,iBAAiB,KAAK,KAAK;IACzB,GAAI,MAAM,SAAS,SAAY,EAAE,MAAM,KAAK,KAAK,IAAI,CAAC;IACtD,GAAI,OAAO,cAAc,SAAY,EAAE,cAAc,MAAM,UAAU,IAAI,CAAC;IAC1E;GACF,CAAC,CACH;GACF,cAAc,SAAS,MAAM,oBAAoB,KAAK,KAAK,IAAI,CAAC;GAChE,YAAY,SAAS,MAAM,kBAAkB,KAAK,KAAK,IAAI,CAAC;GAC5D,WAAW,SAAS,MAAM,iBAAiB,KAAK,KAAK;IAAE,GAAG;IAAM;GAAY,CAAC,CAAC;GAC9E,OAAO,SAAS,MAAM,aAAa,KAAK,KAAK,IAAI,CAAC;GAClD,OAAO,SAAS,MAAM,aAAa,KAAK,KAAK,IAAI,CAAC;GAClD,YAAY,SAAS,MAAM,kBAAkB,KAAK,MAAM,OAAO,IAAI,CAAC;GACpE,WAAW,SAAS,MAAM,iBAAiB,KAAK,MAAM,OAAO,IAAI,CAAC;EACpE;EAEA,MAAM,SAAS,MAAyB,SACtC,UAAU,KAAK,CAAC,IAAI;EAItB,MAAM,eAAe,SACnB,KAAK,aAAa,IAAI,IAAI,MAAM,cAAc,CAAC,sBAAsB,IAAI,IAAI;EAE/E,OAAO;GACL,WAAW,eAAe,OAAO,WAAW,CAAC,CAAC,KAAK,SAAS,MAAM,IAAI,CAAC;GACvE,OAAO,GAAG,UACR,MAAM,QAAQ,SAAS,KAAK,aAAa,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS,MAAM,IAAI,CAAC;GAC/E,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,YAAY,SAAS,MAAM,aAAa,IAAI;GAC5C,YAAY,SAAS,MAAM,aAAa,IAAI;GAC5C,UAAU,SAAS,MAAM,WAAW,IAAI;GACxC,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,cAAc,SAAS,MAAM,eAAe,IAAI;GAChD,YAAY,SAAS,MAAM,aAAa,IAAI;GAC5C,WAAW,SAAS,MAAM,YAAY,IAAI;GAC1C,OAAO,SAAS,MAAM,QAAQ,IAAI;GAClC,OAAO,SAAS,MAAM,QAAQ,IAAI;GAClC,YAAY,SAAS,MAAM,aAAa,IAAI;GAC5C,WAAW,SAAS,MAAM,YAAY,IAAI;EAC5C;CACF;;;;;;CAOA,AAAQ,eAAe,WAAyB;EAC9C,IAAI,KAAK,aAAa,IAAI,WAAW,GACnC;EAGF,MAAM,IAAI,qBACR,cAAc,UAAU,+CACxB;GAAE,MAAM;GAAkB,SAAS;EAAU,CAC/C;CACF;CAEA,AAAO,SACL,UACA,MACyB;EACzB,OAAO,KAAK,IAAI,SAAS,UAAU,IAAI;CACzC;CAEA,MAAa,UACX,UACA,SACiD;EACjD,KAAK,eAAe,WAAW;EAE/B,OAAO,KAAK,IAAI,UAAU,UAAU,OAAO;CAC7C;CAEA,MAAa,SAAS,OAA+C;EACnE,KAAK,eAAe,UAAU;EAE9B,OAAO,KAAK,IAAI,SAAS,KAAK;CAChC;CAEA,MAAa,UAAU,OAAiD;EACtE,KAAK,eAAe,WAAW;EAE/B,OAAO,KAAK,IAAI,UAAU,KAAK;CACjC;CAEA,AAAO,QAAQ,SAAkB,MAA+C;EAC9E,OAAO,KAAK,IAAI,QAAQ,SAAS,IAAI;CACvC;CAEA,MAAa,KAAK,SAAiB,MAA6C;EAC9E,KAAK,eAAe,MAAM;EAE1B,OAAO,KAAK,IAAI,KAAK,SAAS,IAAI;CACpC;CAEA,MAAa,WAAW,SAAiB,MAAiD;EACxF,KAAK,eAAe,YAAY;EAEhC,OAAO,KAAK,IAAI,WAAW,SAAS,IAAI;CAC1C;CAEA,AAAO,YAAY,IAAY,MAAuD;EACpF,OAAO,KAAK,IAAI,YAAY,IAAI,IAAI;CACtC;CAEA,MAAa,UAAU,IAAmC;EACxD,KAAK,eAAe,WAAW;EAE/B,OAAO,KAAK,IAAI,UAAU,EAAE;CAC9B;CAEA,AAAO,aAA6B;EAClC,OAAO,KAAK,IAAI,WAAW;CAC7B;CAEA,AAAO,YAA4B;EACjC,OAAO,KAAK,MAAM,MAAM,KAAK;CAC/B;CAEA,AAAO,WAAW,OAA6C;EAC7D,OAAO,KAAK,MAAM,MAAM,MAAM,KAAK;CACrC;CAEA,AAAO,UAAyB;EAC9B,OAAO,KAAK,IAAI,QAAQ;CAC1B;CAEA,AAAO,KACL,SACA,MACqB;EACrB,OAAO,KAAK,IAAI,KAAK,SAAS,IAAI;CACpC;CAEA,AAAO,KAAK,SAAoC;EAC9C,OAAO,KAAK,IAAI,KAAK,OAAO;CAC9B;CAEA,AAAO,OAAO,UAAoC;EAChD,OAAO,KAAK,IAAI,OAAO,QAAQ;CACjC;CAEA,MAAa,MAAM,UAAiC;EAClD,KAAK,eAAe,OAAO;EAE3B,OAAO,KAAK,IAAI,MAAM,QAAQ;CAChC;CAEA,MAAa,OAAO,UAAiC;EACnD,KAAK,eAAe,QAAQ;EAE5B,OAAO,KAAK,IAAI,OAAO,QAAQ;CACjC;;;;;;;;CASA,AAAO,WAAsB;EAC3B,OAAO,IAAI,cACT,KAAK,QACL,oBAAoB,QAAQ,SAAS,KAAK,aAAa,IAAI,IAAI,CAAC,GAChE,KAAK,KACP;CACF;;;;;;;;CASA,AAAO,MAAM,QAA2B;EACtC,OAAO,IAAI,cACT;GAAE,GAAG,KAAK;GAAQ,KAAK,KAAK,KAAK,KAAK,OAAO,KAAK,MAAM;EAAE,GAC1D,CAAC,GAAG,KAAK,YAAY,GACrB,KAAK,KACP;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,UAAU,QAAoC;CAC5D,OAAO,IAAI,cAAc,MAAM;AACjC;AAiBA,GAAG,YAAY"}
|
package/llms-full.txt
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
name: build-loop-agent
|
|
11
|
-
description:
|
|
11
|
+
description: "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 — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill."
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Build a build-loop agent — read → edit → run tests, until green
|
|
@@ -27,8 +27,8 @@ const ws = ai.workspace({
|
|
|
27
27
|
|
|
28
28
|
const dev = ai.agent({
|
|
29
29
|
model,
|
|
30
|
-
tools: ws.tools.all(),
|
|
31
|
-
maxTrips: 25,
|
|
30
|
+
tools: ws.tools.all(), // read/edit/multi_edit/write, list_dir, run_shell, run_tests, grep, glob, todo_write/todo_read
|
|
31
|
+
maxTrips: 25, // give the loop room to iterate
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
|
|
@@ -51,7 +51,7 @@ Override the base command per agent: `ws.tools.runTests({ command: "vitest run"
|
|
|
51
51
|
|
|
52
52
|
### A read-only reviewer
|
|
53
53
|
|
|
54
|
-
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools
|
|
54
|
+
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools _and_ rejects the mutating direct methods):
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
57
|
const reviewer = ai.agent({
|
|
@@ -61,7 +61,7 @@ const reviewer = ai.agent({
|
|
|
61
61
|
await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
|
|
62
62
|
|
|
63
63
|
// Equivalent projection of the same jail:
|
|
64
|
-
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file
|
|
64
|
+
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file, list_dir, grep, glob, todo tools
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
### Scope an agent to one package
|
|
@@ -69,8 +69,8 @@ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glo
|
|
|
69
69
|
`scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
|
|
70
70
|
|
|
71
71
|
```ts
|
|
72
|
-
const apiDev
|
|
73
|
-
const webDev
|
|
72
|
+
const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
|
|
73
|
+
const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
## Scaffold from code, then hand off
|
|
@@ -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`, `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 — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
|
|
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`, `ws.multiEdit`, `ws.listDir`, `ws.startShell`, `ws.shellOutput`, `ws.shellKill`, `ws.dispose`, `ws.readTodos`, `ws.writeTodos`, `WorkspacePolicyError`, `WorkspaceEditError`, `WorkspaceJobError`, `WorkspaceShellRefusalReason`, `checkShellCommand`, `denyPaths`, `denyPatterns`, `allowCommand`, `interpreter`, `shell.background`, `list.ignore`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`multi_edit`/`write_file`/`list_dir`/`run_shell`/`shell_output`/`shell_kill`/`run_tests`/`grep`/`todo_write`/`todo_read`; ''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'', ''cap how many bytes a read returns'', ''cancel a running workspace command'', ''let the agent use pipes and &&'', ''why was my shell command refused'', ''deny git push --force'', ''run a dev server in the background'', ''atomic multi-edit'', ''list a directory for an agent'', ''todo list tool 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 — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
|
|
116
116
|
---
|
|
117
117
|
|
|
118
118
|
# Use a workspace — a policy jail for an agent's filesystem + shell
|
|
@@ -134,21 +134,29 @@ const ws = ai.workspace({
|
|
|
134
134
|
|
|
135
135
|
## A policy jail, NOT OS isolation
|
|
136
136
|
|
|
137
|
-
This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an
|
|
137
|
+
This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an _allowed_ process does once it runs.
|
|
138
|
+
|
|
139
|
+
Paths are resolved against `cwd` with symlinks collapsed and accepted inside `cwd` or an `allowPaths` root, off `denyPaths`; absolute paths inside the jail, links that stay inside and not-yet-existing paths (for writes) are accepted. To read a folder you do not trust from app code (no links at all, size and entry caps), use `fs.confined(root)` from `@warlock.js/fs` (the `read-a-folder-confined` topic) instead of a workspace.
|
|
140
|
+
|
|
141
|
+
**Shell mode (`interpreter: "sh"`) is a policy layer (defence in depth), not an OS sandbox.** It parses the line before `/bin/sh` runs it and refuses what it cannot read, but `/bin/sh` differs between systems (bash, dash, busybox ash), an allowed program (`node`, `npm`, `make`, a git hook) can still start anything the OS user can, and a symlink can change between the jail re-check and the write. For an untrusted agent (one that reads untrusted repos, issues, web pages or tool output), run the workspace in a container or microVM with only the project mounted, or at least as a separate unprivileged user, and keep the policy as a second layer. Prefer argv mode (the default) when the agent does not need pipes or redirections.
|
|
138
142
|
|
|
139
143
|
## The policy surface
|
|
140
144
|
|
|
141
|
-
| Field
|
|
142
|
-
|
|
143
|
-
| `cwd`
|
|
144
|
-
| `allowPaths`
|
|
145
|
-
| `denyPaths`
|
|
146
|
-
| `shell.allow` / `shell.deny`
|
|
147
|
-
| `shell.
|
|
148
|
-
| `shell.
|
|
149
|
-
| `shell.
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
145
|
+
| Field | What it does |
|
|
146
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
147
|
+
| `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
|
|
148
|
+
| `allowPaths` | Extra readable roots outside `cwd`. |
|
|
149
|
+
| `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
|
|
150
|
+
| `shell.allow` / `shell.deny` | Executable **basenames** matched against every command's argv (after tokenizing — see below). **Deny wins**, case-insensitively. An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
|
|
151
|
+
| `shell.denyPatterns` / `shell.allowCommand` | Argument rules on every command: string prefix patterns (`"git push --force"`) or RegExps, and a predicate `(argv, ctx) => true \| false \| "why"`. |
|
|
152
|
+
| `shell.interpreter` | `"none"` (default, argv) or `"sh"` (opt-in shell syntax, every command checked). |
|
|
153
|
+
| `shell.background` | `{ maxJobs?, timeoutMs? }` — enables background commands. Absent ⇒ refused. |
|
|
154
|
+
| `list.ignore` / `list.maxDepth` / `list.maxEntries` | `list_dir` bounds: hidden globs (default `node_modules`, `.git`), depth cap (5), entry cap (500). |
|
|
155
|
+
| `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`. |
|
|
156
|
+
| `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
|
|
157
|
+
| `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (process tree SIGKILLed on expiry) and stdout/stderr byte cap (clipped on a character boundary). |
|
|
158
|
+
| `read.defaultLines` / `read.maxBytes` | Default read window + a ceiling on the UTF-8 bytes of the returned `content` (line numbers included). See [Read windows and the byte ceiling](#read-windows-and-the-byte-ceiling). |
|
|
159
|
+
| `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
|
|
152
160
|
|
|
153
161
|
> **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
|
|
154
162
|
|
|
@@ -157,27 +165,64 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
|
|
|
157
165
|
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
166
|
|
|
159
167
|
```ts
|
|
160
|
-
await ws.exec("npm test");
|
|
161
|
-
await ws.exec('node -e "console.log(1)"');
|
|
162
|
-
await ws.exec("npm test; curl http://evil");
|
|
168
|
+
await ws.exec("npm test"); // OK — ["npm", "test"]
|
|
169
|
+
await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
|
|
170
|
+
await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
|
|
163
171
|
```
|
|
164
172
|
|
|
165
173
|
- **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
174
|
- **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
175
|
- **`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
|
-
-
|
|
176
|
+
- Shell conveniences (pipes, redirection, `&&` chaining, `$(...)` substitution) don't work in argv mode. The refusal (`reason: "metacharacter"`) names the operator and suggests the split — e.g. "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m "wip"`, as separate commands." — so the model retries correctly.
|
|
177
|
+
- **Wrappers are followed**: `env`, `time`, `timeout`, `nice`, `nohup`, `command`, `builtin`, `exec`, `find -exec` — the inner command is checked too. Runners the policy can't see into (`sh -c`, `bash`, `eval`, `source`, `xargs`, `sudo`, `setsid`, …) are refused with `reason: "nested-command"` while deny rules are in force, unless `allow` names them.
|
|
178
|
+
|
|
179
|
+
## Argument rules and shell mode
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { workspace, WorkspacePolicyError } from "@warlock.js/ai-workspace";
|
|
183
|
+
|
|
184
|
+
export async function commitWork(cwd: string) {
|
|
185
|
+
const ws = workspace({
|
|
186
|
+
cwd,
|
|
187
|
+
shell: {
|
|
188
|
+
interpreter: "sh", // opt in: pipes, &&, redirections — every command still checked
|
|
189
|
+
allow: ["git", "npm", "grep"],
|
|
190
|
+
denyPatterns: ["git push --force", /^git reset --hard\b/],
|
|
191
|
+
allowCommand: (argv) => (argv[0] === "git" && argv[1] === "push" ? "CI pushes." : true),
|
|
192
|
+
inheritEnv: ["PATH"],
|
|
193
|
+
timeoutMs: 120_000,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await ws.exec("git add -A && git commit -m 'wip'");
|
|
199
|
+
return await ws.exec("npm test 2>&1 | grep -i fail > logs/failures.txt");
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
|
|
202
|
+
console.warn(error.reason, error.message); // e.g. "denied-pattern", "path-escape"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- Shell mode parses the line and checks every simple command — inside `;`, `&&`, `||`, pipes, `( )`, `$( )`, backticks, here-documents and `NAME=value cmd` prefixes. Redirection targets and `cd` directories must be literal paths inside the jail (`/dev/null` is fine), re-checked with symlinks resolved.
|
|
211
|
+
- It refuses what it can't parse with confidence (`reason: "unsupported-syntax"`): compound commands, arithmetic, brace expansion, unusual `${…}` forms, bash-only redirections, a command name decided at run time, `&` in a line (use `background: true`), assignments to `IFS`/`PS4`/`CDPATH`/`BASH_ENV`…; it drops shell start-up variables (`ENV`, `BASH_ENV`, `SHELLOPTS`, `BASH_FUNC_*`, …) from the environment.
|
|
212
|
+
- `reason` values: `"metacharacter"`, `"unbalanced-quotes"`, `"denied-executable"`, `"not-allowed"`, `"denied-pattern"`, `"nested-command"`, `"unsupported-syntax"`, `"path-escape"`. `checkShellCommand(policy, line)` returns the verdict without running anything.
|
|
213
|
+
- Pair shell mode with an exhaustive `allow`: `deny` alone is a best-effort filter.
|
|
169
214
|
|
|
170
215
|
## Two callers, one jail
|
|
171
216
|
|
|
172
217
|
### Agent-facing — `ws.tools.*`
|
|
173
218
|
|
|
174
|
-
Each tool is a `ToolContract` ready for `ai.agent({ tools })`. The
|
|
219
|
+
Each tool is a `ToolContract` (with a JSON Schema of its input) ready for `ai.agent({ tools })`. The LLM-visible tool names are `read_file`, `edit_file`, `multi_edit`, `write_file`, `list_dir`, `run_shell`, `shell_output`, `shell_kill`, `run_tests`, `grep`, `glob`, `todo_write`, `todo_read` — `shell_output` / `shell_kill` only in `all()` when `shell.background` is set.
|
|
175
220
|
|
|
176
221
|
```ts
|
|
177
|
-
ws.tools.all();
|
|
178
|
-
ws.tools.pick("readFile", "grep", "glob");
|
|
179
|
-
ws.tools.readFile({ name: "open" });
|
|
180
|
-
ws.tools.runTests({ command: "vitest run" });
|
|
222
|
+
ws.tools.all(); // every tool, canonical order
|
|
223
|
+
ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
|
|
224
|
+
ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
|
|
225
|
+
ws.tools.runTests({ command: "vitest run" }); // override the test base command
|
|
181
226
|
```
|
|
182
227
|
|
|
183
228
|
### Human-facing — direct methods
|
|
@@ -185,10 +230,13 @@ ws.tools.runTests({ command: "vitest run" }); // override the test base command
|
|
|
185
230
|
The same policy seam, callable from code (no LLM):
|
|
186
231
|
|
|
187
232
|
```ts
|
|
188
|
-
const { content, hash, totalLines } = await ws.readFile(
|
|
233
|
+
const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile(
|
|
234
|
+
"src/index.ts",
|
|
235
|
+
{ offset: 1, limit: 200 },
|
|
236
|
+
);
|
|
189
237
|
await ws.writeFile("src/new.ts", "export const x = 1;\n");
|
|
190
238
|
await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
|
|
191
|
-
const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
|
|
239
|
+
const { exitCode, stdout, stderr, timedOut, cancelled, truncated } = await ws.exec("npm test");
|
|
192
240
|
const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
|
|
193
241
|
const paths = await ws.glob("src/models/**/*.ts");
|
|
194
242
|
await ws.exists("package.json");
|
|
@@ -196,14 +244,95 @@ await ws.mkdir("src/generated");
|
|
|
196
244
|
await ws.remove("dist");
|
|
197
245
|
```
|
|
198
246
|
|
|
247
|
+
## The coding-agent tool belt
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { workspace } from "@warlock.js/ai-workspace";
|
|
251
|
+
|
|
252
|
+
export async function devLoop(cwd: string) {
|
|
253
|
+
const ws = workspace({
|
|
254
|
+
cwd,
|
|
255
|
+
list: { maxEntries: 300 },
|
|
256
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], background: { maxJobs: 2 } },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const { entries } = await ws.listDir("src", { depth: 2 }); // bounded, jailed, node_modules/.git hidden
|
|
261
|
+
console.info(entries.map((entry) => `${entry.type} ${entry.path}`));
|
|
262
|
+
|
|
263
|
+
const { hash } = await ws.readFile("src/cart.ts");
|
|
264
|
+
await ws.multiEdit({
|
|
265
|
+
path: "src/cart.ts",
|
|
266
|
+
expectHash: hash,
|
|
267
|
+
edits: [
|
|
268
|
+
{ oldString: "let total = 0;", newString: "let total = 0n;" },
|
|
269
|
+
{ oldString: "total += price;", newString: "total += BigInt(price);" },
|
|
270
|
+
], // all apply, or none (WorkspaceEditError.editIndex names the failing one)
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const server = await ws.startShell("npm run dev"); // run_shell({ command, background: true })
|
|
274
|
+
const { stdout, cursor } = await ws.shellOutput(server.id, { waitMs: 5_000 });
|
|
275
|
+
console.info(stdout, cursor); // pass `since: cursor` to read only what comes next
|
|
276
|
+
await ws.shellKill(server.id); // kills the whole process tree
|
|
277
|
+
|
|
278
|
+
ws.writeTodos([{ content: "Fix the rounding bug", status: "completed" }]); // todo_write
|
|
279
|
+
return ws.readTodos().counts; // todo_read
|
|
280
|
+
} finally {
|
|
281
|
+
await ws.dispose(); // kills every background command; the workspace refuses new commands
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- `list_dir({ path?, depth?, ignore? })` → `{ path, entries: [{ path, type, size?, depth }], truncated }`; `denyPaths` always hidden, `list.ignore` (default `node_modules`, `.git`) hidden from listings, symlinks reported but never followed.
|
|
287
|
+
- `multi_edit({ path, edits, expectHash? })` → `{ path, edits, replacements, hash }`; same uniqueness rules as `edit_file`, at most 100 edits.
|
|
288
|
+
- Background: `shell_output({ id, since?, waitMs? })` → `{ stdout, stderr, cursor, droppedBytes, truncated, status, exitCode }`; only the newest `shell.maxOutputBytes` per command are kept. States `"running"`, `"exited"`, `"killed"`, `"timed-out"`. Unknown id → `WorkspaceJobError`. Background commands outlive the agent run; `dispose()` (or process exit) kills them. Only processes the workspace spawned are ever signalled.
|
|
289
|
+
- Todos: one in-memory list per workspace, shared with `readonly()` / `scope()`; `todo_write` replaces the whole list (≤ 100 items, status `pending` / `in_progress` / `completed`).
|
|
290
|
+
- `run_shell`'s model-supplied `timeoutMs` can only lower `shell.timeoutMs`.
|
|
291
|
+
|
|
199
292
|
## `grep` is ReDoS-bounded
|
|
200
293
|
|
|
201
294
|
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
295
|
|
|
296
|
+
## Read windows and the byte ceiling
|
|
297
|
+
|
|
298
|
+
`readFile()` and the `read_file` tool return the same result for the same arguments: a `cat -n` numbered window plus `{ startLine, endLine, totalLines, totalBytes, truncated, bytesCapped, hash }`.
|
|
299
|
+
|
|
300
|
+
- The window starts at `offset` (`startLine` on the tool) and holds at most `limit` lines (default `read.defaultLines`, else 2000).
|
|
301
|
+
- `read.maxBytes` caps the UTF-8 bytes of the returned `content` itself — line-number prefixes and newlines count. Whole lines are kept while they fit; the first line that doesn't is cut on a **character boundary** (never mid-character), so a single very long line still comes back bounded. `bytesCapped` is `true` whenever the cap cut anything, and `endLine` is the last line included, possibly partially.
|
|
302
|
+
- `truncated` is `true` when anything after `content` was withheld — later lines (by `limit`, the default window or the cap) or the tail of `endLine`.
|
|
303
|
+
- `hash` is always the SHA-256 of the **whole file**, not the window, so it is a valid `expectHash` whatever was returned. `totalBytes` is the whole file's size.
|
|
304
|
+
- No `maxBytes` ⇒ no byte cap (only the line window applies).
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
const ws = ai.workspace({ cwd, read: { maxBytes: 64_000, defaultLines: 400 } });
|
|
308
|
+
const page = await ws.readFile("dist/bundle.js");
|
|
309
|
+
if (page.bytesCapped) console.warn(`Only part of line ${page.endLine} fit in the read budget.`);
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Cancelling a command
|
|
313
|
+
|
|
314
|
+
`exec()` accepts a per-call `signal`; `run_shell` and `run_tests` pass the agent run's signal (the dispatch `ctx.signal`) automatically, so cancelling the agent run stops the command it is waiting on.
|
|
315
|
+
|
|
316
|
+
- Already aborted ⇒ the command is **not started** (`exitCode: 130`).
|
|
317
|
+
- Aborted mid-run ⇒ the whole process tree is SIGKILLed, as on a timeout.
|
|
318
|
+
- Either way the result says `cancelled: true` — it resolves, it doesn't throw — and `cancelled` and `timedOut` are never both `true`. Branch on those flags, not on the exit code.
|
|
319
|
+
- The signal belongs to that one call; the workspace never stores it, so one workspace can serve many runs. The abort listener is removed once the command settles.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const controller = new AbortController();
|
|
323
|
+
const pending = ws.exec("npm test", { signal: controller.signal, timeoutMs: 120_000 });
|
|
324
|
+
controller.abort();
|
|
325
|
+
const { cancelled } = await pending; // true
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
A custom `WorkspaceBackend` receives the signal as `exec(command, { signal })` and should follow the same rules, reporting `cancelled: true` in its result.
|
|
329
|
+
|
|
203
330
|
## Read-before-edit (the stale-hash guard)
|
|
204
331
|
|
|
205
332
|
`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.
|
|
206
333
|
|
|
334
|
+
Writes, edits and removes of the same path run one after another, even when the agent dispatches tool calls concurrently: of two edits pinned to the same `expectHash`, the second is rejected as stale instead of silently overwriting the first. `read.maxBytes` bounds what a read returns, not memory — the whole file is still read and hashed.
|
|
335
|
+
|
|
207
336
|
## Errors flow as data, not throws
|
|
208
337
|
|
|
209
338
|
Policy and edit failures are typed errors extending the `@warlock.js/ai` `AIError` base. Inside a tool the `tool()` wrapper catches them and surfaces them in `{ error }`, so the agent reads the failure and self-corrects. Branch on `.type`:
|
|
@@ -220,17 +349,18 @@ try {
|
|
|
220
349
|
}
|
|
221
350
|
```
|
|
222
351
|
|
|
223
|
-
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (
|
|
224
|
-
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"
|
|
352
|
+
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (the shell policy refused the line — `reason` says why) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
|
|
353
|
+
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"` (+ `editIndex` for `multiEdit`).
|
|
354
|
+
- `WorkspaceJobError.type`: `"not-found"` (unknown background command id).
|
|
225
355
|
|
|
226
356
|
## Composition — `readonly()` and `scope()`
|
|
227
357
|
|
|
228
358
|
```ts
|
|
229
|
-
const ro = ws.readonly();
|
|
359
|
+
const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
|
|
230
360
|
const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
|
|
231
361
|
```
|
|
232
362
|
|
|
233
|
-
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file`
|
|
363
|
+
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file`, `list_dir`, `grep`, `glob` and the todo tools only, and `writeFile` / `editFile` / `multiEdit` / `exec` / `startShell` / `shellKill` / `mkdir` / `remove` throw a `WorkspacePolicyError`. Projections share the workspace's background commands and todo list. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
|
|
234
364
|
|
|
235
365
|
## Hermetic tests with the mock backend
|
|
236
366
|
|
|
@@ -247,4 +377,31 @@ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["np
|
|
|
247
377
|
- The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes `ws.tools.all()`.
|
|
248
378
|
- The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every workspace tool is built on.
|
|
249
379
|
|
|
380
|
+
## Complete bounded-read and cancellation example
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { workspace } from "@warlock.js/ai-workspace";
|
|
384
|
+
|
|
385
|
+
export async function inspectWorkspace(cwd: string) {
|
|
386
|
+
const ws = workspace({
|
|
387
|
+
cwd,
|
|
388
|
+
read: { maxBytes: 64_000, defaultLines: 400 },
|
|
389
|
+
shell: { allow: ["node"], inheritEnv: ["PATH"] },
|
|
390
|
+
});
|
|
391
|
+
const page = await ws.readFile("package.json");
|
|
392
|
+
console.info(page.bytesCapped, page.endLine, page.hash);
|
|
393
|
+
|
|
394
|
+
const controller = new AbortController();
|
|
395
|
+
controller.abort();
|
|
396
|
+
const result = await ws.exec('node -e "console.log(1)"', { signal: controller.signal });
|
|
397
|
+
if (result.cancelled) console.info("Command cancelled before it started");
|
|
398
|
+
return page;
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The caller supplies an absolute existing workspace root. The already-aborted
|
|
403
|
+
signal demonstrates the no-spawn path; a later abort also kills a running
|
|
404
|
+
command's process tree. The byte ceiling limits returned content, while the
|
|
405
|
+
hash still describes the whole file.
|
|
406
|
+
|
|
250
407
|
|
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 — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.
|
|
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 — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.
|
|
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`, `ws.multiEdit`, `ws.listDir`, `ws.startShell`, `ws.shellOutput`, `ws.shellKill`, `ws.dispose`, `ws.readTodos`, `ws.writeTodos`, `WorkspacePolicyError`, `WorkspaceEditError`, `WorkspaceJobError`, `WorkspaceShellRefusalReason`, `checkShellCommand`, `denyPaths`, `denyPatterns`, `allowCommand`, `interpreter`, `shell.background`, `list.ignore`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`multi_edit`/`write_file`/`list_dir`/`run_shell`/`shell_output`/`shell_kill`/`run_tests`/`grep`/`todo_write`/`todo_read`; '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', 'cap how many bytes a read returns', 'cancel a running workspace command', 'let the agent use pipes and &&', 'why was my shell command refused', 'deny git push --force', 'run a dev server in the background', 'atomic multi-edit', 'list a directory for an agent', 'todo list tool 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 — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.
|
package/package.json
CHANGED
|
@@ -15,16 +15,17 @@
|
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/warlockjs/
|
|
18
|
+
"url": "git+https://github.com/warlockjs/framework.git",
|
|
19
|
+
"directory": "packages/ai-workspace"
|
|
19
20
|
},
|
|
20
21
|
"peerDependencies": {
|
|
21
|
-
"@warlock.js/ai": "5.
|
|
22
|
+
"@warlock.js/ai": "5.31.0"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@standard-schema/spec": "^1.0.0",
|
|
25
|
-
"@warlock.js/fs": "5.
|
|
26
|
+
"@warlock.js/fs": "5.31.0"
|
|
26
27
|
},
|
|
27
|
-
"version": "5.
|
|
28
|
+
"version": "5.31.0",
|
|
28
29
|
"main": "./cjs/index.cjs",
|
|
29
30
|
"module": "./esm/index.mjs",
|
|
30
31
|
"types": "./esm/index.d.mts",
|
package/skills/README.md
CHANGED
|
@@ -6,8 +6,8 @@ Per-task skills. Cross-references name the topic ("the `<topic>` topic"), and fo
|
|
|
6
6
|
|
|
7
7
|
### `use-a-workspace`
|
|
8
8
|
|
|
9
|
-
Build a policy-jailed filesystem + shell workspace with `ai.workspace(policy)` and operate it from both ends — the agent-facing `ws.tools.*` (
|
|
9
|
+
Build a policy-jailed filesystem + shell workspace with `ai.workspace(policy)` and operate it from both ends — the agent-facing `ws.tools.*` (files, `list_dir`, `multi_edit`, `run_shell` with background commands, `shell_output` / `shell_kill`, `run_tests`, `grep`, `glob`, `todo_write` / `todo_read`, plus `all()` / `pick(...)`) and the human-facing direct methods (`readFile` / `writeFile` / `editFile` / `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` / `shellKill` / `grep` / `glob` / `exists` / `mkdir` / `remove` / `readTodos` / `writeTodos` / `dispose`). Covers the policy surface (the `cwd` jail, `allowPaths` / `denyPaths`, the fail-closed shell allow/deny + `denyPatterns` / `allowCommand` + `inheritEnv`, the opt-in `interpreter: "sh"` shell mode and its refusal reasons, read caps, list bounds), the local vs mock backend, the read-before-edit stale-hash guard, the typed `WorkspacePolicyError` / `WorkspaceEditError`, and the `readonly()` / `scope(subdir)` projections. Load when constructing a workspace, choosing a policy, vending tools to an agent, or driving the filesystem programmatically.
|
|
10
10
|
|
|
11
11
|
### `build-loop-agent`
|
|
12
12
|
|
|
13
|
-
Wire a workspace into a coding agent that closes the read → edit → run-tests loop on its own — `ai.agent({ tools: ws.tools.all() })` so a failing suite comes back as tool
|
|
13
|
+
Wire a workspace into a coding agent that closes the read → edit → run-tests loop on its own — `ai.agent({ tools: ws.tools.all() })` so a failing suite comes back as tool _data_ and the model self-corrects until green. Covers the minimal build-loop wiring, why errors-as-data drives the loop, the shell policy a test command needs (`shell.allow` + `inheritEnv: ["PATH"]`), the read-only reviewer via `tools.pick` / `readonly()`, scaffolding from code before handing off, and sharing one jail across a supervisor's members. Load when building an agent that edits a repo, runs tests, reviews code, or scaffolds then hands off.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: build-loop-agent
|
|
3
|
-
description:
|
|
3
|
+
description: "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 — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Build a build-loop agent — read → edit → run tests, until green
|
|
@@ -19,8 +19,8 @@ const ws = ai.workspace({
|
|
|
19
19
|
|
|
20
20
|
const dev = ai.agent({
|
|
21
21
|
model,
|
|
22
|
-
tools: ws.tools.all(),
|
|
23
|
-
maxTrips: 25,
|
|
22
|
+
tools: ws.tools.all(), // read/edit/multi_edit/write, list_dir, run_shell, run_tests, grep, glob, todo_write/todo_read
|
|
23
|
+
maxTrips: 25, // give the loop room to iterate
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
|
|
@@ -43,7 +43,7 @@ Override the base command per agent: `ws.tools.runTests({ command: "vitest run"
|
|
|
43
43
|
|
|
44
44
|
### A read-only reviewer
|
|
45
45
|
|
|
46
|
-
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools
|
|
46
|
+
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools _and_ rejects the mutating direct methods):
|
|
47
47
|
|
|
48
48
|
```ts
|
|
49
49
|
const reviewer = ai.agent({
|
|
@@ -53,7 +53,7 @@ const reviewer = ai.agent({
|
|
|
53
53
|
await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
|
|
54
54
|
|
|
55
55
|
// Equivalent projection of the same jail:
|
|
56
|
-
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file
|
|
56
|
+
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file, list_dir, grep, glob, todo tools
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
### Scope an agent to one package
|
|
@@ -61,8 +61,8 @@ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glo
|
|
|
61
61
|
`scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
|
-
const apiDev
|
|
65
|
-
const webDev
|
|
64
|
+
const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
|
|
65
|
+
const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
## Scaffold from code, then hand off
|
package/skills/index.md
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Policy-jailed filesystem and shell access for @warlock.js/ai agents, for coding and build-loop agents. Exports `workspace`, `makeEditFileTool`, `makeRunShellTool`, `makeRunTestsTool`, `createLocalBackend`, `createMockBackend`, `WorkspacePolicyError`. Use for: let an agent read, edit or write files, run shell commands or tests safely, sandbox an agent to a directory, build a coding agent that loops until tests pass, test file tools with a fake backend. Not this package: web, HTTP or MCP tools → @warlock.js/ai-tools; agents and custom tools → @warlock.js/ai; tracing runs → @warlock.js/ai-panoptic."
|
|
3
3
|
---
|
|
4
|
+
|
|
4
5
|
# @warlock.js/ai-workspace
|
|
5
6
|
|
|
6
|
-
A workspace is a policy jail around a directory: it decides which paths an agent may read or write and which shell commands may run, then exposes read, write, edit, glob, grep, shell
|
|
7
|
+
A workspace is a policy jail around a directory: it decides which paths an agent may read or write and which shell commands may run, then exposes read, write, edit, multi-edit, list, glob, grep, shell (argv by default, opt-in `sh`, background commands), test and todo tools bound to that policy. Importing the package registers `ai.workspace(...)` on the shared `ai` object.
|
|
7
8
|
|
|
8
9
|
## The 80% path
|
|
10
|
+
|
|
9
11
|
1. Create a workspace with a root and policy (`use-a-workspace.md`).
|
|
10
12
|
2. Hand its tools to an `ai.agent()`.
|
|
11
13
|
3. For a coding agent, follow the read → edit → run tests loop until green (`build-loop-agent.md`).
|
|
12
14
|
|
|
13
15
|
## Conventions and pitfalls
|
|
16
|
+
|
|
14
17
|
- The policy is the security boundary: keep the root narrow and the shell allowlist explicit; violations throw `WorkspacePolicyError`.
|
|
15
18
|
- Paths resolve inside the jail; escaping it via `..` or symlinks is rejected on purpose.
|
|
16
19
|
- The local backend runs real processes. Use `createMockBackend` in tests so no disk or shell is touched.
|
|
20
|
+
- Shell refusals carry a `reason` and a message that says how to split the command; `interpreter: "sh"` is an explicit opt-in that still checks every command in the line.
|
|
21
|
+
- Call `ws.dispose()` when done: it kills background commands (whole process trees).
|
|
17
22
|
- Edits fail loudly (`WorkspaceEditError`) when the target text is not found; re-read the file rather than retrying blindly.
|