@warlock.js/ai-workspace 5.29.0 → 5.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +40 -21
- package/cjs/index.cjs +3295 -360
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +199 -31
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +12 -6
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +45 -3
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/tool-io.type.d.mts +248 -11
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +78 -2
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +41 -16
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +142 -14
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +75 -22
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +80 -6
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +23 -2
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +13 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +22 -3
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +341 -82
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/jail.mjs +73 -0
- package/esm/policy/jail.mjs.map +1 -0
- package/esm/policy/policy.d.mts +26 -16
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +62 -86
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +64 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +627 -0
- package/esm/policy/shell-check.mjs.map +1 -0
- package/esm/policy/shell-parser.mjs +802 -0
- package/esm/policy/shell-parser.mjs.map +1 -0
- package/esm/policy/tokenize-command.d.mts +63 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +76 -24
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/shell-jobs.d.mts +45 -0
- package/esm/shell-jobs.d.mts.map +1 -0
- package/esm/shell-jobs.mjs +247 -0
- package/esm/shell-jobs.mjs.map +1 -0
- package/esm/todos.d.mts +22 -0
- package/esm/todos.d.mts.map +1 -0
- package/esm/todos.mjs +73 -0
- package/esm/todos.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +1 -1
- package/esm/tools/edit-file.d.mts.map +1 -1
- package/esm/tools/edit-file.mjs +3 -2
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts +1 -1
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +3 -2
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts +1 -1
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +3 -2
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/json-schemas.mjs +175 -0
- package/esm/tools/json-schemas.mjs.map +1 -0
- package/esm/tools/list-dir.d.mts +32 -0
- package/esm/tools/list-dir.d.mts.map +1 -0
- package/esm/tools/list-dir.mjs +50 -0
- package/esm/tools/list-dir.mjs.map +1 -0
- package/esm/tools/multi-edit.d.mts +38 -0
- package/esm/tools/multi-edit.d.mts.map +1 -0
- package/esm/tools/multi-edit.mjs +121 -0
- package/esm/tools/multi-edit.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +5 -5
- package/esm/tools/read-file.d.mts.map +1 -1
- package/esm/tools/read-file.mjs +10 -20
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +39 -13
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +60 -47
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +11 -2
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +15 -7
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +24 -19
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/shell-kill.d.mts +25 -0
- package/esm/tools/shell-kill.d.mts.map +1 -0
- package/esm/tools/shell-kill.mjs +36 -0
- package/esm/tools/shell-kill.mjs.map +1 -0
- package/esm/tools/shell-output.d.mts +30 -0
- package/esm/tools/shell-output.d.mts.map +1 -0
- package/esm/tools/shell-output.mjs +49 -0
- package/esm/tools/shell-output.mjs.map +1 -0
- package/esm/tools/todo.d.mts +43 -0
- package/esm/tools/todo.d.mts.map +1 -0
- package/esm/tools/todo.mjs +126 -0
- package/esm/tools/todo.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +1 -1
- package/esm/tools/write-file.d.mts.map +1 -1
- package/esm/tools/write-file.mjs +3 -2
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +12 -9
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +109 -28
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +192 -35
- package/llms.txt +1 -1
- package/package.json +5 -4
- package/skills/README.md +2 -2
- package/skills/build-loop-agent/SKILL.md +7 -7
- package/skills/index.md +6 -1
- package/skills/use-a-workspace/SKILL.md +185 -28
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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 — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
|
|
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`, `ws.multiEdit`, `ws.listDir`, `ws.startShell`, `ws.shellOutput`, `ws.shellKill`, `ws.dispose`, `ws.readTodos`, `ws.writeTodos`, `WorkspacePolicyError`, `WorkspaceEditError`, `WorkspaceJobError`, `WorkspaceShellRefusalReason`, `checkShellCommand`, `denyPaths`, `denyPatterns`, `allowCommand`, `interpreter`, `shell.background`, `list.ignore`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`multi_edit`/`write_file`/`list_dir`/`run_shell`/`shell_output`/`shell_kill`/`run_tests`/`grep`/`todo_write`/`todo_read`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent'', ''cap how many bytes a read returns'', ''cancel a running workspace command'', ''let the agent use pipes and &&'', ''why was my shell command refused'', ''deny git push --force'', ''run a dev server in the background'', ''atomic multi-edit'', ''list a directory for an agent'', ''todo list tool for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Use a workspace — a policy jail for an agent's filesystem + shell
|
|
@@ -22,21 +22,29 @@ const ws = ai.workspace({
|
|
|
22
22
|
|
|
23
23
|
## A policy jail, NOT OS isolation
|
|
24
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
|
|
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.
|
|
26
|
+
|
|
27
|
+
Paths are resolved against `cwd` with symlinks collapsed and accepted inside `cwd` or an `allowPaths` root, off `denyPaths`; absolute paths inside the jail, links that stay inside and not-yet-existing paths (for writes) are accepted. To read a folder you do not trust from app code (no links at all, size and entry caps), use `fs.confined(root)` from `@warlock.js/fs` (the `read-a-folder-confined` topic) instead of a workspace.
|
|
28
|
+
|
|
29
|
+
**Shell mode (`interpreter: "sh"`) is a policy layer (defence in depth), not an OS sandbox.** It parses the line before `/bin/sh` runs it and refuses what it cannot read, but `/bin/sh` differs between systems (bash, dash, busybox ash), an allowed program (`node`, `npm`, `make`, a git hook) can still start anything the OS user can, and a symlink can change between the jail re-check and the write. For an untrusted agent (one that reads untrusted repos, issues, web pages or tool output), run the workspace in a container or microVM with only the project mounted, or at least as a separate unprivileged user, and keep the policy as a second layer. Prefer argv mode (the default) when the agent does not need pipes or redirections.
|
|
26
30
|
|
|
27
31
|
## The policy surface
|
|
28
32
|
|
|
29
|
-
| Field
|
|
30
|
-
|
|
31
|
-
| `cwd`
|
|
32
|
-
| `allowPaths`
|
|
33
|
-
| `denyPaths`
|
|
34
|
-
| `shell.allow` / `shell.deny`
|
|
35
|
-
| `shell.
|
|
36
|
-
| `shell.
|
|
37
|
-
| `shell.
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
33
|
+
| Field | What it does |
|
|
34
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
35
|
+
| `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
|
|
36
|
+
| `allowPaths` | Extra readable roots outside `cwd`. |
|
|
37
|
+
| `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
|
|
38
|
+
| `shell.allow` / `shell.deny` | Executable **basenames** matched against every command's argv (after tokenizing — see below). **Deny wins**, case-insensitively. An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
|
|
39
|
+
| `shell.denyPatterns` / `shell.allowCommand` | Argument rules on every command: string prefix patterns (`"git push --force"`) or RegExps, and a predicate `(argv, ctx) => true \| false \| "why"`. |
|
|
40
|
+
| `shell.interpreter` | `"none"` (default, argv) or `"sh"` (opt-in shell syntax, every command checked). |
|
|
41
|
+
| `shell.background` | `{ maxJobs?, timeoutMs? }` — enables background commands. Absent ⇒ refused. |
|
|
42
|
+
| `list.ignore` / `list.maxDepth` / `list.maxEntries` | `list_dir` bounds: hidden globs (default `node_modules`, `.git`), depth cap (5), entry cap (500). |
|
|
43
|
+
| `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`. |
|
|
44
|
+
| `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
|
|
45
|
+
| `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (process tree SIGKILLed on expiry) and stdout/stderr byte cap (clipped on a character boundary). |
|
|
46
|
+
| `read.defaultLines` / `read.maxBytes` | Default read window + a ceiling on the UTF-8 bytes of the returned `content` (line numbers included). See [Read windows and the byte ceiling](#read-windows-and-the-byte-ceiling). |
|
|
47
|
+
| `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
|
|
40
48
|
|
|
41
49
|
> **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
|
|
42
50
|
|
|
@@ -45,27 +53,64 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
|
|
|
45
53
|
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
54
|
|
|
47
55
|
```ts
|
|
48
|
-
await ws.exec("npm test");
|
|
49
|
-
await ws.exec('node -e "console.log(1)"');
|
|
50
|
-
await ws.exec("npm test; curl http://evil");
|
|
56
|
+
await ws.exec("npm test"); // OK — ["npm", "test"]
|
|
57
|
+
await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
|
|
58
|
+
await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
|
|
51
59
|
```
|
|
52
60
|
|
|
53
61
|
- **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
62
|
- **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
63
|
- **`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
|
-
-
|
|
64
|
+
- Shell conveniences (pipes, redirection, `&&` chaining, `$(...)` substitution) don't work in argv mode. The refusal (`reason: "metacharacter"`) names the operator and suggests the split — e.g. "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m "wip"`, as separate commands." — so the model retries correctly.
|
|
65
|
+
- **Wrappers are followed**: `env`, `time`, `timeout`, `nice`, `nohup`, `command`, `builtin`, `exec`, `find -exec` — the inner command is checked too. Runners the policy can't see into (`sh -c`, `bash`, `eval`, `source`, `xargs`, `sudo`, `setsid`, …) are refused with `reason: "nested-command"` while deny rules are in force, unless `allow` names them.
|
|
66
|
+
|
|
67
|
+
## Argument rules and shell mode
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { workspace, WorkspacePolicyError } from "@warlock.js/ai-workspace";
|
|
71
|
+
|
|
72
|
+
export async function commitWork(cwd: string) {
|
|
73
|
+
const ws = workspace({
|
|
74
|
+
cwd,
|
|
75
|
+
shell: {
|
|
76
|
+
interpreter: "sh", // opt in: pipes, &&, redirections — every command still checked
|
|
77
|
+
allow: ["git", "npm", "grep"],
|
|
78
|
+
denyPatterns: ["git push --force", /^git reset --hard\b/],
|
|
79
|
+
allowCommand: (argv) => (argv[0] === "git" && argv[1] === "push" ? "CI pushes." : true),
|
|
80
|
+
inheritEnv: ["PATH"],
|
|
81
|
+
timeoutMs: 120_000,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await ws.exec("git add -A && git commit -m 'wip'");
|
|
87
|
+
return await ws.exec("npm test 2>&1 | grep -i fail > logs/failures.txt");
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
|
|
90
|
+
console.warn(error.reason, error.message); // e.g. "denied-pattern", "path-escape"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- Shell mode parses the line and checks every simple command — inside `;`, `&&`, `||`, pipes, `( )`, `$( )`, backticks, here-documents and `NAME=value cmd` prefixes. Redirection targets and `cd` directories must be literal paths inside the jail (`/dev/null` is fine), re-checked with symlinks resolved.
|
|
99
|
+
- It refuses what it can't parse with confidence (`reason: "unsupported-syntax"`): compound commands, arithmetic, brace expansion, unusual `${…}` forms, bash-only redirections, a command name decided at run time, `&` in a line (use `background: true`), assignments to `IFS`/`PS4`/`CDPATH`/`BASH_ENV`…; it drops shell start-up variables (`ENV`, `BASH_ENV`, `SHELLOPTS`, `BASH_FUNC_*`, …) from the environment.
|
|
100
|
+
- `reason` values: `"metacharacter"`, `"unbalanced-quotes"`, `"denied-executable"`, `"not-allowed"`, `"denied-pattern"`, `"nested-command"`, `"unsupported-syntax"`, `"path-escape"`. `checkShellCommand(policy, line)` returns the verdict without running anything.
|
|
101
|
+
- Pair shell mode with an exhaustive `allow`: `deny` alone is a best-effort filter.
|
|
57
102
|
|
|
58
103
|
## Two callers, one jail
|
|
59
104
|
|
|
60
105
|
### Agent-facing — `ws.tools.*`
|
|
61
106
|
|
|
62
|
-
Each tool is a `ToolContract` ready for `ai.agent({ tools })`. The
|
|
107
|
+
Each tool is a `ToolContract` (with a JSON Schema of its input) ready for `ai.agent({ tools })`. The LLM-visible tool names are `read_file`, `edit_file`, `multi_edit`, `write_file`, `list_dir`, `run_shell`, `shell_output`, `shell_kill`, `run_tests`, `grep`, `glob`, `todo_write`, `todo_read` — `shell_output` / `shell_kill` only in `all()` when `shell.background` is set.
|
|
63
108
|
|
|
64
109
|
```ts
|
|
65
|
-
ws.tools.all();
|
|
66
|
-
ws.tools.pick("readFile", "grep", "glob");
|
|
67
|
-
ws.tools.readFile({ name: "open" });
|
|
68
|
-
ws.tools.runTests({ command: "vitest run" });
|
|
110
|
+
ws.tools.all(); // every tool, canonical order
|
|
111
|
+
ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
|
|
112
|
+
ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
|
|
113
|
+
ws.tools.runTests({ command: "vitest run" }); // override the test base command
|
|
69
114
|
```
|
|
70
115
|
|
|
71
116
|
### Human-facing — direct methods
|
|
@@ -73,10 +118,13 @@ ws.tools.runTests({ command: "vitest run" }); // override the test base command
|
|
|
73
118
|
The same policy seam, callable from code (no LLM):
|
|
74
119
|
|
|
75
120
|
```ts
|
|
76
|
-
const { content, hash, totalLines } = await ws.readFile(
|
|
121
|
+
const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile(
|
|
122
|
+
"src/index.ts",
|
|
123
|
+
{ offset: 1, limit: 200 },
|
|
124
|
+
);
|
|
77
125
|
await ws.writeFile("src/new.ts", "export const x = 1;\n");
|
|
78
126
|
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");
|
|
127
|
+
const { exitCode, stdout, stderr, timedOut, cancelled, truncated } = await ws.exec("npm test");
|
|
80
128
|
const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
|
|
81
129
|
const paths = await ws.glob("src/models/**/*.ts");
|
|
82
130
|
await ws.exists("package.json");
|
|
@@ -84,14 +132,95 @@ await ws.mkdir("src/generated");
|
|
|
84
132
|
await ws.remove("dist");
|
|
85
133
|
```
|
|
86
134
|
|
|
135
|
+
## The coding-agent tool belt
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { workspace } from "@warlock.js/ai-workspace";
|
|
139
|
+
|
|
140
|
+
export async function devLoop(cwd: string) {
|
|
141
|
+
const ws = workspace({
|
|
142
|
+
cwd,
|
|
143
|
+
list: { maxEntries: 300 },
|
|
144
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], background: { maxJobs: 2 } },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const { entries } = await ws.listDir("src", { depth: 2 }); // bounded, jailed, node_modules/.git hidden
|
|
149
|
+
console.info(entries.map((entry) => `${entry.type} ${entry.path}`));
|
|
150
|
+
|
|
151
|
+
const { hash } = await ws.readFile("src/cart.ts");
|
|
152
|
+
await ws.multiEdit({
|
|
153
|
+
path: "src/cart.ts",
|
|
154
|
+
expectHash: hash,
|
|
155
|
+
edits: [
|
|
156
|
+
{ oldString: "let total = 0;", newString: "let total = 0n;" },
|
|
157
|
+
{ oldString: "total += price;", newString: "total += BigInt(price);" },
|
|
158
|
+
], // all apply, or none (WorkspaceEditError.editIndex names the failing one)
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const server = await ws.startShell("npm run dev"); // run_shell({ command, background: true })
|
|
162
|
+
const { stdout, cursor } = await ws.shellOutput(server.id, { waitMs: 5_000 });
|
|
163
|
+
console.info(stdout, cursor); // pass `since: cursor` to read only what comes next
|
|
164
|
+
await ws.shellKill(server.id); // kills the whole process tree
|
|
165
|
+
|
|
166
|
+
ws.writeTodos([{ content: "Fix the rounding bug", status: "completed" }]); // todo_write
|
|
167
|
+
return ws.readTodos().counts; // todo_read
|
|
168
|
+
} finally {
|
|
169
|
+
await ws.dispose(); // kills every background command; the workspace refuses new commands
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `list_dir({ path?, depth?, ignore? })` → `{ path, entries: [{ path, type, size?, depth }], truncated }`; `denyPaths` always hidden, `list.ignore` (default `node_modules`, `.git`) hidden from listings, symlinks reported but never followed.
|
|
175
|
+
- `multi_edit({ path, edits, expectHash? })` → `{ path, edits, replacements, hash }`; same uniqueness rules as `edit_file`, at most 100 edits.
|
|
176
|
+
- Background: `shell_output({ id, since?, waitMs? })` → `{ stdout, stderr, cursor, droppedBytes, truncated, status, exitCode }`; only the newest `shell.maxOutputBytes` per command are kept. States `"running"`, `"exited"`, `"killed"`, `"timed-out"`. Unknown id → `WorkspaceJobError`. Background commands outlive the agent run; `dispose()` (or process exit) kills them. Only processes the workspace spawned are ever signalled.
|
|
177
|
+
- Todos: one in-memory list per workspace, shared with `readonly()` / `scope()`; `todo_write` replaces the whole list (≤ 100 items, status `pending` / `in_progress` / `completed`).
|
|
178
|
+
- `run_shell`'s model-supplied `timeoutMs` can only lower `shell.timeoutMs`.
|
|
179
|
+
|
|
87
180
|
## `grep` is ReDoS-bounded
|
|
88
181
|
|
|
89
182
|
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
183
|
|
|
184
|
+
## Read windows and the byte ceiling
|
|
185
|
+
|
|
186
|
+
`readFile()` and the `read_file` tool return the same result for the same arguments: a `cat -n` numbered window plus `{ startLine, endLine, totalLines, totalBytes, truncated, bytesCapped, hash }`.
|
|
187
|
+
|
|
188
|
+
- The window starts at `offset` (`startLine` on the tool) and holds at most `limit` lines (default `read.defaultLines`, else 2000).
|
|
189
|
+
- `read.maxBytes` caps the UTF-8 bytes of the returned `content` itself — line-number prefixes and newlines count. Whole lines are kept while they fit; the first line that doesn't is cut on a **character boundary** (never mid-character), so a single very long line still comes back bounded. `bytesCapped` is `true` whenever the cap cut anything, and `endLine` is the last line included, possibly partially.
|
|
190
|
+
- `truncated` is `true` when anything after `content` was withheld — later lines (by `limit`, the default window or the cap) or the tail of `endLine`.
|
|
191
|
+
- `hash` is always the SHA-256 of the **whole file**, not the window, so it is a valid `expectHash` whatever was returned. `totalBytes` is the whole file's size.
|
|
192
|
+
- No `maxBytes` ⇒ no byte cap (only the line window applies).
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const ws = ai.workspace({ cwd, read: { maxBytes: 64_000, defaultLines: 400 } });
|
|
196
|
+
const page = await ws.readFile("dist/bundle.js");
|
|
197
|
+
if (page.bytesCapped) console.warn(`Only part of line ${page.endLine} fit in the read budget.`);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Cancelling a command
|
|
201
|
+
|
|
202
|
+
`exec()` accepts a per-call `signal`; `run_shell` and `run_tests` pass the agent run's signal (the dispatch `ctx.signal`) automatically, so cancelling the agent run stops the command it is waiting on.
|
|
203
|
+
|
|
204
|
+
- Already aborted ⇒ the command is **not started** (`exitCode: 130`).
|
|
205
|
+
- Aborted mid-run ⇒ the whole process tree is SIGKILLed, as on a timeout.
|
|
206
|
+
- Either way the result says `cancelled: true` — it resolves, it doesn't throw — and `cancelled` and `timedOut` are never both `true`. Branch on those flags, not on the exit code.
|
|
207
|
+
- The signal belongs to that one call; the workspace never stores it, so one workspace can serve many runs. The abort listener is removed once the command settles.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const pending = ws.exec("npm test", { signal: controller.signal, timeoutMs: 120_000 });
|
|
212
|
+
controller.abort();
|
|
213
|
+
const { cancelled } = await pending; // true
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A custom `WorkspaceBackend` receives the signal as `exec(command, { signal })` and should follow the same rules, reporting `cancelled: true` in its result.
|
|
217
|
+
|
|
91
218
|
## Read-before-edit (the stale-hash guard)
|
|
92
219
|
|
|
93
220
|
`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
221
|
|
|
222
|
+
Writes, edits and removes of the same path run one after another, even when the agent dispatches tool calls concurrently: of two edits pinned to the same `expectHash`, the second is rejected as stale instead of silently overwriting the first. `read.maxBytes` bounds what a read returns, not memory — the whole file is still read and hashed.
|
|
223
|
+
|
|
95
224
|
## Errors flow as data, not throws
|
|
96
225
|
|
|
97
226
|
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`:
|
|
@@ -108,17 +237,18 @@ try {
|
|
|
108
237
|
}
|
|
109
238
|
```
|
|
110
239
|
|
|
111
|
-
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (
|
|
112
|
-
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"
|
|
240
|
+
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (the shell policy refused the line — `reason` says why) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
|
|
241
|
+
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"` (+ `editIndex` for `multiEdit`).
|
|
242
|
+
- `WorkspaceJobError.type`: `"not-found"` (unknown background command id).
|
|
113
243
|
|
|
114
244
|
## Composition — `readonly()` and `scope()`
|
|
115
245
|
|
|
116
246
|
```ts
|
|
117
|
-
const ro = ws.readonly();
|
|
247
|
+
const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
|
|
118
248
|
const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
|
|
119
249
|
```
|
|
120
250
|
|
|
121
|
-
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file`
|
|
251
|
+
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file`, `list_dir`, `grep`, `glob` and the todo tools only, and `writeFile` / `editFile` / `multiEdit` / `exec` / `startShell` / `shellKill` / `mkdir` / `remove` throw a `WorkspacePolicyError`. Projections share the workspace's background commands and todo list. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
|
|
122
252
|
|
|
123
253
|
## Hermetic tests with the mock backend
|
|
124
254
|
|
|
@@ -134,3 +264,30 @@ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["np
|
|
|
134
264
|
- The `build-loop-agent` topic — wiring these tools into a read → edit → test agent loop.
|
|
135
265
|
- The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes `ws.tools.all()`.
|
|
136
266
|
- The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every workspace tool is built on.
|
|
267
|
+
|
|
268
|
+
## Complete bounded-read and cancellation example
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { workspace } from "@warlock.js/ai-workspace";
|
|
272
|
+
|
|
273
|
+
export async function inspectWorkspace(cwd: string) {
|
|
274
|
+
const ws = workspace({
|
|
275
|
+
cwd,
|
|
276
|
+
read: { maxBytes: 64_000, defaultLines: 400 },
|
|
277
|
+
shell: { allow: ["node"], inheritEnv: ["PATH"] },
|
|
278
|
+
});
|
|
279
|
+
const page = await ws.readFile("package.json");
|
|
280
|
+
console.info(page.bytesCapped, page.endLine, page.hash);
|
|
281
|
+
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
controller.abort();
|
|
284
|
+
const result = await ws.exec('node -e "console.log(1)"', { signal: controller.signal });
|
|
285
|
+
if (result.cancelled) console.info("Command cancelled before it started");
|
|
286
|
+
return page;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The caller supplies an absolute existing workspace root. The already-aborted
|
|
291
|
+
signal demonstrates the no-spawn path; a later abort also kills a running
|
|
292
|
+
command's process tree. The byte ceiling limits returned content, while the
|
|
293
|
+
hash still describes the whole file.
|