@warlock.js/ai-workspace 5.19.1 → 5.20.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 CHANGED
@@ -4,6 +4,12 @@ All notable changes to `@warlock.js/ai-workspace` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.20.0 - 2026-09-24
8
+
9
+ ### Changed
10
+
11
+ - Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
12
+
7
13
  ## 5.19.0 - 2026-09-23
8
14
 
9
15
  ### Changed
package/llms-full.txt CHANGED
@@ -6,245 +6,245 @@
6
6
 
7
7
  ## build-loop-agent `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`
8
8
 
9
- ---
10
- name: build-loop-agent
11
- description: "Build a build-loop agent — read → edit → run tests, until green in @warlock.js/ai-workspace; use when you need to build loop agent."
12
- ---
13
-
14
- # Build a build-loop agent — read → edit → run tests, until green
15
-
16
- The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
17
-
18
- ```ts
19
- import "@warlock.js/ai-workspace";
20
- import { ai } from "@warlock.js/ai";
21
-
22
- const ws = ai.workspace({
23
- cwd: "/srv/acme-api",
24
- denyPaths: [".git/**", ".env*"],
25
- shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
26
- });
27
-
28
- const dev = ai.agent({
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
32
- });
33
-
34
- await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
35
- ```
36
-
37
- ## Why the loop works: errors as data
38
-
39
- A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
40
-
41
- ## Give the test command a path
42
-
43
- `run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
44
-
45
- - the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
46
- - the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
47
-
48
- Override the base command per agent: `ws.tools.runTests({ command: "vitest run" })`. A `pattern` the model supplies is appended as a filter (e.g. `vitest run cart-total`).
49
-
50
- ## Least-privilege variants
51
-
52
- ### A read-only reviewer
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):
55
-
56
- ```ts
57
- const reviewer = ai.agent({
58
- model,
59
- tools: ws.tools.pick("readFile", "grep", "glob"),
60
- });
61
- await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
62
-
63
- // Equivalent projection of the same jail:
64
- ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
65
- ```
66
-
67
- ### Scope an agent to one package
68
-
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
-
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() });
74
- ```
75
-
76
- ## Scaffold from code, then hand off
77
-
78
- You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
79
-
80
- ```ts
81
- await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
82
- await ws.exec("npm install zod");
83
- await dev.execute("Implement the handlers declared in src/routes.ts.");
84
- ```
85
-
86
- For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
87
-
88
- ## Share one workspace across a team
89
-
90
- One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
91
-
92
- ```ts
93
- const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
94
-
95
- ai.supervisor({
96
- members: [
97
- ai.agent({ /* executor */ model, tools: ws.tools.all() }),
98
- ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
99
- ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
100
- ],
101
- });
102
- ```
103
-
104
- ## See also
105
-
106
- - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
107
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
108
- - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
9
+ ---
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 — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
12
+ ---
13
+
14
+ # Build a build-loop agent — read → edit → run tests, until green
15
+
16
+ The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
17
+
18
+ ```ts
19
+ import "@warlock.js/ai-workspace";
20
+ import { ai } from "@warlock.js/ai";
21
+
22
+ const ws = ai.workspace({
23
+ cwd: "/srv/acme-api",
24
+ denyPaths: [".git/**", ".env*"],
25
+ shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
26
+ });
27
+
28
+ const dev = ai.agent({
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
32
+ });
33
+
34
+ await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
35
+ ```
36
+
37
+ ## Why the loop works: errors as data
38
+
39
+ A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
40
+
41
+ ## Give the test command a path
42
+
43
+ `run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
44
+
45
+ - the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
46
+ - the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
47
+
48
+ Override the base command per agent: `ws.tools.runTests({ command: "vitest run" })`. A `pattern` the model supplies is appended as a filter (e.g. `vitest run cart-total`).
49
+
50
+ ## Least-privilege variants
51
+
52
+ ### A read-only reviewer
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):
55
+
56
+ ```ts
57
+ const reviewer = ai.agent({
58
+ model,
59
+ tools: ws.tools.pick("readFile", "grep", "glob"),
60
+ });
61
+ await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
62
+
63
+ // Equivalent projection of the same jail:
64
+ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
65
+ ```
66
+
67
+ ### Scope an agent to one package
68
+
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
+
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() });
74
+ ```
75
+
76
+ ## Scaffold from code, then hand off
77
+
78
+ You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
79
+
80
+ ```ts
81
+ await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
82
+ await ws.exec("npm install zod");
83
+ await dev.execute("Implement the handlers declared in src/routes.ts.");
84
+ ```
85
+
86
+ For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
87
+
88
+ ## Share one workspace across a team
89
+
90
+ One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
91
+
92
+ ```ts
93
+ const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
94
+
95
+ ai.supervisor({
96
+ members: [
97
+ ai.agent({ /* executor */ model, tools: ws.tools.all() }),
98
+ ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
99
+ ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
100
+ ],
101
+ });
102
+ ```
103
+
104
+ ## See also
105
+
106
+ - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
107
+ - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
108
+ - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
109
109
 
110
110
 
111
111
  ## use-a-workspace `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`
112
112
 
113
- ---
114
- name: use-a-workspace
115
- description: "Use a workspace — a policy jail for an agent's filesystem + shell in @warlock.js/ai-workspace; use when you need to use a workspace."
116
- ---
117
-
118
- # Use a workspace — a policy jail for an agent's filesystem + shell
119
-
120
- `ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
121
-
122
- ```ts
123
- import "@warlock.js/ai-workspace";
124
- import { ai } from "@warlock.js/ai";
125
-
126
- const ws = ai.workspace({
127
- cwd: "/srv/acme-api",
128
- denyPaths: [".git/**", ".env*"],
129
- shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
130
- read: { defaultLines: 2_000 },
131
- backend: "local", // default; use "mock" for hermetic tests
132
- });
133
- ```
134
-
135
- ## A policy jail, NOT OS isolation
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.
138
-
139
- ## The policy surface
140
-
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). |
152
-
153
- > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
154
-
155
- ## `run_shell`/`exec` run argv, NOT a shell
156
-
157
- Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
158
-
159
- ```ts
160
- await ws.exec("npm test"); // OK — ["npm", "test"]
161
- await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
162
- await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
163
- ```
164
-
165
- - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
166
- - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
167
- - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
168
- - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
169
-
170
- ## Two callers, one jail
171
-
172
- ### Agent-facing — `ws.tools.*`
173
-
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`.
175
-
176
- ```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
181
- ```
182
-
183
- ### Human-facing — direct methods
184
-
185
- The same policy seam, callable from code (no LLM):
186
-
187
- ```ts
188
- const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
189
- await ws.writeFile("src/new.ts", "export const x = 1;\n");
190
- 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");
192
- const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
193
- const paths = await ws.glob("src/models/**/*.ts");
194
- await ws.exists("package.json");
195
- await ws.mkdir("src/generated");
196
- await ws.remove("dist");
197
- ```
198
-
199
- ## `grep` is ReDoS-bounded
200
-
201
- The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
202
-
203
- ## Read-before-edit (the stale-hash guard)
204
-
205
- `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
206
-
207
- ## Errors flow as data, not throws
208
-
209
- 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`:
210
-
211
- ```ts
212
- import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
213
-
214
- try {
215
- await ws.exec("rm -rf /");
216
- } catch (error) {
217
- if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
218
- console.warn(`Blocked: ${error.command}`);
219
- }
220
- }
221
- ```
222
-
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"`.
225
-
226
- ## Composition — `readonly()` and `scope()`
227
-
228
- ```ts
229
- const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
230
- const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
231
- ```
232
-
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.
234
-
235
- ## Hermetic tests with the mock backend
236
-
237
- `backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
238
-
239
- ```ts
240
- const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
241
- // (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
242
- ```
243
-
244
- ## See also
245
-
246
- - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
247
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
248
- - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
113
+ ---
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 — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
116
+ ---
117
+
118
+ # Use a workspace — a policy jail for an agent's filesystem + shell
119
+
120
+ `ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
121
+
122
+ ```ts
123
+ import "@warlock.js/ai-workspace";
124
+ import { ai } from "@warlock.js/ai";
125
+
126
+ const ws = ai.workspace({
127
+ cwd: "/srv/acme-api",
128
+ denyPaths: [".git/**", ".env*"],
129
+ shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
130
+ read: { defaultLines: 2_000 },
131
+ backend: "local", // default; use "mock" for hermetic tests
132
+ });
133
+ ```
134
+
135
+ ## A policy jail, NOT OS isolation
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.
138
+
139
+ ## The policy surface
140
+
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). |
152
+
153
+ > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
154
+
155
+ ## `run_shell`/`exec` run argv, NOT a shell
156
+
157
+ Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
158
+
159
+ ```ts
160
+ await ws.exec("npm test"); // OK — ["npm", "test"]
161
+ await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
162
+ await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
163
+ ```
164
+
165
+ - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
166
+ - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
167
+ - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
168
+ - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
169
+
170
+ ## Two callers, one jail
171
+
172
+ ### Agent-facing — `ws.tools.*`
173
+
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`.
175
+
176
+ ```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
181
+ ```
182
+
183
+ ### Human-facing — direct methods
184
+
185
+ The same policy seam, callable from code (no LLM):
186
+
187
+ ```ts
188
+ const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
189
+ await ws.writeFile("src/new.ts", "export const x = 1;\n");
190
+ 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");
192
+ const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
193
+ const paths = await ws.glob("src/models/**/*.ts");
194
+ await ws.exists("package.json");
195
+ await ws.mkdir("src/generated");
196
+ await ws.remove("dist");
197
+ ```
198
+
199
+ ## `grep` is ReDoS-bounded
200
+
201
+ The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
202
+
203
+ ## Read-before-edit (the stale-hash guard)
204
+
205
+ `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
206
+
207
+ ## Errors flow as data, not throws
208
+
209
+ 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`:
210
+
211
+ ```ts
212
+ import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
213
+
214
+ try {
215
+ await ws.exec("rm -rf /");
216
+ } catch (error) {
217
+ if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
218
+ console.warn(`Blocked: ${error.command}`);
219
+ }
220
+ }
221
+ ```
222
+
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"`.
225
+
226
+ ## Composition — `readonly()` and `scope()`
227
+
228
+ ```ts
229
+ const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
230
+ const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
231
+ ```
232
+
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.
234
+
235
+ ## Hermetic tests with the mock backend
236
+
237
+ `backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
238
+
239
+ ```ts
240
+ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
241
+ // (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
242
+ ```
243
+
244
+ ## See also
245
+
246
+ - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
247
+ - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
248
+ - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
249
249
 
250
250
 
package/llms.txt CHANGED
@@ -6,5 +6,5 @@
6
6
 
7
7
  ## Skills
8
8
 
9
- - [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): Build a build-loop agent — read → edit → run tests, until green in @warlock.js/ai-workspace; use when you need to build loop agent.
10
- - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Use a workspace — a policy jail for an agent's filesystem + shell in @warlock.js/ai-workspace; use when you need to use a workspace.
9
+ - [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): (no description)
10
+ - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): (no description)
package/package.json CHANGED
@@ -18,13 +18,13 @@
18
18
  "url": "https://github.com/warlockjs/ai-workspace"
19
19
  },
20
20
  "peerDependencies": {
21
- "@warlock.js/ai": "5.19.1"
21
+ "@warlock.js/ai": "5.20.0"
22
22
  },
23
23
  "dependencies": {
24
24
  "@standard-schema/spec": "^1.0.0",
25
- "@warlock.js/fs": "5.19.1"
25
+ "@warlock.js/fs": "5.20.0"
26
26
  },
27
- "version": "5.19.1",
27
+ "version": "5.20.0",
28
28
  "main": "./cjs/index.cjs",
29
29
  "module": "./esm/index.mjs",
30
30
  "types": "./esm/index.d.mts",
@@ -1,100 +1,100 @@
1
- ---
2
- name: build-loop-agent
3
- description: "Build a build-loop agent — read → edit → run tests, until green in @warlock.js/ai-workspace; use when you need to build loop agent."
4
- ---
5
-
6
- # Build a build-loop agent — read → edit → run tests, until green
7
-
8
- The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
9
-
10
- ```ts
11
- import "@warlock.js/ai-workspace";
12
- import { ai } from "@warlock.js/ai";
13
-
14
- const ws = ai.workspace({
15
- cwd: "/srv/acme-api",
16
- denyPaths: [".git/**", ".env*"],
17
- shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
18
- });
19
-
20
- const dev = ai.agent({
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
24
- });
25
-
26
- await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
27
- ```
28
-
29
- ## Why the loop works: errors as data
30
-
31
- A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
32
-
33
- ## Give the test command a path
34
-
35
- `run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
36
-
37
- - the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
38
- - the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
39
-
40
- Override the base command per agent: `ws.tools.runTests({ command: "vitest run" })`. A `pattern` the model supplies is appended as a filter (e.g. `vitest run cart-total`).
41
-
42
- ## Least-privilege variants
43
-
44
- ### A read-only reviewer
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):
47
-
48
- ```ts
49
- const reviewer = ai.agent({
50
- model,
51
- tools: ws.tools.pick("readFile", "grep", "glob"),
52
- });
53
- await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
54
-
55
- // Equivalent projection of the same jail:
56
- ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
57
- ```
58
-
59
- ### Scope an agent to one package
60
-
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
-
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() });
66
- ```
67
-
68
- ## Scaffold from code, then hand off
69
-
70
- You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
71
-
72
- ```ts
73
- await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
74
- await ws.exec("npm install zod");
75
- await dev.execute("Implement the handlers declared in src/routes.ts.");
76
- ```
77
-
78
- For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
79
-
80
- ## Share one workspace across a team
81
-
82
- One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
83
-
84
- ```ts
85
- const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
86
-
87
- ai.supervisor({
88
- members: [
89
- ai.agent({ /* executor */ model, tools: ws.tools.all() }),
90
- ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
91
- ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
92
- ],
93
- });
94
- ```
95
-
96
- ## See also
97
-
98
- - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
99
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
100
- - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
1
+ ---
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 — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
4
+ ---
5
+
6
+ # Build a build-loop agent — read → edit → run tests, until green
7
+
8
+ The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
9
+
10
+ ```ts
11
+ import "@warlock.js/ai-workspace";
12
+ import { ai } from "@warlock.js/ai";
13
+
14
+ const ws = ai.workspace({
15
+ cwd: "/srv/acme-api",
16
+ denyPaths: [".git/**", ".env*"],
17
+ shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
18
+ });
19
+
20
+ const dev = ai.agent({
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
24
+ });
25
+
26
+ await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
27
+ ```
28
+
29
+ ## Why the loop works: errors as data
30
+
31
+ A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
32
+
33
+ ## Give the test command a path
34
+
35
+ `run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
36
+
37
+ - the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
38
+ - the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
39
+
40
+ Override the base command per agent: `ws.tools.runTests({ command: "vitest run" })`. A `pattern` the model supplies is appended as a filter (e.g. `vitest run cart-total`).
41
+
42
+ ## Least-privilege variants
43
+
44
+ ### A read-only reviewer
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):
47
+
48
+ ```ts
49
+ const reviewer = ai.agent({
50
+ model,
51
+ tools: ws.tools.pick("readFile", "grep", "glob"),
52
+ });
53
+ await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
54
+
55
+ // Equivalent projection of the same jail:
56
+ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
57
+ ```
58
+
59
+ ### Scope an agent to one package
60
+
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
+
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() });
66
+ ```
67
+
68
+ ## Scaffold from code, then hand off
69
+
70
+ You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
71
+
72
+ ```ts
73
+ await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
74
+ await ws.exec("npm install zod");
75
+ await dev.execute("Implement the handlers declared in src/routes.ts.");
76
+ ```
77
+
78
+ For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
79
+
80
+ ## Share one workspace across a team
81
+
82
+ One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
83
+
84
+ ```ts
85
+ const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
86
+
87
+ ai.supervisor({
88
+ members: [
89
+ ai.agent({ /* executor */ model, tools: ws.tools.all() }),
90
+ ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
91
+ ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
92
+ ],
93
+ });
94
+ ```
95
+
96
+ ## See also
97
+
98
+ - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
99
+ - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
100
+ - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
@@ -0,0 +1,17 @@
1
+ ---
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
+ ---
4
+ # @warlock.js/ai-workspace
5
+
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
+
8
+ ## The 80% path
9
+ 1. Create a workspace with a root and policy (`use-a-workspace.md`).
10
+ 2. Hand its tools to an `ai.agent()`.
11
+ 3. For a coding agent, follow the read → edit → run tests loop until green (`build-loop-agent.md`).
12
+
13
+ ## Conventions and pitfalls
14
+ - The policy is the security boundary: keep the root narrow and the shell allowlist explicit; violations throw `WorkspacePolicyError`.
15
+ - Paths resolve inside the jail; escaping it via `..` or symlinks is rejected on purpose.
16
+ - The local backend runs real processes. Use `createMockBackend` in tests so no disk or shell is touched.
17
+ - Edits fail loudly (`WorkspaceEditError`) when the target text is not found; re-read the file rather than retrying blindly.
@@ -1,136 +1,136 @@
1
- ---
2
- name: use-a-workspace
3
- description: "Use a workspace — a policy jail for an agent's filesystem + shell in @warlock.js/ai-workspace; use when you need to use a workspace."
4
- ---
5
-
6
- # Use a workspace — a policy jail for an agent's filesystem + shell
7
-
8
- `ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
9
-
10
- ```ts
11
- import "@warlock.js/ai-workspace";
12
- import { ai } from "@warlock.js/ai";
13
-
14
- const ws = ai.workspace({
15
- cwd: "/srv/acme-api",
16
- denyPaths: [".git/**", ".env*"],
17
- shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
18
- read: { defaultLines: 2_000 },
19
- backend: "local", // default; use "mock" for hermetic tests
20
- });
21
- ```
22
-
23
- ## A policy jail, NOT OS isolation
24
-
25
- 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.
26
-
27
- ## The policy surface
28
-
29
- | Field | What it does |
30
- |---|---|
31
- | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
32
- | `allowPaths` | Extra readable roots outside `cwd`. |
33
- | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
34
- | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
35
- | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
36
- | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
37
- | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
38
- | `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
39
- | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
40
-
41
- > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
42
-
43
- ## `run_shell`/`exec` run argv, NOT a shell
44
-
45
- Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
46
-
47
- ```ts
48
- await ws.exec("npm test"); // OK — ["npm", "test"]
49
- await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
50
- await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
51
- ```
52
-
53
- - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
54
- - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
55
- - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
56
- - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
57
-
58
- ## Two callers, one jail
59
-
60
- ### Agent-facing — `ws.tools.*`
61
-
62
- 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`.
63
-
64
- ```ts
65
- ws.tools.all(); // all seven, canonical order
66
- ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
67
- ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
68
- ws.tools.runTests({ command: "vitest run" }); // override the test base command
69
- ```
70
-
71
- ### Human-facing — direct methods
72
-
73
- The same policy seam, callable from code (no LLM):
74
-
75
- ```ts
76
- const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
77
- await ws.writeFile("src/new.ts", "export const x = 1;\n");
78
- await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
79
- const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
80
- const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
81
- const paths = await ws.glob("src/models/**/*.ts");
82
- await ws.exists("package.json");
83
- await ws.mkdir("src/generated");
84
- await ws.remove("dist");
85
- ```
86
-
87
- ## `grep` is ReDoS-bounded
88
-
89
- The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
90
-
91
- ## Read-before-edit (the stale-hash guard)
92
-
93
- `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
94
-
95
- ## Errors flow as data, not throws
96
-
97
- 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`:
98
-
99
- ```ts
100
- import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
101
-
102
- try {
103
- await ws.exec("rm -rf /");
104
- } catch (error) {
105
- if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
106
- console.warn(`Blocked: ${error.command}`);
107
- }
108
- }
109
- ```
110
-
111
- - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
112
- - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
113
-
114
- ## Composition — `readonly()` and `scope()`
115
-
116
- ```ts
117
- const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
118
- const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
119
- ```
120
-
121
- `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.
122
-
123
- ## Hermetic tests with the mock backend
124
-
125
- `backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
126
-
127
- ```ts
128
- const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
129
- // (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
130
- ```
131
-
132
- ## See also
133
-
134
- - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
135
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
136
- - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
1
+ ---
2
+ name: use-a-workspace
3
+ description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
4
+ ---
5
+
6
+ # Use a workspace — a policy jail for an agent's filesystem + shell
7
+
8
+ `ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
9
+
10
+ ```ts
11
+ import "@warlock.js/ai-workspace";
12
+ import { ai } from "@warlock.js/ai";
13
+
14
+ const ws = ai.workspace({
15
+ cwd: "/srv/acme-api",
16
+ denyPaths: [".git/**", ".env*"],
17
+ shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
18
+ read: { defaultLines: 2_000 },
19
+ backend: "local", // default; use "mock" for hermetic tests
20
+ });
21
+ ```
22
+
23
+ ## A policy jail, NOT OS isolation
24
+
25
+ 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.
26
+
27
+ ## The policy surface
28
+
29
+ | Field | What it does |
30
+ |---|---|
31
+ | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
32
+ | `allowPaths` | Extra readable roots outside `cwd`. |
33
+ | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
34
+ | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
35
+ | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
36
+ | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
37
+ | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
38
+ | `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
39
+ | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
40
+
41
+ > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
42
+
43
+ ## `run_shell`/`exec` run argv, NOT a shell
44
+
45
+ Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
46
+
47
+ ```ts
48
+ await ws.exec("npm test"); // OK — ["npm", "test"]
49
+ await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
50
+ await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
51
+ ```
52
+
53
+ - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
54
+ - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
55
+ - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
56
+ - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
57
+
58
+ ## Two callers, one jail
59
+
60
+ ### Agent-facing — `ws.tools.*`
61
+
62
+ 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`.
63
+
64
+ ```ts
65
+ ws.tools.all(); // all seven, canonical order
66
+ ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
67
+ ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
68
+ ws.tools.runTests({ command: "vitest run" }); // override the test base command
69
+ ```
70
+
71
+ ### Human-facing — direct methods
72
+
73
+ The same policy seam, callable from code (no LLM):
74
+
75
+ ```ts
76
+ const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
77
+ await ws.writeFile("src/new.ts", "export const x = 1;\n");
78
+ await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
79
+ const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
80
+ const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
81
+ const paths = await ws.glob("src/models/**/*.ts");
82
+ await ws.exists("package.json");
83
+ await ws.mkdir("src/generated");
84
+ await ws.remove("dist");
85
+ ```
86
+
87
+ ## `grep` is ReDoS-bounded
88
+
89
+ The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
90
+
91
+ ## Read-before-edit (the stale-hash guard)
92
+
93
+ `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
94
+
95
+ ## Errors flow as data, not throws
96
+
97
+ 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`:
98
+
99
+ ```ts
100
+ import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
101
+
102
+ try {
103
+ await ws.exec("rm -rf /");
104
+ } catch (error) {
105
+ if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
106
+ console.warn(`Blocked: ${error.command}`);
107
+ }
108
+ }
109
+ ```
110
+
111
+ - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
112
+ - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
113
+
114
+ ## Composition — `readonly()` and `scope()`
115
+
116
+ ```ts
117
+ const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
118
+ const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
119
+ ```
120
+
121
+ `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.
122
+
123
+ ## Hermetic tests with the mock backend
124
+
125
+ `backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
126
+
127
+ ```ts
128
+ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
129
+ // (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
130
+ ```
131
+
132
+ ## See also
133
+
134
+ - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
135
+ - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
136
+ - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.