@warlock.js/ai-workspace 5.30.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 +46 -2
- package/README.md +34 -21
- package/cjs/index.cjs +3116 -309
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +165 -27
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +11 -5
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +34 -0
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/tool-io.type.d.mts +202 -4
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +61 -1
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +32 -3
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +133 -12
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +63 -9
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +79 -5
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +22 -1
- 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 +21 -2
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +230 -64
- 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 +25 -15
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +61 -85
- 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 +75 -23
- 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.map +1 -1
- package/esm/tools/edit-file.mjs +2 -1
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +2 -1
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +2 -1
- 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.map +1 -1
- package/esm/tools/read-file.mjs +2 -1
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +34 -12
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +59 -53
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +8 -0
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +10 -3
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +23 -18
- 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.map +1 -1
- package/esm/tools/write-file.mjs +2 -1
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +11 -8
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +108 -27
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +155 -34
- package/llms.txt +1 -1
- package/package.json +3 -3
- 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 +148 -27
package/esm/ops.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { WorkspaceEditError, WorkspacePolicyError } from "./errors.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { globToRegExp, isInside, matchesAnyGlob } from "./policy/jail.mjs";
|
|
3
|
+
import { checkShellCommand } from "./policy/shell-check.mjs";
|
|
4
|
+
import { buildCommandEnv, resolveInJail } from "./policy/policy.mjs";
|
|
5
|
+
import { ShellJobs } from "./shell-jobs.mjs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { fs } from "@warlock.js/fs";
|
|
5
8
|
|
|
@@ -10,6 +13,14 @@ const DEFAULT_READ_LINES = 2e3;
|
|
|
10
13
|
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
11
14
|
/** Default per-command output byte cap when the policy sets none. */
|
|
12
15
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
16
|
+
/** Default `list.ignore` when the policy sets none. */
|
|
17
|
+
const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
|
|
18
|
+
/** Default deepest `depth` a listing may ask for. */
|
|
19
|
+
const DEFAULT_LIST_MAX_DEPTH = 5;
|
|
20
|
+
/** Default most entries one listing returns. */
|
|
21
|
+
const DEFAULT_LIST_MAX_ENTRIES = 500;
|
|
22
|
+
/** Most edits one `multiEdit` call may carry. */
|
|
23
|
+
const MAX_MULTI_EDITS = 100;
|
|
13
24
|
/**
|
|
14
25
|
* Exit code reported for a command refused because its signal was already
|
|
15
26
|
* aborted (the shell convention for an interrupted command). Callers should
|
|
@@ -100,32 +111,6 @@ function capOutput(value, maxBytes) {
|
|
|
100
111
|
return clipUtf8(value, maxBytes);
|
|
101
112
|
}
|
|
102
113
|
/**
|
|
103
|
-
* Minimal glob match over a `/`-separated relative path. Supports `**`
|
|
104
|
-
* (spans separators), `*` (within a segment), and `?` (one non-separator
|
|
105
|
-
* char) — enough for the workspace's `glob`/grep narrowing without
|
|
106
|
-
* pulling in a runtime dependency.
|
|
107
|
-
*/
|
|
108
|
-
function globToRegExp(glob) {
|
|
109
|
-
let source = "";
|
|
110
|
-
for (let index = 0; index < glob.length; index++) {
|
|
111
|
-
const char = glob[index];
|
|
112
|
-
if (char === "*") {
|
|
113
|
-
if (glob[index + 1] === "*") {
|
|
114
|
-
source += ".*";
|
|
115
|
-
index++;
|
|
116
|
-
if (glob[index + 1] === "/") index++;
|
|
117
|
-
} else source += "[^/]*";
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (char === "?") {
|
|
121
|
-
source += "[^/]";
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
125
|
-
}
|
|
126
|
-
return new RegExp(`^${source}$`);
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
114
|
* A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
|
|
130
115
|
*/
|
|
131
116
|
const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
|
|
@@ -147,17 +132,11 @@ function isSafeGrepPattern(pattern) {
|
|
|
147
132
|
if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
|
|
148
133
|
return !NESTED_QUANTIFIER_PATTERN.test(pattern);
|
|
149
134
|
}
|
|
150
|
-
/**
|
|
151
|
-
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
152
|
-
* Holds the backend + policy and is the one place the jail, command
|
|
153
|
-
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
154
|
-
* the agent-facing tools and the human-facing direct methods funnel
|
|
155
|
-
* through this object, so there is exactly one set of rules.
|
|
156
|
-
*/
|
|
157
135
|
var Ops = class {
|
|
158
|
-
constructor(backend, policy) {
|
|
136
|
+
constructor(backend, policy, runtime) {
|
|
159
137
|
this.backend = backend;
|
|
160
138
|
this.policy = policy;
|
|
139
|
+
this.runtime = runtime;
|
|
161
140
|
this.pathLocks = /* @__PURE__ */ new Map();
|
|
162
141
|
}
|
|
163
142
|
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
@@ -251,30 +230,76 @@ var Ops = class {
|
|
|
251
230
|
expectedHash: input.expectHash,
|
|
252
231
|
actualHash: currentHash
|
|
253
232
|
});
|
|
254
|
-
const
|
|
255
|
-
if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
|
|
256
|
-
type: "not-found",
|
|
257
|
-
path: relativePath || input.path,
|
|
258
|
-
matches: 0
|
|
259
|
-
});
|
|
260
|
-
if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
|
|
261
|
-
type: "not-unique",
|
|
262
|
-
path: relativePath || input.path,
|
|
263
|
-
matches: occurrences
|
|
264
|
-
});
|
|
265
|
-
const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
|
|
233
|
+
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
266
234
|
await this.backend.writeFile(absolutePath, updated);
|
|
267
235
|
return {
|
|
268
236
|
path: relativePath || input.path,
|
|
269
|
-
replacements
|
|
237
|
+
replacements,
|
|
270
238
|
hash: fs.hash.string(updated)
|
|
271
239
|
};
|
|
272
240
|
}
|
|
273
|
-
async
|
|
274
|
-
|
|
241
|
+
async multiEdit(input) {
|
|
242
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
243
|
+
const shownPath = relativePath || input.path;
|
|
244
|
+
if (input.edits.length === 0 || input.edits.length > 100) throw new RangeError(`A multi-edit needs between 1 and ${100} edits (got ${input.edits.length}).`);
|
|
245
|
+
return this.withPathLock(absolutePath, async () => {
|
|
246
|
+
const current = await this.backend.readFile(absolutePath);
|
|
247
|
+
const currentHash = fs.hash.string(current);
|
|
248
|
+
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; none of the edits were applied.`, {
|
|
249
|
+
type: "stale-hash",
|
|
250
|
+
path: shownPath,
|
|
251
|
+
expectedHash: input.expectHash,
|
|
252
|
+
actualHash: currentHash
|
|
253
|
+
});
|
|
254
|
+
let content = current;
|
|
255
|
+
let replacements = 0;
|
|
256
|
+
input.edits.forEach((edit, editIndex) => {
|
|
257
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
258
|
+
content = applied.content;
|
|
259
|
+
replacements += applied.replacements;
|
|
260
|
+
});
|
|
261
|
+
await this.backend.writeFile(absolutePath, content);
|
|
262
|
+
return {
|
|
263
|
+
path: shownPath,
|
|
264
|
+
edits: input.edits.length,
|
|
265
|
+
replacements,
|
|
266
|
+
hash: fs.hash.string(content)
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check a command line against the shell policy: every command in it
|
|
272
|
+
* (argv or shell mode), then — shell mode — every redirection target and
|
|
273
|
+
* `cd` directory against the jail with symlinks resolved.
|
|
274
|
+
*/
|
|
275
|
+
async assertCommandAllowed(command) {
|
|
276
|
+
const verdict = checkShellCommand(this.policy, command);
|
|
277
|
+
if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
278
|
+
type: "denied-command",
|
|
279
|
+
command,
|
|
280
|
+
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
281
|
+
...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
|
|
282
|
+
...verdict.path !== void 0 ? { path: verdict.path } : {}
|
|
283
|
+
});
|
|
284
|
+
for (const target of verdict.paths) try {
|
|
285
|
+
await resolveInJail(this.policy, target.path);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!(error instanceof WorkspacePolicyError)) throw error;
|
|
288
|
+
throw new WorkspacePolicyError(target.access === "cwd" ? `\`cd ${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.` : `The redirection target \`${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.`, {
|
|
289
|
+
type: "denied-command",
|
|
290
|
+
reason: "path-escape",
|
|
291
|
+
command,
|
|
292
|
+
path: target.target
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
|
|
275
296
|
type: "denied-command",
|
|
297
|
+
reason: "not-allowed",
|
|
276
298
|
command
|
|
277
299
|
});
|
|
300
|
+
}
|
|
301
|
+
async exec(command, opts) {
|
|
302
|
+
await this.assertCommandAllowed(command);
|
|
278
303
|
const signal = opts?.signal;
|
|
279
304
|
if (signal?.aborted) return {
|
|
280
305
|
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
@@ -287,11 +312,13 @@ var Ops = class {
|
|
|
287
312
|
const shell = this.policy.shell;
|
|
288
313
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
289
314
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
315
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
290
316
|
const result = await this.backend.exec(command, {
|
|
291
317
|
cwd: this.policy.cwd,
|
|
292
318
|
timeoutMs,
|
|
293
|
-
env:
|
|
294
|
-
...signal ? { signal } : {}
|
|
319
|
+
env: buildCommandEnv(this.policy),
|
|
320
|
+
...signal ? { signal } : {},
|
|
321
|
+
...interpreter !== "none" ? { interpreter } : {}
|
|
295
322
|
});
|
|
296
323
|
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
297
324
|
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
@@ -305,6 +332,120 @@ var Ops = class {
|
|
|
305
332
|
cancelled
|
|
306
333
|
};
|
|
307
334
|
}
|
|
335
|
+
async startShell(command, opts) {
|
|
336
|
+
const background = this.policy.shell?.background;
|
|
337
|
+
if (background === void 0) throw new WorkspacePolicyError("Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.", {
|
|
338
|
+
type: "denied-command",
|
|
339
|
+
reason: "not-allowed",
|
|
340
|
+
command
|
|
341
|
+
});
|
|
342
|
+
await this.assertCommandAllowed(command);
|
|
343
|
+
const start = this.backend.start?.bind(this.backend);
|
|
344
|
+
if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
|
|
345
|
+
type: "denied-command",
|
|
346
|
+
reason: "not-allowed",
|
|
347
|
+
command
|
|
348
|
+
});
|
|
349
|
+
const shell = this.policy.shell;
|
|
350
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
351
|
+
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
352
|
+
const env = buildCommandEnv(this.policy);
|
|
353
|
+
return this.runtime.jobs.start({
|
|
354
|
+
command,
|
|
355
|
+
retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
356
|
+
maxJobs: background.maxJobs ?? 4,
|
|
357
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
358
|
+
launch: (onOutput) => start(command, {
|
|
359
|
+
cwd: this.policy.cwd,
|
|
360
|
+
env,
|
|
361
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
362
|
+
onOutput
|
|
363
|
+
})
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
shellOutput(id, opts) {
|
|
367
|
+
return this.runtime.jobs.output(id, opts);
|
|
368
|
+
}
|
|
369
|
+
shellKill(id) {
|
|
370
|
+
return this.runtime.jobs.kill(id);
|
|
371
|
+
}
|
|
372
|
+
listShells() {
|
|
373
|
+
return this.runtime.jobs.list();
|
|
374
|
+
}
|
|
375
|
+
dispose() {
|
|
376
|
+
return this.runtime.jobs.dispose();
|
|
377
|
+
}
|
|
378
|
+
async listDir(inputPath = ".", opts) {
|
|
379
|
+
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
380
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
381
|
+
const listPolicy = this.policy.list;
|
|
382
|
+
const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
|
|
383
|
+
const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
|
|
384
|
+
const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
|
|
385
|
+
const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
|
|
386
|
+
const insideCwd = isInside(root, jailRoot);
|
|
387
|
+
if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
|
|
388
|
+
const entries = [];
|
|
389
|
+
let level = [root];
|
|
390
|
+
let truncated = false;
|
|
391
|
+
for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
|
|
392
|
+
const nextLevel = [];
|
|
393
|
+
for (const directory of level) {
|
|
394
|
+
let children;
|
|
395
|
+
try {
|
|
396
|
+
children = await this.backend.list(directory);
|
|
397
|
+
} catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
401
|
+
for (const child of children) {
|
|
402
|
+
const relativeToJail = insideCwd ? path.relative(jailRoot, child).split(path.sep).join("/") : child;
|
|
403
|
+
if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
|
|
404
|
+
const relativeToRoot = path.relative(root, child).split(path.sep).join("/");
|
|
405
|
+
if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
|
|
406
|
+
if (entries.length >= maxEntries) {
|
|
407
|
+
truncated = true;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
const described = await this.describe(child);
|
|
411
|
+
entries.push({
|
|
412
|
+
path: relativeToJail,
|
|
413
|
+
type: described.type,
|
|
414
|
+
...described.size !== void 0 ? { size: described.size } : {},
|
|
415
|
+
depth: currentDepth
|
|
416
|
+
});
|
|
417
|
+
if (described.type === "directory") nextLevel.push(child);
|
|
418
|
+
}
|
|
419
|
+
if (truncated) break;
|
|
420
|
+
}
|
|
421
|
+
if (truncated) break;
|
|
422
|
+
level = nextLevel;
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
path: insideCwd ? rootRelative || "." : root,
|
|
426
|
+
entries,
|
|
427
|
+
truncated
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Kind and size of a listed entry; symlinks are reported, never followed. */
|
|
431
|
+
async describe(absPath) {
|
|
432
|
+
if (this.backend.stat) try {
|
|
433
|
+
const stats = await this.backend.stat(absPath);
|
|
434
|
+
return stats.type === "file" ? {
|
|
435
|
+
type: "file",
|
|
436
|
+
size: stats.size
|
|
437
|
+
} : { type: stats.type };
|
|
438
|
+
} catch {
|
|
439
|
+
return { type: "other" };
|
|
440
|
+
}
|
|
441
|
+
if (await this.isDirectory(absPath)) try {
|
|
442
|
+
await resolveInJail(this.policy, absPath);
|
|
443
|
+
return { type: "directory" };
|
|
444
|
+
} catch {
|
|
445
|
+
return { type: "other" };
|
|
446
|
+
}
|
|
447
|
+
return { type: "file" };
|
|
448
|
+
}
|
|
308
449
|
async grep(pattern, opts) {
|
|
309
450
|
if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
|
|
310
451
|
type: "unsafe-pattern",
|
|
@@ -328,7 +469,7 @@ var Ops = class {
|
|
|
328
469
|
}
|
|
329
470
|
const lines = content.split("\n");
|
|
330
471
|
for (let index = 0; index < lines.length; index++) {
|
|
331
|
-
const line = lines[index];
|
|
472
|
+
const line = lines[index] ?? "";
|
|
332
473
|
if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
|
|
333
474
|
if (regex.test(line)) {
|
|
334
475
|
matches.push({
|
|
@@ -375,17 +516,40 @@ var Ops = class {
|
|
|
375
516
|
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
376
517
|
isDenied(relativePath) {
|
|
377
518
|
const denyPaths = this.policy.denyPaths;
|
|
378
|
-
|
|
379
|
-
return denyPaths.some((glob) => {
|
|
380
|
-
if (globToRegExp(glob).test(relativePath)) return true;
|
|
381
|
-
if (!glob.includes("*") && !glob.includes("?")) {
|
|
382
|
-
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
383
|
-
return relativePath.startsWith(prefix);
|
|
384
|
-
}
|
|
385
|
-
return false;
|
|
386
|
-
});
|
|
519
|
+
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
387
520
|
}
|
|
388
521
|
};
|
|
522
|
+
/** The lower of two optional limits (either may be absent). */
|
|
523
|
+
function lowerOf(policyLimit, requested) {
|
|
524
|
+
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
525
|
+
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Apply one exact-string replacement to `content` under the edit rules:
|
|
529
|
+
* `oldString` must occur, and exactly once unless `replaceAll`.
|
|
530
|
+
*/
|
|
531
|
+
function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
|
|
532
|
+
const occurrences = countOccurrences(content, edit.oldString);
|
|
533
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
534
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
535
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
536
|
+
if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
|
|
537
|
+
type: "not-found",
|
|
538
|
+
path: shownPath,
|
|
539
|
+
matches: 0,
|
|
540
|
+
...indexOption
|
|
541
|
+
});
|
|
542
|
+
if (occurrences > 1 && !edit.replaceAll) throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${occurrences} matches); pass replaceAll or include more surrounding context.${nothingApplied}`, {
|
|
543
|
+
type: "not-unique",
|
|
544
|
+
path: shownPath,
|
|
545
|
+
matches: occurrences,
|
|
546
|
+
...indexOption
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
|
|
550
|
+
replacements: edit.replaceAll ? occurrences : 1
|
|
551
|
+
};
|
|
552
|
+
}
|
|
389
553
|
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
390
554
|
function countOccurrences(haystack, needle) {
|
|
391
555
|
if (needle === "") return 0;
|
|
@@ -417,13 +581,15 @@ function replaceFirst(haystack, needle, replacement) {
|
|
|
417
581
|
*
|
|
418
582
|
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
419
583
|
* @param policy - The policy that bounds every operation.
|
|
584
|
+
* @param runtime - State shared with other views of the same workspace
|
|
585
|
+
* (its background commands); a fresh one when omitted.
|
|
420
586
|
*
|
|
421
587
|
* @example
|
|
422
588
|
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
423
589
|
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
424
590
|
*/
|
|
425
|
-
function createOps(backend, policy) {
|
|
426
|
-
return new Ops(backend, policy);
|
|
591
|
+
function createOps(backend, policy, runtime) {
|
|
592
|
+
return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
|
|
427
593
|
}
|
|
428
594
|
|
|
429
595
|
//#endregion
|
package/esm/ops.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { buildEnv, isCommandAllowed, resolveInJail } from \"./policy/policy\";\nimport type {\n EditFileInput,\n EditFileResult,\n ExecOptions,\n GrepMatch,\n GrepResult,\n ReadFileResult,\n RunShellResult,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n/**\n * Exit code reported for a command refused because its signal was already\n * aborted (the shell convention for an interrupted command). Callers should\n * branch on `cancelled`, not on this number.\n */\nconst CANCELLED_BEFORE_START_EXIT_CODE = 130;\n/**\n * Hard ceiling on `grep` pattern length. A model-controlled regex has no\n * legitimate reason to be this long; longer patterns are rejected outright\n * rather than compiled.\n */\nconst MAX_GREP_PATTERN_LENGTH = 200;\n/**\n * Hard ceiling on the number of characters of a single line handed to\n * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential\n * in input length, so bounding the input scanned per call bounds the\n * worst-case time a single pathological line can cost — lines longer than\n * this are skipped rather than scanned.\n */\nconst MAX_GREP_LINE_SCAN_LENGTH = 2000;\n\n/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */\nfunction linePrefix(lineNumber: number): string {\n return `${String(lineNumber).padStart(6, \" \")}\\t`;\n}\n\n/**\n * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a\n * character: the cut backs off over continuation bytes (`10xxxxxx`) to the\n * start of the character it would have split, so the result never carries\n * a replacement character.\n */\nfunction clipUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n let cut = Math.max(0, maxBytes);\n\n while (cut > 0 && ((bytes[cut] ?? 0) & 0xc0) === 0x80) {\n cut--;\n }\n\n return { value: bytes.subarray(0, cut).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Number a window of lines `cat -n` style, keeping the joined output within\n * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.\n * A line that does not fit whole is clipped on a character boundary; it is\n * still counted as returned when its prefix and at least one character fit.\n */\nfunction numberWindow(\n lines: string[],\n startLine: number,\n maxBytes: number | undefined,\n): { content: string; returnedLines: number; bytesCapped: boolean } {\n const parts: string[] = [];\n let used = 0;\n\n for (let index = 0; index < lines.length; index++) {\n const prefix = linePrefix(startLine + index);\n const numbered = prefix + lines[index];\n const separator = parts.length > 0 ? 1 : 0;\n\n if (maxBytes === undefined) {\n parts.push(numbered);\n\n continue;\n }\n\n const cost = separator + Buffer.byteLength(numbered, \"utf8\");\n\n if (used + cost <= maxBytes) {\n parts.push(numbered);\n used += cost;\n\n continue;\n }\n\n const room = maxBytes - used - separator - Buffer.byteLength(prefix, \"utf8\");\n const clipped = room > 0 ? clipUtf8(lines[index] ?? \"\", room).value : \"\";\n\n if (clipped.length > 0) {\n parts.push(prefix + clipped);\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: true };\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: false };\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a\n * character boundary. Returns the possibly-clipped string plus whether\n * clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n return clipUtf8(value, maxBytes);\n}\n\n/**\n * Minimal glob match over a `/`-separated relative path. Supports `**`\n * (spans separators), `*` (within a segment), and `?` (one non-separator\n * char) — enough for the workspace's `glob`/grep narrowing without\n * pulling in a runtime dependency.\n */\nfunction globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index];\n\n if (char === \"*\") {\n if (glob[index + 1] === \"*\") {\n source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.\n */\nconst QUANTIFIER_SOURCE = String.raw`[+*?]|\\{\\d*,?\\d*\\}`;\n\n/**\n * Heuristic catastrophic-backtracking detector: flags a quantified group\n * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —\n * the classic exponential-blowup shape. Not a full regex-safety analyzer\n * (it won't catch every ReDoS shape, e.g. quantified alternation like\n * `(a|a)+`), but it rejects the shape an agent is most likely to emit,\n * intentionally or via prompt injection.\n */\nconst NESTED_QUANTIFIER_PATTERN = new RegExp(\n String.raw`\\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\\)(?:${QUANTIFIER_SOURCE})`,\n);\n\n/**\n * Whether `pattern` is safe enough to compile and run against workspace\n * content: within the length cap and free of the nested-quantifier shape\n * that causes catastrophic regex backtracking (ReDoS).\n */\nfunction isSafeGrepPattern(pattern: string): boolean {\n if (pattern.length > MAX_GREP_PATTERN_LENGTH) {\n return false;\n }\n\n return !NESTED_QUANTIFIER_PATTERN.test(pattern);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\nclass Ops implements WorkspaceOps {\n /**\n * Tail of the mutation chain per resolved absolute path. Writes, edits and\n * removes on the same path run one after another, so two tool calls the\n * agent dispatches concurrently cannot both read the same content, both\n * pass the stale-hash guard and silently drop one edit: the second one\n * re-reads after the first has written.\n */\n private readonly pathLocks = new Map<string, Promise<void>>();\n\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n ) {}\n\n /** Run `task` after every earlier mutation of `absolutePath` has settled. */\n private async withPathLock<T>(absolutePath: string, task: () => Promise<T>): Promise<T> {\n const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();\n let release: () => void = () => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const tail = previous.then(() => current);\n\n this.pathLocks.set(absolutePath, tail);\n\n try {\n await previous;\n\n return await task();\n } finally {\n release();\n\n if (this.pathLocks.get(absolutePath) === tail) {\n this.pathLocks.delete(absolutePath);\n }\n }\n }\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<ReadFileResult> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the full content the backend returned (SHA-256 via\n // @warlock.js/fs), not the returned window and not the on-disk file —\n // the mock backend keeps content in memory, and the stale-edit guard\n // later hashes the backend's content too, so it must be the SAME\n // source of truth whatever window or byte cap applied.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n const maxBytes = this.policy.read?.maxBytes;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + Math.max(0, limit));\n const { content, returnedLines, bytesCapped } = numberWindow(\n window,\n offset,\n maxBytes === undefined ? undefined : Math.max(0, Math.floor(maxBytes)),\n );\n const endLine = offset + returnedLines - 1;\n\n return {\n content,\n startLine: offset,\n endLine,\n totalLines,\n truncated: bytesCapped || endLine < totalLines,\n bytesCapped,\n totalBytes: Buffer.byteLength(raw, \"utf8\"),\n hash,\n };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.withPathLock(absolutePath, async () => {\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n });\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n\n return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));\n }\n\n /** The read-check-replace-write of {@link editFile}, run under its path lock. */\n private async applyEdit(\n input: EditFileInput,\n absolutePath: string,\n relativePath: string,\n ): Promise<EditFileResult> {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const occurrences = countOccurrences(current, input.oldString);\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `The text to replace was not found in \"${input.path}\".`,\n { type: \"not-found\", path: relativePath || input.path, matches: 0 },\n );\n }\n\n if (occurrences > 1 && !input.replaceAll) {\n throw new WorkspaceEditError(\n `The text to replace is not unique in \"${input.path}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.`,\n { type: \"not-unique\", path: relativePath || input.path, matches: occurrences },\n );\n }\n\n const updated = input.replaceAll\n ? current.split(input.oldString).join(input.newString)\n : replaceFirst(current, input.oldString, input.newString);\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements: input.replaceAll ? occurrences : 1,\n hash: fs.hash.string(updated),\n };\n }\n\n public async exec(command: string, opts?: ExecOptions): Promise<RunShellResult> {\n if (!isCommandAllowed(this.policy, command)) {\n throw new WorkspacePolicyError(\n `Command is not permitted by the workspace shell policy: ${command}`,\n { type: \"denied-command\", command },\n );\n }\n\n const signal = opts?.signal;\n\n // Refuse an already-cancelled invocation here, so no backend — built-in\n // or custom — ever starts a command whose caller has given up on it.\n if (signal?.aborted) {\n return {\n exitCode: CANCELLED_BEFORE_START_EXIT_CODE,\n stdout: \"\",\n stderr: \"Command was not executed: the invocation was cancelled before it started.\",\n truncated: false,\n timedOut: false,\n cancelled: true,\n };\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildEnv(this.policy),\n ...(signal ? { signal } : {}),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n const cancelled = result.cancelled === true;\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut && !cancelled,\n cancelled,\n };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n if (!isSafeGrepPattern(pattern)) {\n throw new WorkspacePolicyError(\n `Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`,\n { type: \"unsafe-pattern\", pattern },\n );\n }\n\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n\n // Cap the input size a single `RegExp#test` call scans: backtracking\n // cost is exponential in input length, so this bounds the worst-case\n // time even a pathological (but length/shape-allowed) pattern can\n // burn on any one line.\n if (line.length > MAX_GREP_LINE_SCAN_LENGTH) {\n continue;\n }\n\n if (regex.test(line)) {\n matches.push({ path: relativePath, line: index + 1, text: line });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n if (!denyPaths || denyPaths.length === 0) {\n return false;\n }\n\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n }\n}\n\n/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n): WorkspaceOps {\n return new Ops(backend, policy);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { globToRegExp, isInside, matchesAnyGlob } from \"./policy/jail\";\nimport { buildCommandEnv, resolveInJail } from \"./policy/policy\";\nimport { checkShellCommand } from \"./policy/shell-check\";\nimport { DEFAULT_MAX_BACKGROUND_JOBS, ShellJobs } from \"./shell-jobs\";\nimport type {\n EditFileInput,\n EditFileResult,\n ExecOptions,\n GrepMatch,\n GrepResult,\n ListDirEntry,\n ListDirEntryType,\n ListDirOptions,\n ListDirResult,\n MultiEditInput,\n MultiEditOperation,\n MultiEditResult,\n ReadFileResult,\n RunShellResult,\n ShellJobInfo,\n ShellOutputOptions,\n ShellOutputResult,\n StartShellOptions,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n/** Default `list.ignore` when the policy sets none. */\nconst DEFAULT_LIST_IGNORE: readonly string[] = [\"node_modules\", \".git\"];\n/** Default deepest `depth` a listing may ask for. */\nconst DEFAULT_LIST_MAX_DEPTH = 5;\n/** Default most entries one listing returns. */\nconst DEFAULT_LIST_MAX_ENTRIES = 500;\n/** Most edits one `multiEdit` call may carry. */\nexport const MAX_MULTI_EDITS = 100;\n/**\n * Exit code reported for a command refused because its signal was already\n * aborted (the shell convention for an interrupted command). Callers should\n * branch on `cancelled`, not on this number.\n */\nconst CANCELLED_BEFORE_START_EXIT_CODE = 130;\n/**\n * Hard ceiling on `grep` pattern length. A model-controlled regex has no\n * legitimate reason to be this long; longer patterns are rejected outright\n * rather than compiled.\n */\nconst MAX_GREP_PATTERN_LENGTH = 200;\n/**\n * Hard ceiling on the number of characters of a single line handed to\n * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential\n * in input length, so bounding the input scanned per call bounds the\n * worst-case time a single pathological line can cost — lines longer than\n * this are skipped rather than scanned.\n */\nconst MAX_GREP_LINE_SCAN_LENGTH = 2000;\n\n/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */\nfunction linePrefix(lineNumber: number): string {\n return `${String(lineNumber).padStart(6, \" \")}\\t`;\n}\n\n/**\n * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a\n * character: the cut backs off over continuation bytes (`10xxxxxx`) to the\n * start of the character it would have split, so the result never carries\n * a replacement character.\n */\nfunction clipUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n let cut = Math.max(0, maxBytes);\n\n while (cut > 0 && ((bytes[cut] ?? 0) & 0xc0) === 0x80) {\n cut--;\n }\n\n return { value: bytes.subarray(0, cut).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Number a window of lines `cat -n` style, keeping the joined output within\n * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.\n * A line that does not fit whole is clipped on a character boundary; it is\n * still counted as returned when its prefix and at least one character fit.\n */\nfunction numberWindow(\n lines: string[],\n startLine: number,\n maxBytes: number | undefined,\n): { content: string; returnedLines: number; bytesCapped: boolean } {\n const parts: string[] = [];\n let used = 0;\n\n for (let index = 0; index < lines.length; index++) {\n const prefix = linePrefix(startLine + index);\n const numbered = prefix + lines[index];\n const separator = parts.length > 0 ? 1 : 0;\n\n if (maxBytes === undefined) {\n parts.push(numbered);\n\n continue;\n }\n\n const cost = separator + Buffer.byteLength(numbered, \"utf8\");\n\n if (used + cost <= maxBytes) {\n parts.push(numbered);\n used += cost;\n\n continue;\n }\n\n const room = maxBytes - used - separator - Buffer.byteLength(prefix, \"utf8\");\n const clipped = room > 0 ? clipUtf8(lines[index] ?? \"\", room).value : \"\";\n\n if (clipped.length > 0) {\n parts.push(prefix + clipped);\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: true };\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: false };\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a\n * character boundary. Returns the possibly-clipped string plus whether\n * clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n return clipUtf8(value, maxBytes);\n}\n\n/**\n * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.\n */\nconst QUANTIFIER_SOURCE = String.raw`[+*?]|\\{\\d*,?\\d*\\}`;\n\n/**\n * Heuristic catastrophic-backtracking detector: flags a quantified group\n * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —\n * the classic exponential-blowup shape. Not a full regex-safety analyzer\n * (it won't catch every ReDoS shape, e.g. quantified alternation like\n * `(a|a)+`), but it rejects the shape an agent is most likely to emit,\n * intentionally or via prompt injection.\n */\nconst NESTED_QUANTIFIER_PATTERN = new RegExp(\n String.raw`\\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\\)(?:${QUANTIFIER_SOURCE})`,\n);\n\n/**\n * Whether `pattern` is safe enough to compile and run against workspace\n * content: within the length cap and free of the nested-quantifier shape\n * that causes catastrophic regex backtracking (ReDoS).\n */\nfunction isSafeGrepPattern(pattern: string): boolean {\n if (pattern.length > MAX_GREP_PATTERN_LENGTH) {\n return false;\n }\n\n return !NESTED_QUANTIFIER_PATTERN.test(pattern);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\n/**\n * State an ops layer shares with the other projections of the same\n * workspace (`readonly()`, `scope()`): the background commands it owns.\n */\nexport interface OpsRuntime {\n /** The workspace's background commands. */\n jobs: ShellJobs;\n}\n\nclass Ops implements WorkspaceOps {\n /**\n * Tail of the mutation chain per resolved absolute path. Writes, edits and\n * removes on the same path run one after another, so two tool calls the\n * agent dispatches concurrently cannot both read the same content, both\n * pass the stale-hash guard and silently drop one edit: the second one\n * re-reads after the first has written.\n */\n private readonly pathLocks = new Map<string, Promise<void>>();\n\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n private readonly runtime: OpsRuntime,\n ) {}\n\n /** Run `task` after every earlier mutation of `absolutePath` has settled. */\n private async withPathLock<T>(absolutePath: string, task: () => Promise<T>): Promise<T> {\n const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();\n let release: () => void = () => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const tail = previous.then(() => current);\n\n this.pathLocks.set(absolutePath, tail);\n\n try {\n await previous;\n\n return await task();\n } finally {\n release();\n\n if (this.pathLocks.get(absolutePath) === tail) {\n this.pathLocks.delete(absolutePath);\n }\n }\n }\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<ReadFileResult> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the full content the backend returned (SHA-256 via\n // @warlock.js/fs), not the returned window and not the on-disk file —\n // the mock backend keeps content in memory, and the stale-edit guard\n // later hashes the backend's content too, so it must be the SAME\n // source of truth whatever window or byte cap applied.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n const maxBytes = this.policy.read?.maxBytes;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + Math.max(0, limit));\n const { content, returnedLines, bytesCapped } = numberWindow(\n window,\n offset,\n maxBytes === undefined ? undefined : Math.max(0, Math.floor(maxBytes)),\n );\n const endLine = offset + returnedLines - 1;\n\n return {\n content,\n startLine: offset,\n endLine,\n totalLines,\n truncated: bytesCapped || endLine < totalLines,\n bytesCapped,\n totalBytes: Buffer.byteLength(raw, \"utf8\"),\n hash,\n };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.withPathLock(absolutePath, async () => {\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n });\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n\n return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));\n }\n\n /** The read-check-replace-write of {@link editFile}, run under its path lock. */\n private async applyEdit(\n input: EditFileInput,\n absolutePath: string,\n relativePath: string,\n ): Promise<EditFileResult> {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const { content: updated, replacements } = applyReplacement(\n current,\n input,\n input.path,\n relativePath || input.path,\n );\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements,\n hash: fs.hash.string(updated),\n };\n }\n\n public async multiEdit(input: MultiEditInput): Promise<MultiEditResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n const shownPath = relativePath || input.path;\n\n if (input.edits.length === 0 || input.edits.length > MAX_MULTI_EDITS) {\n throw new RangeError(\n `A multi-edit needs between 1 and ${MAX_MULTI_EDITS} edits (got ${input.edits.length}).`,\n );\n }\n\n return this.withPathLock(absolutePath, async () => {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; none of the edits were applied.`,\n {\n type: \"stale-hash\",\n path: shownPath,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n // Apply every edit in memory first; one failure leaves the file untouched.\n let content = current;\n let replacements = 0;\n\n input.edits.forEach((edit, editIndex) => {\n const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);\n\n content = applied.content;\n replacements += applied.replacements;\n });\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n path: shownPath,\n edits: input.edits.length,\n replacements,\n hash: fs.hash.string(content),\n };\n });\n }\n\n /**\n * Check a command line against the shell policy: every command in it\n * (argv or shell mode), then — shell mode — every redirection target and\n * `cd` directory against the jail with symlinks resolved.\n */\n private async assertCommandAllowed(command: string): Promise<void> {\n const verdict = checkShellCommand(this.policy, command);\n\n if (!verdict.allowed) {\n throw new WorkspacePolicyError(\n verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`,\n {\n type: \"denied-command\",\n command,\n ...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),\n ...(verdict.executable !== undefined ? { executable: verdict.executable } : {}),\n ...(verdict.path !== undefined ? { path: verdict.path } : {}),\n },\n );\n }\n\n for (const target of verdict.paths) {\n try {\n await resolveInJail(this.policy, target.path);\n } catch (error) {\n if (!(error instanceof WorkspacePolicyError)) {\n throw error;\n }\n\n throw new WorkspacePolicyError(\n target.access === \"cwd\"\n ? `\\`cd ${target.target}\\` resolves (through a symlink) outside the workspace jail or onto a denied path.`\n : `The redirection target \\`${target.target}\\` resolves (through a symlink) outside the workspace jail or onto a denied path.`,\n { type: \"denied-command\", reason: \"path-escape\", command, path: target.target },\n );\n }\n }\n\n if (this.runtime.jobs.isDisposed) {\n throw new WorkspacePolicyError(\n \"The workspace has been disposed; it cannot run new commands.\",\n { type: \"denied-command\", reason: \"not-allowed\", command },\n );\n }\n }\n\n public async exec(command: string, opts?: ExecOptions): Promise<RunShellResult> {\n await this.assertCommandAllowed(command);\n\n const signal = opts?.signal;\n\n // Refuse an already-cancelled invocation here, so no backend — built-in\n // or custom — ever starts a command whose caller has given up on it.\n if (signal?.aborted) {\n return {\n exitCode: CANCELLED_BEFORE_START_EXIT_CODE,\n stdout: \"\",\n stderr: \"Command was not executed: the invocation was cancelled before it started.\",\n truncated: false,\n timedOut: false,\n cancelled: true,\n };\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n const interpreter = shell?.interpreter ?? \"none\";\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildCommandEnv(this.policy),\n ...(signal ? { signal } : {}),\n ...(interpreter !== \"none\" ? { interpreter } : {}),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n const cancelled = result.cancelled === true;\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut && !cancelled,\n cancelled,\n };\n }\n\n public async startShell(command: string, opts?: StartShellOptions): Promise<ShellJobInfo> {\n const background = this.policy.shell?.background;\n\n if (background === undefined) {\n throw new WorkspacePolicyError(\n \"Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.\",\n { type: \"denied-command\", reason: \"not-allowed\", command },\n );\n }\n\n await this.assertCommandAllowed(command);\n\n const start = this.backend.start?.bind(this.backend);\n\n if (start === undefined) {\n throw new WorkspacePolicyError(\"This workspace backend cannot run background commands.\", {\n type: \"denied-command\",\n reason: \"not-allowed\",\n command,\n });\n }\n\n const shell = this.policy.shell;\n const interpreter = shell?.interpreter ?? \"none\";\n const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);\n const env = buildCommandEnv(this.policy);\n\n return this.runtime.jobs.start({\n command,\n retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,\n maxJobs: background.maxJobs ?? DEFAULT_MAX_BACKGROUND_JOBS,\n ...(timeoutMs !== undefined ? { timeoutMs } : {}),\n launch: (onOutput) =>\n start(command, {\n cwd: this.policy.cwd,\n env,\n ...(interpreter !== \"none\" ? { interpreter } : {}),\n onOutput,\n }),\n });\n }\n\n public shellOutput(id: string, opts?: ShellOutputOptions): Promise<ShellOutputResult> {\n return this.runtime.jobs.output(id, opts);\n }\n\n public shellKill(id: string): Promise<ShellJobInfo> {\n return this.runtime.jobs.kill(id);\n }\n\n public listShells(): ShellJobInfo[] {\n return this.runtime.jobs.list();\n }\n\n public dispose(): Promise<void> {\n return this.runtime.jobs.dispose();\n }\n\n public async listDir(inputPath = \".\", opts?: ListDirOptions): Promise<ListDirResult> {\n const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(\n this.policy,\n inputPath,\n );\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const listPolicy = this.policy.list;\n const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));\n const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);\n const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));\n const ignore = [...(listPolicy?.ignore ?? DEFAULT_LIST_IGNORE), ...(opts?.ignore ?? [])];\n const insideCwd = isInside(root, jailRoot);\n\n if (!(await this.isDirectory(root))) {\n throw new Error(`\"${inputPath}\" is not a directory in the workspace.`);\n }\n\n const entries: ListDirEntry[] = [];\n let level: string[] = [root];\n let truncated = false;\n\n for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {\n const nextLevel: string[] = [];\n\n for (const directory of level) {\n let children: string[];\n\n try {\n children = await this.backend.list(directory);\n } catch {\n continue;\n }\n\n children.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));\n\n for (const child of children) {\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, child).split(path.sep).join(\"/\")\n : child;\n\n if (\n insideCwd &&\n (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))\n ) {\n continue;\n }\n\n const relativeToRoot = path.relative(root, child).split(path.sep).join(\"/\");\n\n if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) {\n continue;\n }\n\n if (entries.length >= maxEntries) {\n truncated = true;\n\n break;\n }\n\n const described = await this.describe(child);\n\n entries.push({\n path: relativeToJail,\n type: described.type,\n ...(described.size !== undefined ? { size: described.size } : {}),\n depth: currentDepth,\n });\n\n if (described.type === \"directory\") {\n nextLevel.push(child);\n }\n }\n\n if (truncated) {\n break;\n }\n }\n\n if (truncated) {\n break;\n }\n\n level = nextLevel;\n }\n\n return { path: insideCwd ? rootRelative || \".\" : root, entries, truncated };\n }\n\n /** Kind and size of a listed entry; symlinks are reported, never followed. */\n private async describe(absPath: string): Promise<{ type: ListDirEntryType; size?: number }> {\n if (this.backend.stat) {\n try {\n const stats = await this.backend.stat(absPath);\n\n return stats.type === \"file\" ? { type: \"file\", size: stats.size } : { type: stats.type };\n } catch {\n return { type: \"other\" };\n }\n }\n\n // No `stat`: a child is a directory iff it can be listed, and it must\n // still resolve inside the jail before it is descended into.\n if (await this.isDirectory(absPath)) {\n try {\n await resolveInJail(this.policy, absPath);\n\n return { type: \"directory\" };\n } catch {\n return { type: \"other\" };\n }\n }\n\n return { type: \"file\" };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n if (!isSafeGrepPattern(pattern)) {\n throw new WorkspacePolicyError(\n `Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`,\n { type: \"unsafe-pattern\", pattern },\n );\n }\n\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index] ?? \"\";\n\n // Cap the input size a single `RegExp#test` call scans: backtracking\n // cost is exponential in input length, so this bounds the worst-case\n // time even a pathological (but length/shape-allowed) pattern can\n // burn on any one line.\n if (line.length > MAX_GREP_LINE_SCAN_LENGTH) {\n continue;\n }\n\n if (regex.test(line)) {\n matches.push({ path: relativePath, line: index + 1, text: line });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n return (\n denyPaths !== undefined && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths)\n );\n }\n}\n\n/** The lower of two optional limits (either may be absent). */\nfunction lowerOf(\n policyLimit: number | undefined,\n requested: number | undefined,\n): number | undefined {\n if (requested === undefined || requested <= 0) {\n return policyLimit;\n }\n\n return policyLimit === undefined ? requested : Math.min(policyLimit, requested);\n}\n\n/**\n * Apply one exact-string replacement to `content` under the edit rules:\n * `oldString` must occur, and exactly once unless `replaceAll`.\n */\nfunction applyReplacement(\n content: string,\n edit: MultiEditOperation,\n inputPath: string,\n shownPath: string,\n editIndex?: number,\n): { content: string; replacements: number } {\n const occurrences = countOccurrences(content, edit.oldString);\n const which = editIndex === undefined ? \"\" : `Edit ${editIndex + 1}: `;\n const nothingApplied = editIndex === undefined ? \"\" : \" None of the edits were applied.\";\n const indexOption = editIndex === undefined ? {} : { editIndex };\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `${which}The text to replace was not found in \"${inputPath}\".${nothingApplied}`,\n { type: \"not-found\", path: shownPath, matches: 0, ...indexOption },\n );\n }\n\n if (occurrences > 1 && !edit.replaceAll) {\n throw new WorkspaceEditError(\n `${which}The text to replace is not unique in \"${inputPath}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.${nothingApplied}`,\n { type: \"not-unique\", path: shownPath, matches: occurrences, ...indexOption },\n );\n }\n\n return {\n content: edit.replaceAll\n ? content.split(edit.oldString).join(edit.newString)\n : replaceFirst(content, edit.oldString, edit.newString),\n replacements: edit.replaceAll ? occurrences : 1,\n };\n}\n\n/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n * @param runtime - State shared with other views of the same workspace\n * (its background commands); a fresh one when omitted.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n runtime?: OpsRuntime,\n): WorkspaceOps {\n return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-workspace/src/policy/jail.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether `child` is contained within `root` (or equals it), comparing
|
|
6
|
+
* absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`
|
|
7
|
+
* prefix-collision by comparing path segments, not string prefixes.
|
|
8
|
+
*/
|
|
9
|
+
function isInside(child, root) {
|
|
10
|
+
const relative = path.relative(root, child);
|
|
11
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Translate a glob (the small subset used by `denyPaths` and
|
|
15
|
+
* `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a
|
|
16
|
+
* `/`-separated relative path. `**` spans path separators; a single `*`
|
|
17
|
+
* does not.
|
|
18
|
+
*/
|
|
19
|
+
function globToRegExp(glob) {
|
|
20
|
+
let source = "";
|
|
21
|
+
for (let index = 0; index < glob.length; index++) {
|
|
22
|
+
const char = glob.charAt(index);
|
|
23
|
+
if (char === "*") {
|
|
24
|
+
if (glob.charAt(index + 1) === "*") {
|
|
25
|
+
source += ".*";
|
|
26
|
+
index++;
|
|
27
|
+
if (glob.charAt(index + 1) === "/") index++;
|
|
28
|
+
} else source += "[^/]*";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (char === "?") {
|
|
32
|
+
source += "[^/]";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
return new RegExp(`^${source}$`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
41
|
+
* globs. A glob also covers everything beneath a matched directory
|
|
42
|
+
* (`".git/**"` covers `.git/config`), and a bare name without wildcards
|
|
43
|
+
* (`".git"`, `"node_modules"`) covers its contents.
|
|
44
|
+
*/
|
|
45
|
+
function matchesAnyGlob(relativePath, globs) {
|
|
46
|
+
return globs.some((glob) => {
|
|
47
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
48
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
49
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
50
|
+
return relativePath.startsWith(prefix);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The jail check without touching the filesystem: is the absolute path
|
|
57
|
+
* `target` (already resolved lexically) inside `cwd` and off every
|
|
58
|
+
* `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT
|
|
59
|
+
* resolved here — the shell policy uses it to refuse obvious escapes
|
|
60
|
+
* synchronously, and `exec` re-checks every path with `resolveInJail`.
|
|
61
|
+
*/
|
|
62
|
+
function isInsideJailLexically(policy, target) {
|
|
63
|
+
const root = path.resolve(policy.cwd);
|
|
64
|
+
if (isInside(target, root)) {
|
|
65
|
+
const relative = path.relative(root, target).split(path.sep).join("/");
|
|
66
|
+
return !(policy.denyPaths && policy.denyPaths.length > 0 && matchesAnyGlob(relative, policy.denyPaths));
|
|
67
|
+
}
|
|
68
|
+
return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, path.resolve(allowRoot)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { globToRegExp, isInside, isInsideJailLexically, matchesAnyGlob };
|
|
73
|
+
//# sourceMappingURL=jail.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jail.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/jail.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`\n * prefix-collision by comparing path segments, not string prefixes.\n */\nexport function isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return relative === \"\" || (!relative.startsWith(\"..\") && !path.isAbsolute(relative));\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` and\n * `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a\n * `/`-separated relative path. `**` spans path separators; a single `*`\n * does not.\n */\nexport function globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob.charAt(index);\n\n if (char === \"*\") {\n if (glob.charAt(index + 1) === \"*\") {\n // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob.charAt(index + 1) === \"/\") {\n index++;\n }\n } else {\n // `*` — match within a single segment.\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n // Escape everything else so it matches literally.\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * Whether a workspace-relative (`/`-separated) path matches any of the\n * globs. A glob also covers everything beneath a matched directory\n * (`\".git/**\"` covers `.git/config`), and a bare name without wildcards\n * (`\".git\"`, `\"node_modules\"`) covers its contents.\n */\nexport function matchesAnyGlob(relativePath: string, globs: readonly string[]): boolean {\n return globs.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n}\n\n/**\n * The jail check without touching the filesystem: is the absolute path\n * `target` (already resolved lexically) inside `cwd` and off every\n * `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT\n * resolved here — the shell policy uses it to refuse obvious escapes\n * synchronously, and `exec` re-checks every path with `resolveInJail`.\n */\nexport function isInsideJailLexically(policy: WorkspacePolicy, target: string): boolean {\n const root = path.resolve(policy.cwd);\n\n if (isInside(target, root)) {\n const relative = path.relative(root, target).split(path.sep).join(\"/\");\n\n return !(\n policy.denyPaths &&\n policy.denyPaths.length > 0 &&\n matchesAnyGlob(relative, policy.denyPaths)\n );\n }\n\n return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, path.resolve(allowRoot)));\n}\n"],"mappings":";;;;;;;;AAQA,SAAgB,SAAS,OAAe,MAAuB;CAC7D,MAAM,WAAW,KAAK,SAAS,MAAM,KAAK;CAE1C,OAAO,aAAa,MAAO,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,KAAK,WAAW,QAAQ;AACpF;;;;;;;AAQA,SAAgB,aAAa,MAAsB;CACjD,IAAI,SAAS;CAEb,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EAChD,MAAM,OAAO,KAAK,OAAO,KAAK;EAE9B,IAAI,SAAS,KAAK;GAChB,IAAI,KAAK,OAAO,QAAQ,CAAC,MAAM,KAAK;IAElC,UAAU;IACV;IAEA,IAAI,KAAK,OAAO,QAAQ,CAAC,MAAM,KAC7B;GAEJ,OAEE,UAAU;GAGZ;EACF;EAEA,IAAI,SAAS,KAAK;GAChB,UAAU;GAEV;EACF;EAGA,UAAU,KAAK,QAAQ,qBAAqB,MAAM;CACpD;CAEA,OAAO,IAAI,OAAO,IAAI,OAAO,EAAE;AACjC;;;;;;;AAQA,SAAgB,eAAe,cAAsB,OAAmC;CACtF,OAAO,MAAM,MAAM,SAAS;EAC1B,IAAI,aAAa,IAAI,CAAC,CAAC,KAAK,YAAY,GACtC,OAAO;EAGT,IAAI,CAAC,KAAK,SAAS,GAAG,KAAK,CAAC,KAAK,SAAS,GAAG,GAAG;GAC9C,MAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,KAAK;GAEnD,OAAO,aAAa,WAAW,MAAM;EACvC;EAEA,OAAO;CACT,CAAC;AACH;;;;;;;;AASA,SAAgB,sBAAsB,QAAyB,QAAyB;CACtF,MAAM,OAAO,KAAK,QAAQ,OAAO,GAAG;CAEpC,IAAI,SAAS,QAAQ,IAAI,GAAG;EAC1B,MAAM,WAAW,KAAK,SAAS,MAAM,MAAM,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG;EAErE,OAAO,EACL,OAAO,aACP,OAAO,UAAU,SAAS,KAC1B,eAAe,UAAU,OAAO,SAAS;CAE7C;CAEA,QAAQ,OAAO,cAAc,CAAC,EAAC,CAAE,MAAM,cAAc,SAAS,QAAQ,KAAK,QAAQ,SAAS,CAAC,CAAC;AAChG"}
|