@jitsusama/agentic-harness.core 0.6.3 → 0.6.4

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.
@@ -6,8 +6,10 @@
6
6
  * does not trip the gate. This is a nudge toward the right stage,
7
7
  * not a security boundary.
8
8
  */
9
+ import { isAbsolute, join, resolve } from "node:path";
9
10
  import { tokenize } from "../../command/index.js";
10
11
  import { stripHeredocBodies, stripShellData, unquote, } from "../../shell/index.js";
12
+ import { createdBy, removedBy, unwrap } from "./bash-effects.js";
11
13
  /** Git subcommands that change repository or working-tree state. */
12
14
  const GIT_MUTATING = /\bgit(?:\s+(?:-c\s+\S+|-C\s+\S+|--git-dir=\S+|--work-tree=\S+|--no-pager))*\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|switch|restore|am|format-patch)\b/i;
13
15
  /** Shell patterns that write to the filesystem via redirection or in-place edit. */
@@ -69,6 +71,24 @@ export function bashWriteTargets(command) {
69
71
  return;
70
72
  targets.push(value);
71
73
  };
74
+ for (const target of patternTargets(skeleton))
75
+ add(target);
76
+ for (const simple of tokenize(command).commands) {
77
+ for (const target of commandTargets(simple))
78
+ add(target);
79
+ }
80
+ return [...new Set(targets)];
81
+ }
82
+ /**
83
+ * Write destinations found by pattern in a data-stripped skeleton, for
84
+ * the grammar the command model declines, such as a loop or a subshell.
85
+ */
86
+ function patternTargets(skeleton) {
87
+ const found = [];
88
+ const add = (token) => {
89
+ if (token)
90
+ found.push(token);
91
+ };
72
92
  // Redirect destinations: the token following > or >>. A leading
73
93
  // digit or & marks an fd redirect (2>, &>), which routes a stream
74
94
  // rather than naming a content target, so it is skipped.
@@ -96,55 +116,309 @@ export function bashWriteTargets(command) {
96
116
  add(token);
97
117
  }
98
118
  }
99
- for (const target of modelledTargets(command))
100
- add(target);
101
- return [...new Set(targets)];
119
+ return found;
102
120
  }
103
121
  /**
104
- * Write destinations read from the command model, where quoting is
105
- * already understood.
122
+ * Resolve where a bash command writes, in the directory each writing
123
+ * command actually runs in.
106
124
  *
107
- * Three shapes, the same three the patterns above look for: a redirect's
108
- * target, `tee`'s file arguments, and the file arguments of an in-place
109
- * editor. A file-descriptor redirect (`2>`, `&>`) is skipped, matching
110
- * what the pattern pass does, since it routes a stream rather than naming
111
- * a content target.
125
+ * The session directory is only where the command starts: `cd <tree> &&
126
+ * cat >> x_test.go` writes inside the tree, and reading it against the
127
+ * session directory is how a write in a tracked tree came to be judged as
128
+ * one in the repository the session happened to open. So the command
129
+ * model is walked in order, each `cd` moving the directory for what
130
+ * follows, with the command's own variables filled in.
112
131
  *
113
- * An editor's script is reported alongside its file, because telling one
114
- * from the other means knowing whether this `sed` is the BSD or the GNU
115
- * one. A script cannot name tracked code, so including it costs nothing,
116
- * whereas missing the file was the whole defect.
132
+ * A target whose location the command does not say is reported as
133
+ * unresolved rather than guessed: a `cd` into a command substitution, a
134
+ * variable from outside the command, or a relative target in a subshell
135
+ * or loop that changes directory. A guess judges a file nobody wrote,
136
+ * while an unresolved target can still be checked on disk afterwards.
137
+ *
138
+ * What a command removes is placed the same way and kept apart, since
139
+ * removing a document is a different question from writing one. It is
140
+ * only read where the command model applies; a removal inside a loop or
141
+ * a subshell is left to the check on disk.
117
142
  */
118
- function modelledTargets(command) {
143
+ export function resolveBashWrites(command, place) {
144
+ const skeleton = stripShellData(stripHeredocBodies(command));
145
+ const known = assignmentsIn(skeleton, command);
146
+ const paths = [];
147
+ const removed = [];
148
+ const unresolved = [];
149
+ const locate = (raw, dir, into = paths) => {
150
+ const bare = raw.replace(/^['"]/, "").replace(/['"]$/, "");
151
+ if (!bare)
152
+ return;
153
+ const value = expand(bare, known);
154
+ const absolute = value === undefined ? undefined : absoluteIn(value, dir, place.home);
155
+ if (absolute === undefined)
156
+ unresolved.push(value ?? bare);
157
+ else
158
+ into.push(absolute);
159
+ };
119
160
  const line = tokenize(command);
120
- const found = [];
121
- for (const simple of line.commands) {
122
- for (const redirect of simple.redirects) {
123
- if (!redirect.target)
161
+ if (line.supported) {
162
+ let dir = place.cwd;
163
+ for (const simple of line.commands) {
164
+ const argv = argvOf(simple);
165
+ if (argv[0] === "cd") {
166
+ dir = changeDirectory(dir, argv[1], known, place.home);
124
167
  continue;
125
- if (/^[0-9&]/.test(redirect.operator))
126
- continue;
127
- found.push(unquote(redirect.target.text));
168
+ }
169
+ for (const target of commandTargets(simple))
170
+ locate(target, dir);
171
+ for (const target of removedBy(unwrap(argv))) {
172
+ locate(target, dir, removed);
173
+ }
128
174
  }
129
- const argv = simple.argv.map((word) => unquote(word.text));
130
- const name = argv[0];
131
- if (!name)
132
- continue;
133
- const rest = argv.slice(1).filter((token) => !token.startsWith("-"));
134
- if (name === "tee")
135
- found.push(...rest);
136
- if (/^(g?sed|perl)$/.test(name) && editsInPlace(simple.argv)) {
137
- found.push(...rest);
175
+ }
176
+ else {
177
+ // Outside the grammar there is no order to follow, so a relative
178
+ // target is only placed when nothing in the command changes directory.
179
+ const moves = /(?:^|[\s;&|(])cd(?:\s|$)/.test(skeleton);
180
+ for (const target of patternTargets(skeleton)) {
181
+ locate(target, moves ? undefined : place.cwd);
138
182
  }
139
183
  }
184
+ return {
185
+ paths: [...new Set(paths)],
186
+ removed: [...new Set(removed)],
187
+ unresolved: [...new Set(unresolved)],
188
+ };
189
+ }
190
+ /**
191
+ * The directory after `cd <target>`, or undefined once it is no longer
192
+ * knowable: `cd -`, or a target the command's own variables cannot fill.
193
+ */
194
+ function changeDirectory(dir, target, known, home) {
195
+ if (target === undefined)
196
+ return home;
197
+ if (target === "-")
198
+ return undefined;
199
+ const value = expand(target, known);
200
+ return value === undefined ? undefined : absoluteIn(value, dir, home);
201
+ }
202
+ /** A path made absolute, or undefined when it is relative to nothing known. */
203
+ function absoluteIn(path, dir, home) {
204
+ if (path === "~")
205
+ return home;
206
+ if (path.startsWith("~/"))
207
+ return join(home, path.slice(2));
208
+ if (isAbsolute(path))
209
+ return resolve(path);
210
+ return dir === undefined ? undefined : resolve(dir, path);
211
+ }
212
+ /**
213
+ * One command's write destinations, read from the command model, where
214
+ * quoting is already understood.
215
+ *
216
+ * The three shapes `patternTargets` also looks for, a redirect's target,
217
+ * `tee`'s file arguments and the file arguments of an in-place editor,
218
+ * plus what a command creates without a redirect (see `createdBy`), read
219
+ * through wrappers such as `nohup` and `env`. A file-descriptor redirect
220
+ * (`2>`, `&>`) is skipped, matching what the pattern pass does, since it
221
+ * routes a stream rather than naming a content target.
222
+ *
223
+ * An editor's script is told apart from its files by reading its options
224
+ * the way the editor does, since a script reported as a file is a path
225
+ * nobody wrote, and one that happens to look like a quest document gets
226
+ * a write refused that never touched it.
227
+ */
228
+ function commandTargets(simple) {
229
+ const found = [];
230
+ for (const redirect of simple.redirects) {
231
+ if (!redirect.target)
232
+ continue;
233
+ if (/^[0-9&]/.test(redirect.operator))
234
+ continue;
235
+ found.push(unquote(redirect.target.text));
236
+ }
237
+ const argv = unwrap(argvOf(simple));
238
+ const name = argv[0];
239
+ if (!name)
240
+ return found;
241
+ found.push(...createdBy(argv));
242
+ if (name === "tee") {
243
+ found.push(...argv.slice(1).filter((token) => !token.startsWith("-")));
244
+ }
245
+ if (/^g?sed$/.test(name))
246
+ found.push(...sedFiles(argv.slice(1)));
247
+ if (name === "perl")
248
+ found.push(...perlFiles(argv.slice(1)));
140
249
  return found;
141
250
  }
142
- /** Whether an editor invocation carries the in-place flag. */
143
- function editsInPlace(argv) {
144
- return argv.some((word) => {
145
- const text = unquote(word.text);
146
- return text === "-i" || text.startsWith("-i");
147
- });
251
+ /** Short sed options that take the next word as their value. */
252
+ const SED_VALUED = new Set(["e", "f", "l"]);
253
+ /**
254
+ * The files a sed invocation edits in place, or none when it does not.
255
+ *
256
+ * Both spellings of the in-place flag are read. GNU attaches its optional
257
+ * suffix (`-i.bak`); BSD always takes one as the next word, and that word
258
+ * is only read as a suffix when it looks like one (empty, or starting with
259
+ * a dot), since `sed -i 's/a/b/' f` is the GNU spelling written on a Mac.
260
+ * Without `-e` or `-f`, the first operand is the script.
261
+ */
262
+ function sedFiles(args) {
263
+ const operands = [];
264
+ let inPlace = false;
265
+ let scripted = false;
266
+ for (let at = 0; at < args.length; at++) {
267
+ const arg = args[at] ?? "";
268
+ if (arg === "--") {
269
+ operands.push(...args.slice(at + 1));
270
+ break;
271
+ }
272
+ if (arg.startsWith("--")) {
273
+ const [option] = arg.split("=", 1);
274
+ if (option === "--in-place")
275
+ inPlace = true;
276
+ if (option === "--expression" || option === "--file") {
277
+ scripted = true;
278
+ if (!arg.includes("="))
279
+ at++;
280
+ }
281
+ continue;
282
+ }
283
+ if (!arg.startsWith("-") || arg === "-") {
284
+ operands.push(arg);
285
+ continue;
286
+ }
287
+ for (let char = 1; char < arg.length; char++) {
288
+ const flag = arg[char] ?? "";
289
+ if (flag === "i") {
290
+ inPlace = true;
291
+ const next = args[at + 1];
292
+ const bare = char === arg.length - 1;
293
+ if (bare && next !== undefined && /^(\.|$)/.test(next))
294
+ at++;
295
+ break;
296
+ }
297
+ if (SED_VALUED.has(flag)) {
298
+ if (flag !== "l")
299
+ scripted = true;
300
+ if (char === arg.length - 1)
301
+ at++;
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ if (!inPlace)
307
+ return [];
308
+ return scripted ? operands : operands.slice(1);
309
+ }
310
+ /** Perl switches whose value is the rest of the word, or the next word. */
311
+ const PERL_VALUED = new Set(["e", "E", "I", "M", "m", "x"]);
312
+ /** Perl switches whose optional value can only be attached. */
313
+ const PERL_ATTACHED = new Set(["i", "l", "0", "C", "d", "D", "F"]);
314
+ /**
315
+ * The files a perl invocation edits in place, or none when it does not.
316
+ *
317
+ * Switches cluster (`-pi`, `-ne`), so each word is read one switch at a
318
+ * time. Without `-e` or `-E`, the first operand is the program file.
319
+ */
320
+ function perlFiles(args) {
321
+ const operands = [];
322
+ let inPlace = false;
323
+ let inline = false;
324
+ for (let at = 0; at < args.length; at++) {
325
+ const arg = args[at] ?? "";
326
+ if (arg === "--") {
327
+ operands.push(...args.slice(at + 1));
328
+ break;
329
+ }
330
+ if (!arg.startsWith("-") || arg === "-") {
331
+ operands.push(...args.slice(at));
332
+ break;
333
+ }
334
+ for (let char = 1; char < arg.length; char++) {
335
+ const flag = arg[char] ?? "";
336
+ if (flag === "i")
337
+ inPlace = true;
338
+ if (PERL_VALUED.has(flag)) {
339
+ if (flag === "e" || flag === "E")
340
+ inline = true;
341
+ if (char === arg.length - 1)
342
+ at++;
343
+ break;
344
+ }
345
+ if (PERL_ATTACHED.has(flag))
346
+ break;
347
+ }
348
+ }
349
+ if (!inPlace)
350
+ return [];
351
+ return inline ? operands : operands.slice(1);
352
+ }
353
+ /** A command's arguments as the command receives them: braces expanded, quotes removed. */
354
+ function argvOf(simple) {
355
+ return simple.argv.flatMap((word) => expandBraces(word.text).map(unquote));
356
+ }
357
+ /**
358
+ * A word with its brace lists expanded, the way bash does before a
359
+ * command sees its arguments: `lab/{a,b}` is `lab/a lab/b`.
360
+ *
361
+ * Only an unquoted list with a comma at its top level expands, so
362
+ * `'{a,b}'`, `{solo}` and a variable's `${NAME}` stay as written. A
363
+ * sequence such as `{1..3}` is left alone too; nothing that writes into a
364
+ * quest folder has been seen to use one.
365
+ */
366
+ function expandBraces(word) {
367
+ for (let open = 0; open < word.length; open = skipQuoted(word, open) + 1) {
368
+ if (word[open] !== "{" || word[open - 1] === "$")
369
+ continue;
370
+ const list = braceList(word, open);
371
+ if (!list)
372
+ continue;
373
+ const prefix = word.slice(0, open);
374
+ const suffix = word.slice(list.close + 1);
375
+ return list.parts.flatMap((part) => expandBraces(prefix + part + suffix));
376
+ }
377
+ return [word];
378
+ }
379
+ /**
380
+ * The alternatives of the brace list opening at `open`, and where it
381
+ * closes, or undefined when the braces hold no top-level comma or never
382
+ * close.
383
+ */
384
+ function braceList(word, open) {
385
+ const parts = [];
386
+ let depth = 0;
387
+ let start = open + 1;
388
+ for (let at = open; at < word.length; at = skipQuoted(word, at) + 1) {
389
+ const char = word[at];
390
+ if (char === "{")
391
+ depth++;
392
+ else if (char === "}" && --depth === 0) {
393
+ if (parts.length === 0)
394
+ return undefined;
395
+ parts.push(word.slice(start, at));
396
+ return { parts, close: at };
397
+ }
398
+ else if (char === "," && depth === 1) {
399
+ parts.push(word.slice(start, at));
400
+ start = at + 1;
401
+ }
402
+ }
403
+ return undefined;
404
+ }
405
+ /**
406
+ * The last index of the quoted span or escape starting at `at`, or `at`
407
+ * itself when nothing starts there, so a scan can step past quoted text.
408
+ */
409
+ function skipQuoted(word, at) {
410
+ const char = word[at];
411
+ if (char === "\\")
412
+ return at + 1;
413
+ if (char !== "'" && char !== '"')
414
+ return at;
415
+ let end = at + 1;
416
+ while (end < word.length && word[end] !== char) {
417
+ if (char === '"' && word[end] === "\\")
418
+ end++;
419
+ end++;
420
+ }
421
+ return end;
148
422
  }
149
423
  /**
150
424
  * The variables a command assigns to itself, last assignment winning.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The record's rules, read from disk rather than from a single write.
3
+ *
4
+ * The gate judges a write by its path before it lands, which cannot see
5
+ * what a command made without saying so, how big a file turned out, or
6
+ * whether a document cites it. The audit reads the whole quest folder
7
+ * afterwards and reports what the record holds that it should not: the
8
+ * check after each write, conclude's refusal and the migration all ask
9
+ * it the same question.
10
+ */
11
+ import { type Stats } from "node:fs";
12
+ import { type AttachmentFile, type AttachmentProblem } from "./record.js";
13
+ /** What a quest folder holds that its record should not. */
14
+ export interface RecordAudit {
15
+ /** Paths outside the record, each by its outermost folder or file. */
16
+ readonly strays: string[];
17
+ /** Attachments that break the limits, and how. */
18
+ readonly attachments: {
19
+ rel: string;
20
+ problem: AttachmentProblem;
21
+ }[];
22
+ /** Links into `attachments/` that name no attachment. */
23
+ readonly broken: {
24
+ document: string;
25
+ target: string;
26
+ }[];
27
+ /** Attachments no document cites, which the backup leaves out. */
28
+ readonly uncited: string[];
29
+ }
30
+ /** Read a quest folder and report what its record should not hold. */
31
+ export declare function auditQuestRecord(questDir: string): RecordAudit;
32
+ /**
33
+ * Whether a record may conclude as it is. Uncited attachments do not
34
+ * stop it: the backup leaves them out, which is their only cost.
35
+ */
36
+ export declare function isRecordSound(audit: RecordAudit): boolean;
37
+ /**
38
+ * What the attachment rules need to know about one path on disk, named
39
+ * by `rel` as it would sit in the record. The migration asks this of a
40
+ * file before it moves, so it is judged the same way the audit would
41
+ * judge it once it lands.
42
+ */
43
+ export declare function attachmentFileAt(path: string, rel: string, stats?: Stats): AttachmentFile;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The record's rules, read from disk rather than from a single write.
3
+ *
4
+ * The gate judges a write by its path before it lands, which cannot see
5
+ * what a command made without saying so, how big a file turned out, or
6
+ * whether a document cites it. The audit reads the whole quest folder
7
+ * afterwards and reports what the record holds that it should not: the
8
+ * check after each write, conclude's refusal and the migration all ask
9
+ * it the same question.
10
+ */
11
+ import { closeSync, lstatSync, openSync, readdirSync, readFileSync, readSync, } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { ATTACHMENT_LIMITS, attachmentCitations, attachmentProblem, questRecordPath, } from "./record.js";
14
+ /**
15
+ * Files the quest machinery and the system keep in a quest folder: the
16
+ * quest lock, the atomic writer's half-written files and Finder's notes.
17
+ */
18
+ function isMachinery(name) {
19
+ return (name === ".quest.lock" ||
20
+ name === ".DS_Store" ||
21
+ /^\..+\.tmp-\d+-/.test(name));
22
+ }
23
+ /** Read a quest folder and report what its record should not hold. */
24
+ export function auditQuestRecord(questDir) {
25
+ const questsRoot = join(questDir, "..");
26
+ const quest = basename(questDir);
27
+ const strays = [];
28
+ const problems = [];
29
+ const documents = [];
30
+ const attachments = [];
31
+ const walk = (dir) => {
32
+ for (const name of readdirSync(dir).sort()) {
33
+ if (isMachinery(name))
34
+ continue;
35
+ const path = join(dir, name);
36
+ const place = questRecordPath(questsRoot, path);
37
+ if (!place)
38
+ continue;
39
+ const stats = lstatSync(path);
40
+ switch (place.place) {
41
+ case "stray":
42
+ strays.push(place.rel);
43
+ break;
44
+ case "readme":
45
+ case "document":
46
+ if (stats.isFile()) {
47
+ documents.push({
48
+ rel: place.rel,
49
+ text: readFileSync(path, "utf8"),
50
+ });
51
+ }
52
+ else
53
+ strays.push(place.rel);
54
+ break;
55
+ case "folder":
56
+ if (stats.isDirectory())
57
+ walk(path);
58
+ else
59
+ strays.push(place.rel);
60
+ break;
61
+ case "attachment": {
62
+ const problem = attachmentProblem(attachmentFileAt(path, place.rel, stats));
63
+ if (problem)
64
+ problems.push({ rel: place.rel, problem });
65
+ else if (stats.isDirectory())
66
+ walk(path);
67
+ else
68
+ attachments.push(place.rel);
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ };
74
+ walk(questDir);
75
+ const { cited, broken } = attachmentCitations(quest, documents, attachments);
76
+ return {
77
+ strays: strays.sort(),
78
+ attachments: problems.sort((a, b) => compare(a.rel, b.rel)),
79
+ broken,
80
+ uncited: attachments.filter((rel) => !cited.has(rel)).sort(),
81
+ };
82
+ }
83
+ /**
84
+ * Whether a record may conclude as it is. Uncited attachments do not
85
+ * stop it: the backup leaves them out, which is their only cost.
86
+ */
87
+ export function isRecordSound(audit) {
88
+ return (audit.strays.length === 0 &&
89
+ audit.attachments.length === 0 &&
90
+ audit.broken.length === 0);
91
+ }
92
+ /**
93
+ * What the attachment rules need to know about one path on disk, named
94
+ * by `rel` as it would sit in the record. The migration asks this of a
95
+ * file before it moves, so it is judged the same way the audit would
96
+ * judge it once it lands.
97
+ */
98
+ export function attachmentFileAt(path, rel, stats = lstatSync(path)) {
99
+ const kind = stats.isSymbolicLink()
100
+ ? "symlink"
101
+ : stats.isDirectory()
102
+ ? "directory"
103
+ : stats.isFile()
104
+ ? "file"
105
+ : "other";
106
+ return {
107
+ rel,
108
+ kind,
109
+ size: stats.size,
110
+ head: kind === "file" ? headOf(path) : new Uint8Array(),
111
+ };
112
+ }
113
+ /** A file's first bytes, as many as the text sniff reads. */
114
+ function headOf(path) {
115
+ const buffer = new Uint8Array(ATTACHMENT_LIMITS.sniffBytes);
116
+ const fd = openSync(path, "r");
117
+ try {
118
+ return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
119
+ }
120
+ finally {
121
+ closeSync(fd);
122
+ }
123
+ }
124
+ function compare(a, b) {
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The record's rules for a single write, applied before it lands.
3
+ *
4
+ * A write is refused only when the rule it breaks is certain from the
5
+ * path alone, and every refusal says where the write belongs instead, so
6
+ * the refusal is a redirection rather than a dead end. What a path cannot
7
+ * decide (an attachment's size, whether it is text, whether a document
8
+ * cites it) is left to the check on disk after the write.
9
+ *
10
+ * The rules hold in every quest's folder, not only the loaded quest's,
11
+ * since a write into another quest's record breaks that record just the
12
+ * same.
13
+ */
14
+ /** One write or removal, already resolved to an absolute path. */
15
+ export interface RecordWrite {
16
+ readonly path: string;
17
+ readonly effect: "write" | "remove";
18
+ /** A bash command, or the write and edit tools. */
19
+ readonly via: "bash" | "tool";
20
+ /** Whether something is already at the path. */
21
+ readonly exists: boolean;
22
+ }
23
+ /** Where the record and the workspaces live. */
24
+ export interface RecordRoots {
25
+ readonly questsRoot: string;
26
+ readonly workspaceRoot: string;
27
+ }
28
+ /** A write the record refuses, why, and where it belongs instead. */
29
+ export interface RecordRefusal {
30
+ readonly rule: "document-by-hand" | "document-by-bash" | "document-removed" | "stray" | "attachment";
31
+ readonly reason: string;
32
+ /** The path the write belongs at, when it belongs somewhere else. */
33
+ readonly instead?: string;
34
+ }
35
+ /** Judge one write or removal against the record's rules. */
36
+ export declare function judgeRecordWrite(write: RecordWrite, roots: RecordRoots): RecordRefusal | undefined;