@warlock.js/ai-workspace 5.24.0 → 5.25.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 +6 -0
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.mjs.map +1 -1
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/tools/run-tests.mjs.map +1 -1
- package/llms-full.txt +236 -236
- package/package.json +3 -3
- package/skills/build-loop-agent/SKILL.md +100 -100
- package/skills/use-a-workspace/SKILL.md +136 -136
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: '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.
|
|
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: '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.
|
|
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/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.25.0"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@standard-schema/spec": "^1.0.0",
|
|
25
|
-
"@warlock.js/fs": "5.
|
|
25
|
+
"@warlock.js/fs": "5.25.0"
|
|
26
26
|
},
|
|
27
|
-
"version": "5.
|
|
27
|
+
"version": "5.25.0",
|
|
28
28
|
"main": "./cjs/index.cjs",
|
|
29
29
|
"module": "./esm/index.mjs",
|
|
30
30
|
"types": "./esm/index.d.mts",
|