@warlock.js/ai-workspace 4.5.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/cjs/index.cjs +1609 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/backends/local.d.mts +22 -0
- package/esm/backends/local.d.mts.map +1 -0
- package/esm/backends/local.mjs +208 -0
- package/esm/backends/local.mjs.map +1 -0
- package/esm/backends/mock.d.mts +62 -0
- package/esm/backends/mock.d.mts.map +1 -0
- package/esm/backends/mock.mjs +167 -0
- package/esm/backends/mock.mjs.map +1 -0
- package/esm/contracts/index.d.mts +5 -0
- package/esm/contracts/tool-io.type.d.mts +149 -0
- package/esm/contracts/tool-io.type.d.mts.map +1 -0
- package/esm/contracts/workspace-backend.contract.d.mts +69 -0
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-ops.contract.d.mts +72 -0
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-policy.type.d.mts +86 -0
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -0
- package/esm/contracts/workspace.contract.d.mts +131 -0
- package/esm/contracts/workspace.contract.d.mts.map +1 -0
- package/esm/errors.d.mts +100 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +58 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/index.d.mts +20 -0
- package/esm/index.mjs +15 -0
- package/esm/ops.d.mts +25 -0
- package/esm/ops.d.mts.map +1 -0
- package/esm/ops.mjs +294 -0
- package/esm/ops.mjs.map +1 -0
- package/esm/policy/policy.d.mts +71 -0
- package/esm/policy/policy.d.mts.map +1 -0
- package/esm/policy/policy.mjs +184 -0
- package/esm/policy/policy.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +40 -0
- package/esm/tools/edit-file.d.mts.map +1 -0
- package/esm/tools/edit-file.mjs +57 -0
- package/esm/tools/edit-file.mjs.map +1 -0
- package/esm/tools/glob.d.mts +37 -0
- package/esm/tools/glob.d.mts.map +1 -0
- package/esm/tools/glob.mjs +45 -0
- package/esm/tools/glob.mjs.map +1 -0
- package/esm/tools/grep.d.mts +36 -0
- package/esm/tools/grep.d.mts.map +1 -0
- package/esm/tools/grep.mjs +51 -0
- package/esm/tools/grep.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +35 -0
- package/esm/tools/read-file.d.mts.map +1 -0
- package/esm/tools/read-file.mjs +64 -0
- package/esm/tools/read-file.mjs.map +1 -0
- package/esm/tools/run-shell.d.mts +35 -0
- package/esm/tools/run-shell.d.mts.map +1 -0
- package/esm/tools/run-shell.mjs +65 -0
- package/esm/tools/run-shell.mjs.map +1 -0
- package/esm/tools/run-tests.d.mts +40 -0
- package/esm/tools/run-tests.d.mts.map +1 -0
- package/esm/tools/run-tests.mjs +67 -0
- package/esm/tools/run-tests.mjs.map +1 -0
- package/esm/tools/schema.mjs +111 -0
- package/esm/tools/schema.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +33 -0
- package/esm/tools/write-file.d.mts.map +1 -0
- package/esm/tools/write-file.mjs +52 -0
- package/esm/tools/write-file.mjs.map +1 -0
- package/esm/workspace.d.mts +54 -0
- package/esm/workspace.d.mts.map +1 -0
- package/esm/workspace.mjs +210 -0
- package/esm/workspace.mjs.map +1 -0
- package/llms-full.txt +231 -0
- package/llms.txt +10 -0
- package/package.json +42 -0
- package/skills/README.md +13 -0
- package/skills/build-loop-agent/SKILL.md +100 -0
- package/skills/use-a-workspace/SKILL.md +117 -0
package/esm/ops.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { WorkspaceEditError, WorkspacePolicyError } from "./errors.mjs";
|
|
2
|
+
import { buildEnv, isCommandAllowed, resolveInJail } from "./policy/policy.mjs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { hashString } from "@warlock.js/fs";
|
|
5
|
+
|
|
6
|
+
//#region ../@warlock.js/ai-workspace/src/ops.ts
|
|
7
|
+
/** Default line window a read returns when the policy sets no `defaultLines`. */
|
|
8
|
+
const DEFAULT_READ_LINES = 2e3;
|
|
9
|
+
/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
|
|
10
|
+
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
11
|
+
/** Default per-command output byte cap when the policy sets none. */
|
|
12
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
13
|
+
/**
|
|
14
|
+
* Number the lines of `content` `cat -n` style: a right-aligned line
|
|
15
|
+
* number (min width 6), a tab, then the line. `startLine` is the 1-based
|
|
16
|
+
* number of the first line in the window.
|
|
17
|
+
*/
|
|
18
|
+
function numberLines(content, startLine) {
|
|
19
|
+
return content.split("\n").map((line, index) => {
|
|
20
|
+
const lineNumber = startLine + index;
|
|
21
|
+
return `${String(lineNumber).padStart(6, " ")}\t${line}`;
|
|
22
|
+
}).join("\n");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
|
|
26
|
+
* Returns the possibly-clipped string plus whether clipping occurred.
|
|
27
|
+
*/
|
|
28
|
+
function capOutput(value, maxBytes) {
|
|
29
|
+
const bytes = Buffer.from(value, "utf8");
|
|
30
|
+
if (bytes.byteLength <= maxBytes) return {
|
|
31
|
+
value,
|
|
32
|
+
truncated: false
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
value: bytes.subarray(0, maxBytes).toString("utf8"),
|
|
36
|
+
truncated: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Minimal glob match over a `/`-separated relative path. Supports `**`
|
|
41
|
+
* (spans separators), `*` (within a segment), and `?` (one non-separator
|
|
42
|
+
* char) — enough for the workspace's `glob`/grep narrowing without
|
|
43
|
+
* pulling in a runtime dependency.
|
|
44
|
+
*/
|
|
45
|
+
function globToRegExp(glob) {
|
|
46
|
+
let source = "";
|
|
47
|
+
for (let index = 0; index < glob.length; index++) {
|
|
48
|
+
const char = glob[index];
|
|
49
|
+
if (char === "*") {
|
|
50
|
+
if (glob[index + 1] === "*") {
|
|
51
|
+
source += ".*";
|
|
52
|
+
index++;
|
|
53
|
+
if (glob[index + 1] === "/") index++;
|
|
54
|
+
} else source += "[^/]*";
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (char === "?") {
|
|
58
|
+
source += "[^/]";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
}
|
|
63
|
+
return new RegExp(`^${source}$`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
67
|
+
* Holds the backend + policy and is the one place the jail, command
|
|
68
|
+
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
69
|
+
* the agent-facing tools and the human-facing direct methods funnel
|
|
70
|
+
* through this object, so there is exactly one set of rules.
|
|
71
|
+
*/
|
|
72
|
+
var Ops = class {
|
|
73
|
+
constructor(backend, policy) {
|
|
74
|
+
this.backend = backend;
|
|
75
|
+
this.policy = policy;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Recursively collect every file under `absDir` as absolute paths,
|
|
79
|
+
* via the backend's `list` (so it works over disk or the in-memory
|
|
80
|
+
* mock). Directories are descended; files are accumulated.
|
|
81
|
+
*/
|
|
82
|
+
async walkFiles(absDir) {
|
|
83
|
+
const found = [];
|
|
84
|
+
const entries = await this.backend.list(absDir);
|
|
85
|
+
await Promise.all(entries.map(async (entry) => {
|
|
86
|
+
if (await this.isDirectory(entry)) {
|
|
87
|
+
const nested = await this.walkFiles(entry);
|
|
88
|
+
found.push(...nested);
|
|
89
|
+
} else found.push(entry);
|
|
90
|
+
}));
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/** Whether an absolute path is a directory, by attempting to list it. */
|
|
94
|
+
async isDirectory(absPath) {
|
|
95
|
+
try {
|
|
96
|
+
await this.backend.list(absPath);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async readFile(inputPath, opts) {
|
|
103
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
104
|
+
const raw = await this.backend.readFile(absolutePath);
|
|
105
|
+
const hash = hashString(raw);
|
|
106
|
+
const lines = raw.split("\n");
|
|
107
|
+
const totalLines = lines.length;
|
|
108
|
+
const offset = Math.max(1, opts?.offset ?? 1);
|
|
109
|
+
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
110
|
+
const startIndex = offset - 1;
|
|
111
|
+
return {
|
|
112
|
+
content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
|
|
113
|
+
hash,
|
|
114
|
+
totalLines
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async writeFile(inputPath, content) {
|
|
118
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
119
|
+
const parent = path.dirname(absolutePath);
|
|
120
|
+
await this.backend.mkdir(parent);
|
|
121
|
+
await this.backend.writeFile(absolutePath, content);
|
|
122
|
+
return {
|
|
123
|
+
hash: hashString(content),
|
|
124
|
+
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async editFile(input) {
|
|
128
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
129
|
+
const current = await this.backend.readFile(absolutePath);
|
|
130
|
+
const currentHash = hashString(current);
|
|
131
|
+
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
|
|
132
|
+
type: "stale-hash",
|
|
133
|
+
path: relativePath || input.path,
|
|
134
|
+
expectedHash: input.expectHash,
|
|
135
|
+
actualHash: currentHash
|
|
136
|
+
});
|
|
137
|
+
const occurrences = countOccurrences(current, input.oldString);
|
|
138
|
+
if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
|
|
139
|
+
type: "not-found",
|
|
140
|
+
path: relativePath || input.path,
|
|
141
|
+
matches: 0
|
|
142
|
+
});
|
|
143
|
+
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.`, {
|
|
144
|
+
type: "not-unique",
|
|
145
|
+
path: relativePath || input.path,
|
|
146
|
+
matches: occurrences
|
|
147
|
+
});
|
|
148
|
+
const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
|
|
149
|
+
await this.backend.writeFile(absolutePath, updated);
|
|
150
|
+
return {
|
|
151
|
+
path: relativePath || input.path,
|
|
152
|
+
replacements: input.replaceAll ? occurrences : 1,
|
|
153
|
+
hash: hashString(updated)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async exec(command, opts) {
|
|
157
|
+
if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
|
|
158
|
+
type: "denied-command",
|
|
159
|
+
command
|
|
160
|
+
});
|
|
161
|
+
const shell = this.policy.shell;
|
|
162
|
+
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
163
|
+
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
164
|
+
const result = await this.backend.exec(command, {
|
|
165
|
+
cwd: this.policy.cwd,
|
|
166
|
+
timeoutMs,
|
|
167
|
+
env: buildEnv(this.policy)
|
|
168
|
+
});
|
|
169
|
+
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
170
|
+
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
171
|
+
return {
|
|
172
|
+
exitCode: result.exitCode,
|
|
173
|
+
stdout: stdout.value,
|
|
174
|
+
stderr: stderr.value,
|
|
175
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
176
|
+
timedOut: result.timedOut
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
async grep(pattern, opts) {
|
|
180
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
181
|
+
const flags = opts?.ignoreCase ? "i" : "";
|
|
182
|
+
const regex = new RegExp(pattern, flags);
|
|
183
|
+
const globRegex = opts?.glob ? globToRegExp(opts.glob) : void 0;
|
|
184
|
+
const files = await this.walkFiles(jailRoot);
|
|
185
|
+
const matches = [];
|
|
186
|
+
for (const absFile of files) {
|
|
187
|
+
const relativePath = path.relative(jailRoot, absFile).split(path.sep).join("/");
|
|
188
|
+
if (globRegex && !globRegex.test(relativePath)) continue;
|
|
189
|
+
if (this.isDenied(relativePath)) continue;
|
|
190
|
+
let content;
|
|
191
|
+
try {
|
|
192
|
+
content = await this.backend.readFile(absFile);
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const lines = content.split("\n");
|
|
197
|
+
for (let index = 0; index < lines.length; index++) if (regex.test(lines[index])) {
|
|
198
|
+
matches.push({
|
|
199
|
+
path: relativePath,
|
|
200
|
+
line: index + 1,
|
|
201
|
+
text: lines[index]
|
|
202
|
+
});
|
|
203
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
|
|
204
|
+
matches,
|
|
205
|
+
total: matches.length
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
matches,
|
|
211
|
+
total: matches.length
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async glob(pattern) {
|
|
215
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
216
|
+
const regex = globToRegExp(pattern);
|
|
217
|
+
const files = await this.walkFiles(jailRoot);
|
|
218
|
+
const matched = [];
|
|
219
|
+
for (const absFile of files) {
|
|
220
|
+
const relativePath = path.relative(jailRoot, absFile).split(path.sep).join("/");
|
|
221
|
+
if (this.isDenied(relativePath)) continue;
|
|
222
|
+
if (regex.test(relativePath)) matched.push(relativePath);
|
|
223
|
+
}
|
|
224
|
+
return matched.sort();
|
|
225
|
+
}
|
|
226
|
+
async exists(inputPath) {
|
|
227
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
228
|
+
return this.backend.exists(absolutePath);
|
|
229
|
+
}
|
|
230
|
+
async mkdir(inputPath) {
|
|
231
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
232
|
+
await this.backend.mkdir(absolutePath);
|
|
233
|
+
}
|
|
234
|
+
async remove(inputPath) {
|
|
235
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
236
|
+
await this.backend.remove(absolutePath);
|
|
237
|
+
}
|
|
238
|
+
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
239
|
+
isDenied(relativePath) {
|
|
240
|
+
const denyPaths = this.policy.denyPaths;
|
|
241
|
+
if (!denyPaths || denyPaths.length === 0) return false;
|
|
242
|
+
return denyPaths.some((glob) => {
|
|
243
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
244
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
245
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
246
|
+
return relativePath.startsWith(prefix);
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
253
|
+
function countOccurrences(haystack, needle) {
|
|
254
|
+
if (needle === "") return 0;
|
|
255
|
+
let count = 0;
|
|
256
|
+
let from = 0;
|
|
257
|
+
while (true) {
|
|
258
|
+
const index = haystack.indexOf(needle, from);
|
|
259
|
+
if (index === -1) break;
|
|
260
|
+
count++;
|
|
261
|
+
from = index + needle.length;
|
|
262
|
+
}
|
|
263
|
+
return count;
|
|
264
|
+
}
|
|
265
|
+
/** Replace the first occurrence of `needle` with `replacement`. */
|
|
266
|
+
function replaceFirst(haystack, needle, replacement) {
|
|
267
|
+
const index = haystack.indexOf(needle);
|
|
268
|
+
if (index === -1) return haystack;
|
|
269
|
+
return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Create the policy-enforced operation layer over a backend.
|
|
273
|
+
*
|
|
274
|
+
* The returned {@link WorkspaceOps} is the single seam both the
|
|
275
|
+
* agent-facing `.tools.*` factories and the human-facing direct methods
|
|
276
|
+
* delegate to — one jail, one command-gate, one read-before-edit guard,
|
|
277
|
+
* regardless of caller. Path inputs are workspace-relative and resolved
|
|
278
|
+
* against `policy.cwd`; escapes and denied commands surface as typed
|
|
279
|
+
* {@link WorkspacePolicyError} / {@link WorkspaceEditError}.
|
|
280
|
+
*
|
|
281
|
+
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
282
|
+
* @param policy - The policy that bounds every operation.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
286
|
+
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
287
|
+
*/
|
|
288
|
+
function createOps(backend, policy) {
|
|
289
|
+
return new Ops(backend, policy);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
//#endregion
|
|
293
|
+
export { createOps };
|
|
294
|
+
//# sourceMappingURL=ops.mjs.map
|
package/esm/ops.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../@warlock.js/ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { hashString } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { buildEnv, isCommandAllowed, resolveInJail } from \"./policy/policy\";\nimport type {\n EditFileInput,\n EditFileResult,\n GrepMatch,\n GrepResult,\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/**\n * Number the lines of `content` `cat -n` style: a right-aligned line\n * number (min width 6), a tab, then the line. `startLine` is the 1-based\n * number of the first line in the window.\n */\nfunction numberLines(content: string, startLine: number): string {\n const lines = content.split(\"\\n\");\n\n return lines\n .map((line, index) => {\n const lineNumber = startLine + index;\n\n return `${String(lineNumber).padStart(6, \" \")}\\t${line}`;\n })\n .join(\"\\n\");\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).\n * Returns the possibly-clipped string plus whether clipping occurred.\n */\nfunction capOutput(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 // Slice on a byte boundary; `toString` tolerates a split multi-byte\n // char at the tail by emitting the replacement character, which is\n // acceptable for a truncated diagnostic stream.\n return { value: bytes.subarray(0, maxBytes).toString(\"utf8\"), truncated: true };\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 * 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 public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\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<{ content: string; hash: string; totalLines: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the content the backend returned (SHA-256 via @warlock.js/fs),\n // not the on-disk file — the mock backend keeps content in memory,\n // and hashing the returned bytes is what the stale-edit guard later\n // compares against, so it must be the SAME source of truth.\n const hash = hashString(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\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + limit);\n const content = numberLines(window.join(\"\\n\"), offset);\n\n return { content, hash, totalLines };\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 // 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: hashString(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n const current = await this.backend.readFile(absolutePath);\n const currentHash = hashString(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: hashString(updated),\n };\n }\n\n public async exec(\n command: string,\n opts?: { timeoutMs?: number },\n ): 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 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 });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\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,\n };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\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 if (regex.test(lines[index])) {\n matches.push({ path: relativePath, line: index + 1, text: lines[index] });\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.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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"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { WorkspacePolicy } from "../contracts/workspace-policy.type.mjs";
|
|
2
|
+
//#region ../@warlock.js/ai-workspace/src/policy/policy.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The outcome of resolving a workspace-relative (or absolute) input path
|
|
5
|
+
* against the jail — the canonical absolute location the backend should
|
|
6
|
+
* touch, plus the workspace-relative form (POSIX-style, `/`-separated)
|
|
7
|
+
* the agent and tool results echo back.
|
|
8
|
+
*/
|
|
9
|
+
interface ResolvedPath {
|
|
10
|
+
/** Canonical absolute path (symlinks in existing ancestors collapsed). */
|
|
11
|
+
absolutePath: string;
|
|
12
|
+
/**
|
|
13
|
+
* The path relative to `policy.cwd`, `/`-separated regardless of OS,
|
|
14
|
+
* used in tool results so the agent always sees stable workspace paths.
|
|
15
|
+
* Empty string when the resolved path IS the jail root.
|
|
16
|
+
*/
|
|
17
|
+
relativePath: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
21
|
+
*
|
|
22
|
+
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
23
|
+
* existing ancestors collapsed so a symlinked directory cannot escape
|
|
24
|
+
* the jail), then accepted **only** when it sits under `cwd` or one of
|
|
25
|
+
* the `allowPaths` roots. A path that escapes, or that matches any
|
|
26
|
+
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
27
|
+
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
28
|
+
*
|
|
29
|
+
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
30
|
+
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
31
|
+
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
32
|
+
* @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
36
|
+
*/
|
|
37
|
+
declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Promise<ResolvedPath>;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
40
|
+
*
|
|
41
|
+
* The command's leading executable basename is matched against
|
|
42
|
+
* `shell.deny` then `shell.allow`. **Deny always wins.** When
|
|
43
|
+
* `shell.allow` is set, the executable MUST appear in it (fail-closed
|
|
44
|
+
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
45
|
+
* permitted. An absent `shell` block means no command may run at all.
|
|
46
|
+
*
|
|
47
|
+
* Returns a plain `boolean` rather than throwing — the ops layer raises
|
|
48
|
+
* the {@link WorkspacePolicyError} so the thrown context (`command`)
|
|
49
|
+
* lives next to the call site.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
53
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
|
|
54
|
+
*/
|
|
55
|
+
declare function isCommandAllowed(policy: WorkspacePolicy, command: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Build the exact environment a spawned process receives — `process.env`
|
|
58
|
+
* is **never** inherited wholesale. The result is
|
|
59
|
+
* `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command
|
|
60
|
+
* cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the
|
|
61
|
+
* policy opts in via `shell.inheritEnv: ["PATH"]`. Explicit `shell.env`
|
|
62
|
+
* values override inherited ones on key collision.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* buildEnv({ cwd, shell: { inheritEnv: ["PATH"], env: { CI: "1" } } });
|
|
66
|
+
* // → { PATH: <process PATH>, CI: "1" }
|
|
67
|
+
*/
|
|
68
|
+
declare function buildEnv(policy: WorkspacePolicy): Record<string, string>;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { ResolvedPath, buildEnv, isCommandAllowed, resolveInJail };
|
|
71
|
+
//# sourceMappingURL=policy.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-workspace/src/policy/policy.ts"],"mappings":";;;;;AAWA;;;UAAiB,YAAA;EAQH;EANZ,YAAA;EAkJiC;;;;;EA5IjC,YAAY;AAAA;;;;;;;AA+IS;AA+EvB;;;;;;;;AAEiB;AAyCjB;;iBA7HsB,aAAA,CACpB,MAAA,EAAQ,eAAA,EACR,SAAA,WACC,OAAA,CAAQ,YAAA;;;;;;AA0H8C;;;;;;;;;;;;iBA3CzC,gBAAA,CACd,MAAA,EAAQ,eAAe,EACvB,OAAA;;;;;;;;;;;;;iBAyCc,QAAA,CAAS,MAAA,EAAQ,eAAA,GAAkB,MAAM"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { WorkspacePolicyError } from "../errors.mjs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region ../@warlock.js/ai-workspace/src/policy/policy.ts
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the canonical absolute form of `target`, collapsing any
|
|
8
|
+
* symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
|
|
9
|
+
* so we realpath the deepest **existing** ancestor and re-attach the
|
|
10
|
+
* non-existent tail — a symlinked ancestor still cannot smuggle the
|
|
11
|
+
* path out of the jail, while genuinely new leaves stay creatable.
|
|
12
|
+
*/
|
|
13
|
+
async function canonicalize(target) {
|
|
14
|
+
let resolvedTarget = path.resolve(target);
|
|
15
|
+
const tail = [];
|
|
16
|
+
while (true) try {
|
|
17
|
+
const real = await realpath(resolvedTarget);
|
|
18
|
+
return tail.length > 0 ? path.join(real, ...tail) : real;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code !== "ENOENT") throw error;
|
|
21
|
+
const parent = path.dirname(resolvedTarget);
|
|
22
|
+
if (parent === resolvedTarget) return path.join(resolvedTarget, ...tail);
|
|
23
|
+
tail.unshift(path.basename(resolvedTarget));
|
|
24
|
+
resolvedTarget = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether `child` is contained within `root` (or equals it), comparing
|
|
29
|
+
* canonical absolute paths. Guards against the `/srv/app-evil` vs
|
|
30
|
+
* `/srv/app` prefix-collision by anchoring on a path separator.
|
|
31
|
+
*/
|
|
32
|
+
function isInside(child, root) {
|
|
33
|
+
const relative = path.relative(root, child);
|
|
34
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
|
|
38
|
+
* `?`) into an anchored `RegExp` over a `/`-separated relative path.
|
|
39
|
+
* `**` spans path separators; a single `*` does not.
|
|
40
|
+
*/
|
|
41
|
+
function globToRegExp(glob) {
|
|
42
|
+
let source = "";
|
|
43
|
+
for (let index = 0; index < glob.length; index++) {
|
|
44
|
+
const char = glob[index];
|
|
45
|
+
if (char === "*") {
|
|
46
|
+
if (glob[index + 1] === "*") {
|
|
47
|
+
source += ".*";
|
|
48
|
+
index++;
|
|
49
|
+
if (glob[index + 1] === "/") index++;
|
|
50
|
+
} else source += "[^/]*";
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === "?") {
|
|
54
|
+
source += "[^/]";
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
}
|
|
59
|
+
return new RegExp(`^${source}$`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
63
|
+
* policy's `denyPaths` globs. A deny glob also blocks everything beneath
|
|
64
|
+
* a matched directory (`".git/**"` blocks `.git/config`).
|
|
65
|
+
*/
|
|
66
|
+
function matchesDeny(relativePath, denyPaths) {
|
|
67
|
+
return denyPaths.some((glob) => {
|
|
68
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
69
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
70
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
71
|
+
return relativePath.startsWith(prefix);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
78
|
+
*
|
|
79
|
+
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
80
|
+
* existing ancestors collapsed so a symlinked directory cannot escape
|
|
81
|
+
* the jail), then accepted **only** when it sits under `cwd` or one of
|
|
82
|
+
* the `allowPaths` roots. A path that escapes, or that matches any
|
|
83
|
+
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
84
|
+
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
85
|
+
*
|
|
86
|
+
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
87
|
+
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
88
|
+
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
89
|
+
* @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
93
|
+
*/
|
|
94
|
+
async function resolveInJail(policy, inputPath) {
|
|
95
|
+
const jailRoot = await canonicalize(policy.cwd);
|
|
96
|
+
const absolutePath = await canonicalize(path.isAbsolute(inputPath) ? inputPath : path.join(policy.cwd, inputPath));
|
|
97
|
+
const insideCwd = isInside(absolutePath, jailRoot);
|
|
98
|
+
const allowRoots = policy.allowPaths ?? [];
|
|
99
|
+
let insideAllow = false;
|
|
100
|
+
if (!insideCwd) {
|
|
101
|
+
for (const root of allowRoots) if (isInside(absolutePath, await canonicalize(root))) {
|
|
102
|
+
insideAllow = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
|
|
107
|
+
type: "path-escape",
|
|
108
|
+
path: inputPath
|
|
109
|
+
});
|
|
110
|
+
const relativeToJail = insideCwd ? path.relative(jailRoot, absolutePath).split(path.sep).join("/") : "";
|
|
111
|
+
if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
|
|
112
|
+
if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
|
|
113
|
+
type: "path-escape",
|
|
114
|
+
path: inputPath
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
absolutePath,
|
|
119
|
+
relativePath: relativeToJail
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Extract the leading executable basename from a command line — the
|
|
124
|
+
* token the shell allow/deny policy is keyed on. `"npm run build"` →
|
|
125
|
+
* `"npm"`; `"/usr/bin/node app.js"` → `"node"`; `"node.exe app"` →
|
|
126
|
+
* `"node"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).
|
|
127
|
+
*/
|
|
128
|
+
function leadingExecutable(command) {
|
|
129
|
+
const firstToken = command.trim().split(/\s+/)[0] ?? "";
|
|
130
|
+
return path.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
134
|
+
*
|
|
135
|
+
* The command's leading executable basename is matched against
|
|
136
|
+
* `shell.deny` then `shell.allow`. **Deny always wins.** When
|
|
137
|
+
* `shell.allow` is set, the executable MUST appear in it (fail-closed
|
|
138
|
+
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
139
|
+
* permitted. An absent `shell` block means no command may run at all.
|
|
140
|
+
*
|
|
141
|
+
* Returns a plain `boolean` rather than throwing — the ops layer raises
|
|
142
|
+
* the {@link WorkspacePolicyError} so the thrown context (`command`)
|
|
143
|
+
* lives next to the call site.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
147
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
|
|
148
|
+
*/
|
|
149
|
+
function isCommandAllowed(policy, command) {
|
|
150
|
+
const shell = policy.shell;
|
|
151
|
+
if (!shell) return false;
|
|
152
|
+
const executable = leadingExecutable(command);
|
|
153
|
+
if (executable === "") return false;
|
|
154
|
+
if (shell.deny && shell.deny.includes(executable)) return false;
|
|
155
|
+
if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build the exact environment a spawned process receives — `process.env`
|
|
160
|
+
* is **never** inherited wholesale. The result is
|
|
161
|
+
* `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command
|
|
162
|
+
* cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the
|
|
163
|
+
* policy opts in via `shell.inheritEnv: ["PATH"]`. Explicit `shell.env`
|
|
164
|
+
* values override inherited ones on key collision.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* buildEnv({ cwd, shell: { inheritEnv: ["PATH"], env: { CI: "1" } } });
|
|
168
|
+
* // → { PATH: <process PATH>, CI: "1" }
|
|
169
|
+
*/
|
|
170
|
+
function buildEnv(policy) {
|
|
171
|
+
const shell = policy.shell;
|
|
172
|
+
const env = {};
|
|
173
|
+
if (!shell) return env;
|
|
174
|
+
for (const key of shell.inheritEnv ?? []) {
|
|
175
|
+
const value = process.env[key];
|
|
176
|
+
if (value !== void 0) env[key] = value;
|
|
177
|
+
}
|
|
178
|
+
if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
|
|
179
|
+
return env;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
export { buildEnv, isCommandAllowed, resolveInJail };
|
|
184
|
+
//# sourceMappingURL=policy.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-workspace/src/policy/policy.ts"],"sourcesContent":["import path from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\nimport { WorkspacePolicyError } from \"../errors\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * The outcome of resolving a workspace-relative (or absolute) input path\n * against the jail — the canonical absolute location the backend should\n * touch, plus the workspace-relative form (POSIX-style, `/`-separated)\n * the agent and tool results echo back.\n */\nexport interface ResolvedPath {\n /** Canonical absolute path (symlinks in existing ancestors collapsed). */\n absolutePath: string;\n /**\n * The path relative to `policy.cwd`, `/`-separated regardless of OS,\n * used in tool results so the agent always sees stable workspace paths.\n * Empty string when the resolved path IS the jail root.\n */\n relativePath: string;\n}\n\n/**\n * Resolve the canonical absolute form of `target`, collapsing any\n * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),\n * so we realpath the deepest **existing** ancestor and re-attach the\n * non-existent tail — a symlinked ancestor still cannot smuggle the\n * path out of the jail, while genuinely new leaves stay creatable.\n */\nasync function canonicalize(target: string): Promise<string> {\n let resolvedTarget = path.resolve(target);\n const tail: string[] = [];\n\n // Walk up until an existing ancestor is found (or we hit the root).\n // eslint-disable-next-line no-constant-condition\n while (true) {\n try {\n const real = await realpath(resolvedTarget);\n\n return tail.length > 0 ? path.join(real, ...tail) : real;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n // Only ENOENT means \"this segment doesn't exist yet\" — keep\n // walking up. Any other error (EACCES, ELOOP, …) is a real fault.\n if (code !== \"ENOENT\") {\n throw error;\n }\n\n const parent = path.dirname(resolvedTarget);\n\n // Reached the filesystem root without finding an existing\n // ancestor — give back the lexically-resolved path unchanged.\n if (parent === resolvedTarget) {\n return path.join(resolvedTarget, ...tail);\n }\n\n tail.unshift(path.basename(resolvedTarget));\n resolvedTarget = parent;\n }\n }\n}\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * canonical absolute paths. Guards against the `/srv/app-evil` vs\n * `/srv/app` prefix-collision by anchoring on a path separator.\n */\nfunction isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return (\n relative === \"\" ||\n (!relative.startsWith(\"..\") && !path.isAbsolute(relative))\n );\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,\n * `?`) into an anchored `RegExp` over a `/`-separated relative path.\n * `**` spans path separators; a single `*` does not.\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 // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob[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 * policy's `denyPaths` globs. A deny glob also blocks everything beneath\n * a matched directory (`\".git/**\"` blocks `.git/config`).\n */\nfunction matchesDeny(relativePath: string, denyPaths: string[]): boolean {\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n // A bare directory glob (`\".git\"`, `\"node_modules\"`) should also\n // block its contents, mirroring how `\".git/**\"` would behave.\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 * Resolve and jail a single input path against a {@link WorkspacePolicy}.\n *\n * The input is resolved against `policy.cwd`, canonicalized (symlinks in\n * existing ancestors collapsed so a symlinked directory cannot escape\n * the jail), then accepted **only** when it sits under `cwd` or one of\n * the `allowPaths` roots. A path that escapes, or that matches any\n * `denyPaths` glob even while inside `cwd`, is rejected with a\n * {@link WorkspacePolicyError} of type `\"path-escape\"`.\n *\n * @param policy - The bounding policy (its `cwd` is the jail root).\n * @param inputPath - A workspace-relative or absolute path to resolve.\n * @returns The canonical absolute path plus its `/`-separated relative form.\n * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.\n *\n * @example\n * const { absolutePath } = await resolveInJail(policy, \"src/index.ts\");\n */\nexport async function resolveInJail(\n policy: WorkspacePolicy,\n inputPath: string,\n): Promise<ResolvedPath> {\n const jailRoot = await canonicalize(policy.cwd);\n const requested = path.isAbsolute(inputPath)\n ? inputPath\n : path.join(policy.cwd, inputPath);\n const absolutePath = await canonicalize(requested);\n\n const insideCwd = isInside(absolutePath, jailRoot);\n const allowRoots = policy.allowPaths ?? [];\n let insideAllow = false;\n\n if (!insideCwd) {\n for (const root of allowRoots) {\n const canonicalRoot = await canonicalize(root);\n\n if (isInside(absolutePath, canonicalRoot)) {\n insideAllow = true;\n\n break;\n }\n }\n }\n\n if (!insideCwd && !insideAllow) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" resolves outside the workspace jail.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n\n // `denyPaths` is evaluated relative to the jail root and wins even\n // when the path is comfortably inside `cwd`.\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, absolutePath).split(path.sep).join(\"/\")\n : \"\";\n\n if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {\n if (matchesDeny(relativeToJail, policy.denyPaths)) {\n throw new WorkspacePolicyError(\n `Path \"${inputPath}\" is blocked by the workspace deny list.`,\n { type: \"path-escape\", path: inputPath },\n );\n }\n }\n\n return { absolutePath, relativePath: relativeToJail };\n}\n\n/**\n * Extract the leading executable basename from a command line — the\n * token the shell allow/deny policy is keyed on. `\"npm run build\"` →\n * `\"npm\"`; `\"/usr/bin/node app.js\"` → `\"node\"`; `\"node.exe app\"` →\n * `\"node\"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).\n */\nfunction leadingExecutable(command: string): string {\n const trimmed = command.trim();\n const firstToken = trimmed.split(/\\s+/)[0] ?? \"\";\n const base = path.basename(firstToken);\n\n return base.replace(/\\.(exe|cmd|bat|com)$/i, \"\");\n}\n\n/**\n * Whether a shell command is permitted by the policy's `shell` sub-policy.\n *\n * The command's leading executable basename is matched against\n * `shell.deny` then `shell.allow`. **Deny always wins.** When\n * `shell.allow` is set, the executable MUST appear in it (fail-closed\n * allowlist); when `allow` is absent/empty, any non-denied command is\n * permitted. An absent `shell` block means no command may run at all.\n *\n * Returns a plain `boolean` rather than throwing — the ops layer raises\n * the {@link WorkspacePolicyError} so the thrown context (`command`)\n * lives next to the call site.\n *\n * @example\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"npm test\"); // true\n * isCommandAllowed({ cwd, shell: { allow: [\"npm\"] } }, \"rm -rf /\"); // false\n */\nexport function isCommandAllowed(\n policy: WorkspacePolicy,\n command: string,\n): boolean {\n const shell = policy.shell;\n\n // No shell sub-policy ⇒ fail-closed: nothing may run.\n if (!shell) {\n return false;\n }\n\n const executable = leadingExecutable(command);\n\n if (executable === \"\") {\n return false;\n }\n\n // Deny wins over everything else.\n if (shell.deny && shell.deny.includes(executable)) {\n return false;\n }\n\n // An allowlist, when present, is exhaustive.\n if (shell.allow && shell.allow.length > 0) {\n return shell.allow.includes(executable);\n }\n\n // No allowlist: anything not explicitly denied is permitted.\n return true;\n}\n\n/**\n * Build the exact environment a spawned process receives — `process.env`\n * is **never** inherited wholesale. The result is\n * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command\n * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the\n * policy opts in via `shell.inheritEnv: [\"PATH\"]`. Explicit `shell.env`\n * values override inherited ones on key collision.\n *\n * @example\n * buildEnv({ cwd, shell: { inheritEnv: [\"PATH\"], env: { CI: \"1\" } } });\n * // → { PATH: <process PATH>, CI: \"1\" }\n */\nexport function buildEnv(policy: WorkspacePolicy): Record<string, string> {\n const shell = policy.shell;\n const env: Record<string, string> = {};\n\n if (!shell) {\n return env;\n }\n\n for (const key of shell.inheritEnv ?? []) {\n const value = process.env[key];\n\n if (value !== undefined) {\n env[key] = value;\n }\n }\n\n if (shell.env) {\n for (const [key, value] of Object.entries(shell.env)) {\n env[key] = value;\n }\n }\n\n return env;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { EditFileInput, EditFileResult } from "../contracts/tool-io.type.mjs";
|
|
2
|
+
import { WorkspaceOps } from "../contracts/workspace-ops.contract.mjs";
|
|
3
|
+
import { ToolContract } from "@warlock.js/ai";
|
|
4
|
+
|
|
5
|
+
//#region ../@warlock.js/ai-workspace/src/tools/edit-file.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Build the agent-facing `edit_file` tool over a workspace's policy-
|
|
8
|
+
* enforced {@link WorkspaceOps}.
|
|
9
|
+
*
|
|
10
|
+
* The tool validates `{ path, oldString, newString, replaceAll?,
|
|
11
|
+
* expectHash? }` against a Standard Schema, then delegates to
|
|
12
|
+
* `ops.editFile`, which applies the exact-string replacement under the
|
|
13
|
+
* read-before-edit guard and returns the replacement count plus the
|
|
14
|
+
* post-edit `hash`.
|
|
15
|
+
*
|
|
16
|
+
* **Errors flow as data.** A non-unique `oldString` (without
|
|
17
|
+
* `replaceAll`), a missing `oldString`, or a stale `expectHash` cause
|
|
18
|
+
* `ops` to throw a `WorkspaceEditError`; the `tool()` wrapper catches it
|
|
19
|
+
* and surfaces it in the returned `{ error }` field — `invoke()` never
|
|
20
|
+
* throws — so the agent can re-read and retry.
|
|
21
|
+
*
|
|
22
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
23
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const editTool = makeEditFileTool(ops);
|
|
27
|
+
* const { data, error } = await editTool.invoke({
|
|
28
|
+
* path: "src/index.ts",
|
|
29
|
+
* oldString: "const a = 1;",
|
|
30
|
+
* newString: "const a = 2;",
|
|
31
|
+
* expectHash,
|
|
32
|
+
* });
|
|
33
|
+
* if (error) console.warn(error.message); // e.g. stale-hash → re-read
|
|
34
|
+
*/
|
|
35
|
+
declare function makeEditFileTool(ops: WorkspaceOps, options?: {
|
|
36
|
+
name?: string;
|
|
37
|
+
}): ToolContract<EditFileInput, EditFileResult>;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { makeEditFileTool };
|
|
40
|
+
//# sourceMappingURL=edit-file.d.mts.map
|