@warlock.js/ai-workspace 5.19.1 → 5.21.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 +12 -1
- package/llms-full.txt +236 -236
- package/llms.txt +2 -2
- package/package.json +3 -3
- package/skills/build-loop-agent/SKILL.md +100 -100
- package/skills/index.md +17 -0
- package/skills/use-a-workspace/SKILL.md +136 -136
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
# Changelog — @warlock.js/ai-workspace
|
|
1
|
+
# Changelog — @warlock.js/ai-workspace
|
|
2
2
|
|
|
3
3
|
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.21.0 - 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Lockstep release maintenance and dependency refresh.
|
|
12
|
+
## 5.20.0 - 2026-09-24
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
|
|
17
|
+
|
|
7
18
|
## 5.19.0 - 2026-09-23
|
|
8
19
|
|
|
9
20
|
### 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:
|
|
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:
|
|
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):
|
|
10
|
-
- [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md):
|
|
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.
|
|
21
|
+
"@warlock.js/ai": "5.21.0"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@standard-schema/spec": "^1.0.0",
|
|
25
|
-
"@warlock.js/fs": "5.
|
|
25
|
+
"@warlock.js/fs": "5.21.0"
|
|
26
26
|
},
|
|
27
|
-
"version": "5.
|
|
27
|
+
"version": "5.21.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:
|
|
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.
|
package/skills/index.md
ADDED
|
@@ -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:
|
|
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.
|