@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +40 -21
  3. package/cjs/index.cjs +3295 -360
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -1
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +199 -31
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +12 -6
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +45 -3
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +3 -3
  14. package/esm/contracts/tool-io.type.d.mts +248 -11
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +78 -2
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +41 -16
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +142 -14
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +75 -22
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +80 -6
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +23 -2
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +13 -5
  29. package/esm/index.mjs +10 -2
  30. package/esm/ops.d.mts +22 -3
  31. package/esm/ops.d.mts.map +1 -1
  32. package/esm/ops.mjs +341 -82
  33. package/esm/ops.mjs.map +1 -1
  34. package/esm/policy/jail.mjs +73 -0
  35. package/esm/policy/jail.mjs.map +1 -0
  36. package/esm/policy/policy.d.mts +26 -16
  37. package/esm/policy/policy.d.mts.map +1 -1
  38. package/esm/policy/policy.mjs +62 -86
  39. package/esm/policy/policy.mjs.map +1 -1
  40. package/esm/policy/shell-check.d.mts +64 -0
  41. package/esm/policy/shell-check.d.mts.map +1 -0
  42. package/esm/policy/shell-check.mjs +627 -0
  43. package/esm/policy/shell-check.mjs.map +1 -0
  44. package/esm/policy/shell-parser.mjs +802 -0
  45. package/esm/policy/shell-parser.mjs.map +1 -0
  46. package/esm/policy/tokenize-command.d.mts +63 -0
  47. package/esm/policy/tokenize-command.d.mts.map +1 -0
  48. package/esm/policy/tokenize-command.mjs +76 -24
  49. package/esm/policy/tokenize-command.mjs.map +1 -1
  50. package/esm/shell-jobs.d.mts +45 -0
  51. package/esm/shell-jobs.d.mts.map +1 -0
  52. package/esm/shell-jobs.mjs +247 -0
  53. package/esm/shell-jobs.mjs.map +1 -0
  54. package/esm/todos.d.mts +22 -0
  55. package/esm/todos.d.mts.map +1 -0
  56. package/esm/todos.mjs +73 -0
  57. package/esm/todos.mjs.map +1 -0
  58. package/esm/tools/edit-file.d.mts +1 -1
  59. package/esm/tools/edit-file.d.mts.map +1 -1
  60. package/esm/tools/edit-file.mjs +3 -2
  61. package/esm/tools/edit-file.mjs.map +1 -1
  62. package/esm/tools/glob.d.mts +1 -1
  63. package/esm/tools/glob.d.mts.map +1 -1
  64. package/esm/tools/glob.mjs +3 -2
  65. package/esm/tools/glob.mjs.map +1 -1
  66. package/esm/tools/grep.d.mts +1 -1
  67. package/esm/tools/grep.d.mts.map +1 -1
  68. package/esm/tools/grep.mjs +3 -2
  69. package/esm/tools/grep.mjs.map +1 -1
  70. package/esm/tools/json-schemas.mjs +175 -0
  71. package/esm/tools/json-schemas.mjs.map +1 -0
  72. package/esm/tools/list-dir.d.mts +32 -0
  73. package/esm/tools/list-dir.d.mts.map +1 -0
  74. package/esm/tools/list-dir.mjs +50 -0
  75. package/esm/tools/list-dir.mjs.map +1 -0
  76. package/esm/tools/multi-edit.d.mts +38 -0
  77. package/esm/tools/multi-edit.d.mts.map +1 -0
  78. package/esm/tools/multi-edit.mjs +121 -0
  79. package/esm/tools/multi-edit.mjs.map +1 -0
  80. package/esm/tools/read-file.d.mts +5 -5
  81. package/esm/tools/read-file.d.mts.map +1 -1
  82. package/esm/tools/read-file.mjs +10 -20
  83. package/esm/tools/read-file.mjs.map +1 -1
  84. package/esm/tools/run-shell.d.mts +39 -13
  85. package/esm/tools/run-shell.d.mts.map +1 -1
  86. package/esm/tools/run-shell.mjs +60 -47
  87. package/esm/tools/run-shell.mjs.map +1 -1
  88. package/esm/tools/run-tests.d.mts +11 -2
  89. package/esm/tools/run-tests.d.mts.map +1 -1
  90. package/esm/tools/run-tests.mjs +15 -7
  91. package/esm/tools/run-tests.mjs.map +1 -1
  92. package/esm/tools/schema.mjs +24 -19
  93. package/esm/tools/schema.mjs.map +1 -1
  94. package/esm/tools/shell-kill.d.mts +25 -0
  95. package/esm/tools/shell-kill.d.mts.map +1 -0
  96. package/esm/tools/shell-kill.mjs +36 -0
  97. package/esm/tools/shell-kill.mjs.map +1 -0
  98. package/esm/tools/shell-output.d.mts +30 -0
  99. package/esm/tools/shell-output.d.mts.map +1 -0
  100. package/esm/tools/shell-output.mjs +49 -0
  101. package/esm/tools/shell-output.mjs.map +1 -0
  102. package/esm/tools/todo.d.mts +43 -0
  103. package/esm/tools/todo.d.mts.map +1 -0
  104. package/esm/tools/todo.mjs +126 -0
  105. package/esm/tools/todo.mjs.map +1 -0
  106. package/esm/tools/write-file.d.mts +1 -1
  107. package/esm/tools/write-file.d.mts.map +1 -1
  108. package/esm/tools/write-file.mjs +3 -2
  109. package/esm/tools/write-file.mjs.map +1 -1
  110. package/esm/workspace.d.mts +12 -9
  111. package/esm/workspace.d.mts.map +1 -1
  112. package/esm/workspace.mjs +109 -28
  113. package/esm/workspace.mjs.map +1 -1
  114. package/llms-full.txt +192 -35
  115. package/llms.txt +1 -1
  116. package/package.json +5 -4
  117. package/skills/README.md +2 -2
  118. package/skills/build-loop-agent/SKILL.md +7 -7
  119. package/skills/index.md +6 -1
  120. package/skills/use-a-workspace/SKILL.md +185 -28
@@ -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":"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"}
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":"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"}
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ---
10
10
  name: build-loop-agent
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.'
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(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
31
- maxTrips: 25, // give the loop room to iterate
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 *and* rejects the mutating direct methods):
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 + grep + glob only
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 = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
73
- const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
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 *allowed* process does once it runs. Real isolation (container / microVM) is a later phase.
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 | What it does |
142
- |---|---|
143
- | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
144
- | `allowPaths` | Extra readable roots outside `cwd`. |
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 argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
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
- | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
149
- | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
150
- | `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
151
- | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
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"); // 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
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
- - 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.
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 seven LLM-visible tool names are `read_file`, `edit_file`, `write_file`, `run_shell`, `run_tests`, `grep`, `glob`.
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(); // all seven, canonical order
178
- ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
179
- ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
180
- ws.tools.runTests({ command: "vitest run" }); // override the test base command
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("src/index.ts", { offset: 1, limit: 200 });
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"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
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(); // vends only read/grep/glob; mutating direct methods reject
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` + `grep` + `glob` only, and `writeFile` / `editFile` / `exec` / `mkdir` / `remove` throw a `WorkspacePolicyError`. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
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/ai-workspace"
18
+ "url": "git+https://github.com/warlockjs/framework.git",
19
+ "directory": "packages/ai-workspace"
19
20
  },
20
21
  "peerDependencies": {
21
- "@warlock.js/ai": "5.29.0"
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.29.0"
26
+ "@warlock.js/fs": "5.31.0"
26
27
  },
27
- "version": "5.29.0",
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.*` (the seven tools + `all()` / `pick(...)`) and the human-facing direct methods (`readFile` / `writeFile` / `editFile` / `exec` / `grep` / `glob` / `exists` / `mkdir` / `remove`). Covers the policy surface (the `cwd` jail, `allowPaths` / `denyPaths`, the fail-closed shell allow/deny + `inheritEnv`, read caps), 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.
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 *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.
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: '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.'
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(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
23
- maxTrips: 25, // give the loop room to iterate
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 *and* rejects the mutating direct methods):
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 + grep + glob only
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 = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
65
- const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
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 and test tools bound to that policy. Importing the package registers `ai.workspace(...)` on the shared `ai` object.
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.