@visual-z/xeno 0.5.0 → 0.6.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -3
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +84 -0
  22. package/src/apps/shell.ts +54 -0
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +21 -22
  25. package/src/apps/ui/surface.ts +1 -0
  26. package/src/apps/update-check.ts +71 -0
  27. package/src/apps/workflow.ts +58 -0
  28. package/src/core/agent-profile.ts +130 -0
  29. package/src/core/control-protocol.ts +8 -1
  30. package/src/core/file-precondition.ts +6 -0
  31. package/src/core/lsp-framing.ts +34 -0
  32. package/src/core/mcp-config.ts +68 -0
  33. package/src/core/patch.ts +130 -0
  34. package/src/core/permissions.ts +143 -4
  35. package/src/core/question.ts +94 -0
  36. package/src/core/review.ts +68 -0
  37. package/src/core/update-check.ts +24 -5
  38. package/src/core/web-content.ts +80 -0
  39. package/src/core/workflow-mode.ts +34 -0
  40. package/src/runtime/acp-agent.ts +289 -0
  41. package/src/runtime/agent-profiles.ts +21 -0
  42. package/src/runtime/catalog.ts +2 -2
  43. package/src/runtime/control-client.ts +149 -0
  44. package/src/runtime/control-connection.ts +39 -4
  45. package/src/runtime/extensions.ts +20 -2
  46. package/src/runtime/formatter-tool.ts +38 -0
  47. package/src/runtime/formatter.ts +50 -0
  48. package/src/runtime/foundation.ts +78 -0
  49. package/src/runtime/integrations.ts +8 -2
  50. package/src/runtime/loop.ts +68 -10
  51. package/src/runtime/lsp-client.ts +201 -0
  52. package/src/runtime/mcp-client.ts +264 -0
  53. package/src/runtime/mcp-oauth.ts +168 -0
  54. package/src/runtime/mcp-schema.ts +51 -0
  55. package/src/runtime/oauth.ts +1 -1
  56. package/src/runtime/output-schema.ts +26 -0
  57. package/src/runtime/patch-tool.ts +38 -0
  58. package/src/runtime/permission-resources.ts +11 -0
  59. package/src/runtime/provider.ts +2 -0
  60. package/src/runtime/question-tool.ts +62 -0
  61. package/src/runtime/review-tool.ts +48 -0
  62. package/src/runtime/session-control.ts +60 -2
  63. package/src/runtime/session-hub.ts +85 -0
  64. package/src/runtime/tool-discovery.ts +4 -3
  65. package/src/runtime/tools.ts +37 -7
  66. package/src/runtime/update-check.ts +3 -2
  67. package/src/runtime/web-http.ts +56 -0
  68. package/src/runtime/web-tools.ts +88 -0
  69. package/src/runtime/workspace-lifecycle.ts +14 -1
  70. package/src/store/agent-profile-files.ts +13 -0
  71. package/src/store/context-files.ts +77 -1
  72. package/src/store/file-checkpoints.ts +5 -4
  73. package/src/store/foundation-config.ts +24 -0
  74. package/src/store/lsp-document.ts +30 -0
  75. package/src/store/mcp-credentials.ts +85 -0
  76. package/src/store/patch.ts +92 -0
  77. package/src/store/permission-paths.ts +34 -0
  78. package/src/store/review.ts +109 -0
  79. package/src/store/search.ts +48 -9
@@ -0,0 +1,85 @@
1
+ /** Credential storage is separate from append-only observations: secrets must never become log evidence. */
2
+ import { mkdir, open, rename, rm, lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { constants } from "node:fs";
6
+ import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
7
+
8
+ export interface McpCredentials {
9
+ version: 1; serverUrl: string; redirectUrl: string;
10
+ clientIdentity?: string;
11
+ clients: Record<string, JsonValue>; tokens: Record<string, JsonValue>; expiresAt: Record<string, number>; activeIssuer: string | null;
12
+ }
13
+ export interface McpCredentialsStore {
14
+ read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined>;
15
+ write(credentials: McpCredentials): Promise<void>;
16
+ remove(serverUrl: string, clientIdentity?: string): Promise<void>;
17
+ }
18
+ // Assumed bound prevents a replaced credential file from becoming an unbounded JSON allocation.
19
+ const MAX_CREDENTIAL_BYTES = 1024 * 1024;
20
+ function filename(dir: string, url: string, clientIdentity = "") { return join(dir, createHash("sha256").update(JSON.stringify([url, clientIdentity])).digest("hex") + ".json"); }
21
+ function record(value: unknown): value is Record<string, JsonValue> {
22
+ return value !== null && typeof value === "object" && !Array.isArray(value) && isJsonValue(value);
23
+ }
24
+ function validate(value: unknown, serverUrl: string, clientIdentity = ""): McpCredentials {
25
+ if (!record(value) || value["version"] !== 1 || value["serverUrl"] !== serverUrl || typeof value["redirectUrl"] !== "string"
26
+ || (value["clientIdentity"] ?? "") !== clientIdentity
27
+ || !record(value["clients"]) || !record(value["tokens"]) || !record(value["expiresAt"])
28
+ || Object.values(value["expiresAt"]).some(time => typeof time !== "number" || !Number.isFinite(time))
29
+ || value["activeIssuer"] !== null && typeof value["activeIssuer"] !== "string") throw Error("MCP credential record is invalid");
30
+ return { version: 1, serverUrl, redirectUrl: value["redirectUrl"], clients: value["clients"], tokens: value["tokens"],
31
+ expiresAt: Object.fromEntries(Object.entries(value["expiresAt"]).map(([issuer, time]) => [issuer, Number(time)])), activeIssuer: value["activeIssuer"],
32
+ ...(typeof value["clientIdentity"] === "string" ? { clientIdentity: value["clientIdentity"] } : {}) };
33
+ }
34
+ /** Explicit restricted-file backend, compatible with Codex's fallback; applications may provide an OS vault adapter. */
35
+ export class FileMcpCredentialsStore implements McpCredentialsStore {
36
+ private readonly dir: string;
37
+ private writes = Promise.resolve();
38
+ constructor(dir: string) { this.dir = dir; }
39
+ private async directory(): Promise<void> {
40
+ const stat = await lstat(this.dir);
41
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw Error("MCP credential directory permissions are invalid");
42
+ }
43
+ async read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined> {
44
+ const path = filename(this.dir, serverUrl, clientIdentity);
45
+ try {
46
+ await this.directory();
47
+ const stat = await lstat(path);
48
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > MAX_CREDENTIAL_BYTES) throw Error("MCP credential file permissions or size are invalid");
49
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
50
+ try {
51
+ const opened = await file.stat();
52
+ if (!opened.isFile() || opened.size > MAX_CREDENTIAL_BYTES || (opened.mode & 0o077) !== 0) throw Error("Unsafe credential handle");
53
+ return validate(JSON.parse(await file.readFile("utf8")), serverUrl, clientIdentity);
54
+ } finally { await file.close(); }
55
+ } catch (error) {
56
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
57
+ throw Error("MCP credential store could not be read safely");
58
+ }
59
+ }
60
+ write(credentials: McpCredentials): Promise<void> {
61
+ const serialized = JSON.stringify(validate(credentials, credentials.serverUrl, credentials.clientIdentity));
62
+ const target = filename(this.dir, credentials.serverUrl, credentials.clientIdentity);
63
+ if (Buffer.byteLength(serialized) > MAX_CREDENTIAL_BYTES) return Promise.reject(Error("MCP credential record exceeds size limit"));
64
+ const operation = this.writes.then(async () => {
65
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
66
+ await this.directory();
67
+ const temporary = join(this.dir, `.${randomUUID()}.tmp`);
68
+ try {
69
+ const file = await open(temporary, "wx", 0o600);
70
+ try { await file.writeFile(serialized); await file.sync(); } finally { await file.close(); }
71
+ await rename(temporary, target);
72
+ } finally { await rm(temporary, { force: true }); }
73
+ });
74
+ this.writes = operation.catch(() => {});
75
+ return operation;
76
+ }
77
+ async remove(serverUrl: string, clientIdentity?: string): Promise<void> {
78
+ await this.writes;
79
+ try { await this.directory(); await rm(filename(this.dir, serverUrl, clientIdentity), { force: true }); }
80
+ catch (error) {
81
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
82
+ throw Error("MCP credential store could not be removed safely");
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,92 @@
1
+ /** R4 / P.3: one fully prepared plan shares the existing confined checkpoint CAS. */
2
+ import { relative, resolve } from "node:path";
3
+ import { parsePatch, applyPatchChunks, patchPaths, PatchError, type PatchOperation } from "../core/patch.ts";
4
+ import { sameVersion, changedPaths, type FileChanges } from "../core/checkpoints.ts";
5
+ import { unifiedFileDiff } from "../core/review.ts";
6
+ import { isFileDigest } from "../core/file-precondition.ts";
7
+ import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
8
+ import { editablePath, readEditableFile, applyFileChanges } from "./file-checkpoints.ts";
9
+ import { putBlob } from "./blobs.ts";
10
+
11
+ export interface PreparedPatch {
12
+ root: string;
13
+ changes: FileChanges;
14
+ operations: readonly PatchOperation[];
15
+ diffs: readonly { path: string; destination?: string; kind: "add" | "update" | "move" | "delete"; diff: string }[];
16
+ }
17
+ export interface PreparePatchOptions {
18
+ observedFileVersion?: (absolute: string) => Promise<string | undefined>;
19
+ expectedSha256?: Readonly<Record<string, string>>;
20
+ signal?: AbortSignal;
21
+ }
22
+ const decode = (bytes: Uint8Array) => {
23
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
24
+ catch { throw new ToolFault("invalid_input", "patch requires valid UTF-8 files; use a trusted command for binary data", READ_ATTEMPTED); }
25
+ };
26
+
27
+ /** No workspace write occurs until every source, destination and hunk passes. */
28
+ export async function preparePatch(root: string, blobs: string, text: string, options: PreparePatchOptions = {}): Promise<PreparedPatch> {
29
+ let operations: PatchOperation[];
30
+ try { operations = parsePatch(text); }
31
+ catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, error.message, NOT_STARTED); throw error; }
32
+ const names = new Map<string, string>();
33
+ const targets = new Set<string>();
34
+ for (const path of patchPaths(operations)) {
35
+ const absolute = await editablePath(root, path), canonical = relative(resolve(root), absolute);
36
+ if (targets.has(canonical) || [...targets].some(existing => existing.startsWith(canonical + "/") || canonical.startsWith(existing + "/")))
37
+ throw new ToolFault("invalid_input", `patch has duplicate or overlapping targets: ${path}`, NOT_STARTED);
38
+ names.set(path, canonical); targets.add(canonical);
39
+ }
40
+ const canonical = (path: string) => names.get(path)!;
41
+ const observed = new Map<string, Awaited<ReturnType<typeof readEditableFile>>>();
42
+ for (const path of targets) {
43
+ if (options.signal?.aborted) throw new ToolFault("cancelled", "patch preparation cancelled", NOT_STARTED);
44
+ observed.set(path, await readEditableFile(root, path));
45
+ }
46
+ const changes: FileChanges = Object.create(null) as FileChanges;
47
+ const diffs: PreparedPatch["diffs"][number][] = [];
48
+ for (const operation of operations) {
49
+ const path = canonical(operation.path), source = observed.get(path)!;
50
+ if (operation.kind !== "add") {
51
+ if (source.bytes === null || source.version === null) throw new ToolFault("not_found", `patch source is missing: ${path}`, READ_ATTEMPTED);
52
+ const explicit = options.expectedSha256?.[operation.path] ?? options.expectedSha256?.[path];
53
+ const expected = explicit ?? await options.observedFileVersion?.(resolve(root, path));
54
+ if (!isFileDigest(expected) || expected !== source.version.sha256)
55
+ throw new ToolFault("version_mismatch", `patch source version was not observed or changed: ${path}; read it before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
56
+ } else if (source.version !== null) {
57
+ throw new ToolFault("version_mismatch", `patch add requires an absent destination: ${path}; existing bytes were preserved`, READ_ATTEMPTED);
58
+ }
59
+ let destination = path, content: string | null;
60
+ const before = source.bytes === null ? null : decode(source.bytes);
61
+ if (operation.kind === "delete") content = null;
62
+ else if (operation.kind === "add") content = operation.content;
63
+ else {
64
+ try { content = applyPatchChunks(before!, operation.chunks); }
65
+ catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, `${path}: ${error.message}`, READ_ATTEMPTED); throw error; }
66
+ if (operation.movePath !== undefined) {
67
+ destination = canonical(operation.movePath);
68
+ if (observed.get(destination)!.version !== null) throw new ToolFault("version_mismatch", `patch move requires an absent destination: ${destination}`, READ_ATTEMPTED);
69
+ }
70
+ }
71
+ if (source.bytes !== null) await putBlob(blobs, source.bytes);
72
+ const after = content === null ? null : { sha256: await putBlob(blobs, content), mode: source.version?.mode ?? (0o666 & ~process.umask()) };
73
+ if (destination !== path) {
74
+ changes[path] = { before: source.version, after: null };
75
+ changes[destination] = { before: null, after };
76
+ } else changes[path] = { before: source.version, after };
77
+ diffs.push({ path, ...(destination === path ? {} : { destination }), kind: destination !== path ? "move" : operation.kind,
78
+ diff: unifiedFileDiff(path, before, content, destination) });
79
+ }
80
+ return { root: resolve(root), operations, changes, diffs };
81
+ }
82
+
83
+ /** The caller persists prepared/applied checkpoint phases around this operation. */
84
+ export async function applyPreparedPatch(root: string, blobs: string, prepared: PreparedPatch, signal?: AbortSignal): Promise<void> {
85
+ if (resolve(root) !== prepared.root) throw new ToolFault("invalid_input", "prepared patch names another workspace", NOT_STARTED);
86
+ if (signal?.aborted) throw new ToolFault("cancelled", "patch application cancelled", NOT_STARTED);
87
+ await applyFileChanges(root, blobs, prepared.changes, signal);
88
+ for (const path of changedPaths(prepared.changes)) {
89
+ if (!sameVersion((await readEditableFile(root, path)).version, prepared.changes[path]!.after))
90
+ throw new Error(`file changed concurrently after patch: ${path}; inspect the prepared checkpoint`);
91
+ }
92
+ }
@@ -0,0 +1,34 @@
1
+ /** R13 / D13: path policy sees aliases and actual resources; confinement remains separate. */
2
+ import { realpath } from "node:fs/promises";
3
+ import { dirname, basename, resolve, relative, isAbsolute } from "node:path";
4
+ import { permissionRequest, normalizePermissionPath, type PermissionRequest } from "../core/permissions.ts";
5
+
6
+ async function canonicalPath(path: string): Promise<string> {
7
+ try { return await realpath(path); }
8
+ catch (error) {
9
+ if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ENOTDIR"].includes(String(error.code))) throw error;
10
+ const parent = dirname(path);
11
+ if (parent === path) throw error;
12
+ // New files have no realpath yet; existing ancestors still reveal aliases.
13
+ return resolve(await canonicalPath(parent), basename(path));
14
+ }
15
+ }
16
+ function resource(root: string, absolute: string): string {
17
+ const path = relative(root, absolute);
18
+ return normalizePermissionPath(path === ".." || path.startsWith("../") || isAbsolute(path) ? absolute : path || ".");
19
+ }
20
+
21
+ export async function permissionPaths(root: string, paths: readonly string[]): Promise<string[]> {
22
+ const absoluteRoot = resolve(root), canonicalRoot = await canonicalPath(absoluteRoot), resources: string[] = [];
23
+ for (const path of paths) {
24
+ const absolute = resolve(absoluteRoot, path);
25
+ resources.push(resource(absoluteRoot, absolute), resource(canonicalRoot, await canonicalPath(absolute)));
26
+ }
27
+ return [...new Set(resources)];
28
+ }
29
+
30
+ /** Models never supply the root or canonical target; the current runtime does. */
31
+ export async function canonicalPermissionRequest(root: string, name: string, input: unknown): Promise<PermissionRequest> {
32
+ const request = permissionRequest(name, input);
33
+ return request.paths ? { ...request, paths: await permissionPaths(root, request.paths) } : request;
34
+ }
@@ -0,0 +1,109 @@
1
+ /** Read-only Git review with fixed argv; project diff drivers cannot execute code. */
2
+ import { lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { FileChanges } from "../core/checkpoints.ts";
5
+ import { changedPaths } from "../core/checkpoints.ts";
6
+ import { ToolFault, READ_ATTEMPTED } from "../core/tool-failure.ts";
7
+ import { unifiedFileDiff, type ReviewScope, type ReviewSnapshot } from "../core/review.ts";
8
+ import { gitBytes, MAX_UNTRACKED_BYTES } from "./workspace-observation.ts";
9
+ import { readEditableFile } from "./file-checkpoints.ts";
10
+ import { getBlobBytes, blobAddress } from "./blobs.ts";
11
+
12
+ // Assumed bounded review memory. Exceeding it is unavailable, never silently partial.
13
+ export const MAX_REVIEW_BYTES = 16 * 1024 * 1024;
14
+ const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
15
+ const flags = ["--no-pager", "--literal-pathspecs", "-c", "core.hooksPath=/dev/null"];
16
+ const diffFlags = ["--no-ext-diff", "--no-textconv", "--no-renames", "--relative"];
17
+ const checkedText = (bytes: Uint8Array) => {
18
+ if (bytes.byteLength > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
19
+ return decode(bytes);
20
+ };
21
+ const ordinaryText = (bytes: Uint8Array): string | null => {
22
+ if (bytes.includes(0)) return null;
23
+ try { return decode(bytes); } catch { return null; }
24
+ };
25
+
26
+ export async function readReview(root: string, scope: Exclude<ReviewScope, { kind: "last-turn" }>, signal?: AbortSignal, canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
27
+ const git = (args: readonly string[], input?: Uint8Array) => gitBytes(root, [...flags, ...args], input, signal);
28
+ const revision = async (value: string) => {
29
+ if (!value || value.includes("\0") || value.startsWith("-")) throw new Error("review revision must name a commit or branch");
30
+ const sha = decode(await git(["rev-parse", "--verify", "--end-of-options", value + "^{commit}"])).trim();
31
+ if (!/^[a-f0-9]{40,64}$/.test(sha)) throw new Error("review revision did not resolve to a commit");
32
+ return sha;
33
+ };
34
+ const emptyTree = async () => decode(await git(["hash-object", "-t", "tree", "--stdin"], new Uint8Array())).trim();
35
+ let base: string, end: string | undefined, label: string;
36
+ if (scope.kind === "commit") {
37
+ end = await revision(scope.commit);
38
+ const parents = decode(await git(["rev-list", "--parents", "-n", "1", end])).trim().split(" ");
39
+ base = parents[1] ?? await emptyTree();
40
+ label = `commit ${end.slice(0, 12)}${parents.length > 2 ? " against its first parent" : ""}`;
41
+ } else if (scope.kind === "branch") {
42
+ const branch = await revision(scope.base), head = await revision("HEAD");
43
+ base = decode(await git(["merge-base", head, branch])).trim();
44
+ label = `changes against ${scope.base} (merge base ${base.slice(0, 12)})`;
45
+ } else {
46
+ try { base = await revision("HEAD"); }
47
+ catch {
48
+ // An unborn branch has no HEAD; a corrupt/missing existing HEAD stays unavailable.
49
+ const heads = decode(await git(["rev-list", "--all", "--count"])).trim();
50
+ if (heads !== "0") throw new Error("working review cannot resolve HEAD");
51
+ base = await emptyTree();
52
+ }
53
+ label = "current staged, unstaged and untracked changes";
54
+ }
55
+ const range = [base, ...(end === undefined ? [] : [end])];
56
+ const paths = decode(await git(["diff", ...diffFlags, "--name-only", "-z", ...range, "--", "."])).split("\0").filter(Boolean);
57
+ const untracked = scope.kind === "commit" ? [] : decode(await git(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])).split("\0").filter(Boolean);
58
+ for (const path of [...new Set([...paths, ...untracked])]) if (canRead && !await canRead(path))
59
+ throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
60
+ // Freeze the authorized path set in the actual collection argv. A concurrent
61
+ // change to a different path must not widen this observation after admission.
62
+ const diff = paths.length ? checkedText(await git(["diff", ...diffFlags, ...range, "--", ...paths])) : "";
63
+ const parts = [diff], limitations: string[] = [];
64
+ let bytes = Buffer.byteLength(diff);
65
+ if (scope.kind !== "commit") {
66
+ for (const path of untracked.sort()) {
67
+ if (signal?.aborted) throw new Error("review cancelled");
68
+ const info = await lstat(join(root, path));
69
+ if (info.size > MAX_UNTRACKED_BYTES) { limitations.push(`Untracked file ${JSON.stringify(path)} exceeds ${MAX_UNTRACKED_BYTES} bytes and is unavailable.`); continue; }
70
+ let source: Awaited<ReturnType<typeof readEditableFile>>;
71
+ try { source = await readEditableFile(root, path); }
72
+ catch { limitations.push(`Untracked file ${JSON.stringify(path)} is not an ordinary confined file.`); continue; }
73
+ if (source.bytes === null) throw new Error(`untracked file disappeared during review: ${path}`);
74
+ const text = ordinaryText(source.bytes);
75
+ const addition = text === null ? `Binary untracked file ${JSON.stringify(path)}: ${source.bytes.byteLength} bytes, sha256 ${source.version!.sha256}\n`
76
+ : unifiedFileDiff(path, null, text);
77
+ bytes += Buffer.byteLength(addition);
78
+ if (bytes > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
79
+ parts.push(addition); paths.push(path);
80
+ if (text === null) limitations.push(`Binary untracked content is identified but cannot be reviewed as text: ${JSON.stringify(path)}.`);
81
+ }
82
+ }
83
+ return { scope, label, diff: parts.join("\n"), paths: [...new Set(paths)].sort(), limitations, base };
84
+ }
85
+
86
+ /** Build last-turn review from verified checkpoint blobs, independent of present disk bytes. */
87
+ export async function readCheckpointReview(blobs: string, changes: FileChanges, limitations: readonly string[] = [], canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
88
+ const parts: string[] = [], notices = [...limitations], paths = changedPaths(changes);
89
+ for (const path of paths) if (canRead && !await canRead(path))
90
+ throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
91
+ let bytes = 0;
92
+ for (const path of paths) {
93
+ const firstPart = parts.length;
94
+ const { before, after } = changes[path]!;
95
+ const source = before === null ? null : await getBlobBytes(blobs, before.sha256);
96
+ const target = after === null ? null : await getBlobBytes(blobs, after.sha256);
97
+ if (before && (source === null || blobAddress(source) !== before.sha256) || after && (target === null || blobAddress(target) !== after.sha256))
98
+ throw new Error(`missing or corrupt last-turn checkpoint object: ${path}`);
99
+ const oldText = source === null ? null : ordinaryText(source), newText = target === null ? null : ordinaryText(target);
100
+ if (source !== null && oldText === null || target !== null && newText === null) {
101
+ parts.push(`Binary checkpoint change ${JSON.stringify(path)}: ${before?.sha256 ?? "absent"} -> ${after?.sha256 ?? "absent"}\n`);
102
+ notices.push(`Binary checkpoint content cannot be reviewed as text: ${JSON.stringify(path)}.`);
103
+ } else parts.push(unifiedFileDiff(path, oldText, newText));
104
+ if (before && after && before.mode !== after.mode) parts.push(`mode ${JSON.stringify(path)}: ${before.mode.toString(8)} -> ${after.mode.toString(8)}\n`);
105
+ bytes += parts.slice(firstPart).reduce((total, part) => total + Buffer.byteLength(part), 0);
106
+ if (bytes > MAX_REVIEW_BYTES) throw new Error("last-turn review exceeds the byte budget");
107
+ }
108
+ return { scope: { kind: "last-turn" }, label: "captured file changes in the last user turn", diff: parts.join("\n"), paths, limitations: notices };
109
+ }
@@ -94,6 +94,8 @@ export interface GrepOpts {
94
94
  /** Trusted harness budgets, not model arguments. */
95
95
  readonly maxCaptureBytes?: number;
96
96
  readonly maxIndexEntries?: number;
97
+ /** Trusted admission for each POSIX workspace-relative resource, before content is read. */
98
+ readonly canRead?: (path: string) => Promise<boolean>;
97
99
  }
98
100
 
99
101
  export interface FindOpts {
@@ -106,6 +108,7 @@ export interface FindOpts {
106
108
  readonly signal?: AbortSignal;
107
109
  readonly maxCaptureBytes?: number;
108
110
  readonly maxIndexEntries?: number;
111
+ readonly canRead?: (path: string) => Promise<boolean>;
109
112
  }
110
113
 
111
114
  export interface LsOpts {
@@ -113,6 +116,7 @@ export interface LsOpts {
113
116
  readonly path: string;
114
117
  readonly limit?: number;
115
118
  readonly signal?: AbortSignal;
119
+ readonly canRead?: (path: string) => Promise<boolean>;
116
120
  }
117
121
 
118
122
  function observed(observation: SearchObservation, diagnostic?: ToolDiagnostic): SearchResult {
@@ -256,6 +260,29 @@ async function listedFiles(abs: string, rel: string, kind: "file" | "dir", optio
256
260
  return { paths: index.paths.map((p) => (rel.length === 0 ? p : `${rel}/${p}`)), issues: [...(index.issues ?? [])] };
257
261
  }
258
262
 
263
+ /** Denied filenames cannot reappear in scan diagnostics or archived observations. */
264
+ function policyIssues(issues: SearchObservation["scan"]["reasons"], canRead: GrepOpts["canRead"]): SearchObservation["scan"]["reasons"] {
265
+ return canRead ? issues.map(issue => ({ code: issue.code })) : [...issues];
266
+ }
267
+ async function resourceAdmitted(path: string, canRead: GrepOpts["canRead"], reasons: SearchObservation["scan"]["reasons"],
268
+ signal?: AbortSignal, deadline?: number): Promise<boolean> {
269
+ if (!canRead) return true;
270
+ let allowed: boolean | undefined = false, abort = () => {};
271
+ let timer: ReturnType<typeof setTimeout> | undefined;
272
+ const stopped = new Promise<undefined>(resolve => {
273
+ abort = () => resolve(undefined); signal?.addEventListener("abort", abort, { once: true });
274
+ if (deadline !== undefined) timer = setTimeout(abort, Math.max(0, deadline - performance.now()));
275
+ if (signal?.aborted) abort();
276
+ });
277
+ try { allowed = await Promise.race([Promise.resolve().then(() => canRead(path)).catch(() => false), stopped]); }
278
+ finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
279
+ if (allowed === undefined || signal?.aborted || deadline !== undefined && performance.now() >= deadline) {
280
+ reasons.push({ code: signal?.aborted ? "cancelled" : "timeout" }); return false;
281
+ }
282
+ if (!allowed && !reasons.some(reason => reason.code === "permission_omitted")) reasons.push({ code: "permission_omitted" });
283
+ return allowed;
284
+ }
285
+
259
286
  interface WorkerMatch {
260
287
  readonly ok: boolean;
261
288
  readonly hits?: ReadonlyArray<{ readonly line: number; readonly text: string }>;
@@ -475,7 +502,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
475
502
  const filtered = glob === undefined ? files.paths : files.paths.filter((p) => globMatches(glob, p));
476
503
  const matcher = new LineMatcher();
477
504
  const hits: MatchLine[] = [];
478
- const reasons: SearchObservation["scan"]["reasons"] = [...files.issues];
505
+ const reasons: SearchObservation["scan"]["reasons"] = policyIssues(files.issues, opts.canRead);
479
506
  const sources: SearchObservation["sources"] = [];
480
507
  let examined = 0, capturedBytes = 0;
481
508
  const budget = Math.min(opts.maxCaptureBytes ?? MAX_SEARCH_CAPTURE_BYTES, MAX_SEARCH_CAPTURE_BYTES);
@@ -483,6 +510,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
483
510
  for (const fileRel of filtered) {
484
511
  if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
485
512
  if (ignoredRel(fileRel)) continue;
513
+ if (!await resourceAdmitted(fileRel, opts.canRead, reasons, opts.signal, deadline)) continue;
486
514
  const fileAbs = join(opts.root, fileRel);
487
515
  let st;
488
516
  try {
@@ -541,7 +569,9 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
541
569
  if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
542
570
 
543
571
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_MATCH_LIMIT;
544
- const engine = opts.engine ?? "auto";
572
+ // External search captures bytes before the runtime can inspect each
573
+ // resource. A policy callback therefore always uses the admitted walk.
574
+ const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
545
575
 
546
576
  switch (engine) {
547
577
  case "walk":
@@ -607,13 +637,18 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
607
637
  }
608
638
 
609
639
  async function findWalk(opts: FindOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
610
- const files = await listedFiles(abs, rel, kind, { deadline: performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS),
640
+ const deadline = performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
641
+ const files = await listedFiles(abs, rel, kind, { deadline,
611
642
  ...(opts.signal ? { signal: opts.signal } : {}), ...(opts.maxIndexEntries === undefined ? {} : { maxEntries: opts.maxIndexEntries }) });
612
- const paths = files.paths.filter((p) => !ignoredRel(p) && globMatches(opts.pattern, p));
643
+ const reasons = policyIssues(files.issues, opts.canRead), paths: string[] = [];
644
+ for (const path of files.paths) {
645
+ if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
646
+ if (!ignoredRel(path) && globMatches(opts.pattern, path) && await resourceAdmitted(path, opts.canRead, reasons, opts.signal, deadline)) paths.push(path);
647
+ }
613
648
  paths.sort((a, b) => (a < b ? -1 : 1));
614
- const stopped = files.issues.find(reason => ["timeout", "cancelled"].includes(reason.code));
649
+ const stopped = reasons.find(reason => ["timeout", "cancelled"].includes(reason.code));
615
650
  return observed({ engine: "walk", rows: paths.map(text => ({ prefix: "", text })), sources: [], limit, empty: "No files found matching pattern",
616
- scan: { status: files.issues.length ? "partial" : "complete", reasons: files.issues, examined_files: files.paths.length, source_versions: "unknown" } },
651
+ scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: files.paths.length, source_versions: "unknown" } },
617
652
  stopped ? failureDiagnostic(stopped.code === "cancelled" ? "cancelled" : "timeout", `search ${stopped.code}`, READ_ATTEMPTED) : undefined);
618
653
  }
619
654
 
@@ -622,7 +657,7 @@ export async function find(opts: FindOpts): Promise<SearchResult> {
622
657
  const target = await inspectTarget(opts.root, opts.path);
623
658
  if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
624
659
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_FIND_LIMIT;
625
- const engine = opts.engine ?? "auto";
660
+ const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
626
661
  switch (engine) {
627
662
  case "walk":
628
663
  return findWalk(opts, opts.path, target.kind, target.rel, limit);
@@ -656,11 +691,14 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
656
691
  return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false, diagnostic: failureFromError(err, READ_ATTEMPTED) };
657
692
  }
658
693
 
659
- const names: string[] = [];
694
+ const names: string[] = [], reasons: SearchObservation["scan"]["reasons"] = [];
695
+ const deadline = performance.now() + PROCESS_TIMEOUT_MS;
660
696
  for (const entry of entries) {
661
697
  if (DEFAULT_IGNORE.includes(entry.name)) continue;
662
698
  const childRel = target.rel.length === 0 ? entry.name : `${target.rel}/${entry.name}`;
663
699
  if (ignoredRel(childRel)) continue;
700
+ if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
701
+ if (!await resourceAdmitted(childRel, opts.canRead, reasons, opts.signal, deadline)) continue;
664
702
  // Do not follow: a symlink is listed as a name, never as the directory
665
703
  // it points at, so a link to / cannot walk the disk.
666
704
  if (entry.isSymbolicLink()) {
@@ -679,5 +717,6 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
679
717
 
680
718
  const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_LS_LIMIT;
681
719
  return observed({ engine: "directory", rows: names.map(text => ({ prefix: "", text })), sources: [], limit, empty: "(empty directory)",
682
- scan: { status: "complete", reasons: [], examined_files: names.length, source_versions: "unknown" } });
720
+ scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: names.length, source_versions: "unknown" } },
721
+ reasons.some(reason => reason.code === "cancelled" || reason.code === "timeout") ? failureDiagnostic(opts.signal?.aborted ? "cancelled" : "timeout", "directory listing stopped", READ_ATTEMPTED) : undefined);
683
722
  }