@visual-z/xeno 0.4.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 (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -59,24 +59,33 @@ export function requestCompletion(state: RequestState, remainingRequests: number
59
59
 
60
60
  const gaps: string[] = [];
61
61
  const acceptance = options?.acceptance ?? state.acceptance;
62
+ const missingCommands = acceptance?.status === "needs_work" ? acceptance.criteria.flatMap(item =>
63
+ item.kind === "command" && item.status !== "pass" ? [{ id: item.id, command: item.command, status: item.status }] : []) : [];
62
64
  if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
63
65
  if (state.changed && !verifiedAfter && !acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
64
66
  if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
65
- ...acceptance.criteria.filter(item => item.kind === "command" && item.status !== "pass").map(item => `${item.id}=${item.status}`),
67
+ ...missingCommands.map(item => `${item.id}=${item.status}`),
66
68
  ...(acceptance.targets ?? []).filter(target => target.status !== "ready").map(target => `target ${target.id}=${target.status}`),
67
69
  ].join(", ")}`);
68
70
  if (text.length > 0 && looksLikeHandBack(text) && acceptance?.status !== "awaiting_human") gaps.push(options?.taskAware
69
71
  ? "the reply deferred the request; check whether authorized work remains or a specific blocker requires the user"
70
72
  : "the reply handed the task back instead of finishing it; no one will answer");
71
73
  if (gaps.length === 0) return finish;
74
+ // D11 / O.11: a declared command needs its exact receipt even when the
75
+ // request has no recorded edit. Compound shell success is not that receipt.
76
+ const next = missingCommands.length > 0
77
+ ? "Obtain a fresh successful receipt for each missing command. Run each command exactly as a separate invocation without adding other commands:\n" +
78
+ missingCommands.map(item => `- ${item.id}: ${JSON.stringify(item.command)}`).join("\n") +
79
+ "\nIf verification cannot run, report the specific blocker. "
80
+ : options?.taskAware && !state.changed
81
+ ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Finish with the result or the specific blocker. "
82
+ : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ";
72
83
  return { action: "continue", guidance: {
73
84
  reason: "completion_review",
74
85
  sources: [state.request.event, final.source.event],
75
86
  text: `Harness completion check: ${gaps.join("; ")}. ` +
76
87
  `Re-read the original request (event ${state.request.event}) and check each requirement against evidence in this session. ` +
77
- (options?.taskAware && !state.changed
78
- ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Required human acceptance remains pending. Finish with the result or the specific blocker. "
79
- : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ") +
88
+ next + (acceptance?.criteria.some(item => item.kind === "human" && item.status !== "accepted") ? "Required human acceptance remains pending. " : "") +
80
89
  "This check runs once per request.",
81
90
  } };
82
91
  }
@@ -9,12 +9,19 @@ const turn = { ...thread, turnId: identity };
9
9
  export const CONTROL_METHODS = {
10
10
  initialize: object({ clientInfo: object({ name: identity, version: identity }) }),
11
11
  initialized: object({}),
12
+ "thread/list": object({}),
13
+ "thread/new": object({ name: identity }, []),
14
+ "thread/resume": object({ session: text }),
15
+ "thread/fork": object({ ...thread, name: identity }, ["threadId"]),
16
+ "thread/close": object(thread),
17
+ "thread/mode": object({ ...thread, mode: { enum: ["build", "plan"] } }),
12
18
  "thread/read": object(thread),
13
19
  "thread/subscribe": object({ ...thread, after: object({ streamId: identity, sequence: { type: "integer", minimum: 0 } }) }, ["threadId"]),
14
20
  "turn/start": object({ ...thread, input: text }),
15
21
  "turn/steer": object({ ...turn, input: text }),
16
22
  "turn/interrupt": object(turn),
17
23
  "approval/respond": object({ ...turn, approvalId: identity, choice: { enum: ["once", "command", "tool", "deny"] } }),
24
+ "question/respond": object({ ...turn, requestId: text, reply: text }),
18
25
  "acceptance/accept": object({ ...thread, requestId: identity, criterionId: identity }),
19
26
  "context/read": object({ ...thread, focus: { enum: ["messages", "omitted", "history"] }, offset: { type: "integer", minimum: 0 },
20
27
  limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId", "focus"]),
@@ -24,7 +31,7 @@ export const CONTROL_METHODS = {
24
31
  } as const;
25
32
  export const CONTROL_SCHEMA = { protocol: "xeno-control/0.1", experimental: true,
26
33
  transport: "JSONL; JSON-RPC envelopes with jsonrpc omitted", methods: CONTROL_METHODS,
27
- notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "thread/statusChanged"],
34
+ notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "question/request", "question/resolved", "thread/statusChanged"],
28
35
  recovery: "subscribe first, then use the returned snapshot cursor; gaps or a new streamId require a snapshot and paginated item/list, never replaying commands",
29
36
  identity: "threadId hashes the resolved session directory path; turnId is the original user event ID; item identity is node plus event ID",
30
37
  limits: { frameBytes: 1048576, queuedRequests: 32, outputBytes: 2097152 } } as const;
@@ -26,6 +26,12 @@ export const isFileDigest = (value: unknown): value is string => typeof value ==
26
26
  export function latestObservedFileVersion(events: readonly LogEvent[], resource: string, identify: (path: string) => string): string | undefined {
27
27
  for (let index = events.length - 1; index >= 0; index--) {
28
28
  const event = events[index]!;
29
+ // P.3: multi-file Patch and captured commands observe each actual resulting version.
30
+ if (event.t === "checkpoint" && event.phase === "applied") {
31
+ for (const [path, change] of Object.entries(event.changes))
32
+ if (identify(path) === resource) return change.after?.sha256;
33
+ continue;
34
+ }
29
35
  if (event.t !== "tool_result") continue;
30
36
  const effect = event.effect;
31
37
  if ((effect?.kind === "read" || effect?.kind === "edit") && identify(effect.path) === resource)
@@ -0,0 +1,34 @@
1
+ /** LSP 3.17 uses byte-counted Content-Length, not line-delimited JSON. */
2
+ export type LspFrame = { body: Uint8Array; consumed: number };
3
+ // Operational ceiling for optional server output; this is not a context segment threshold.
4
+ export const MAX_LSP_FRAME = 8 * 1024 * 1024;
5
+ // An operational bound, independent of context-segment thresholds: reject unbounded headers.
6
+ export const MAX_LSP_HEADER = 8192;
7
+ export function nextLspFrame(bytes: Uint8Array): LspFrame | null {
8
+ let end = -1;
9
+ for (let i = 0; i + 3 < bytes.length; i++) if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) { end = i; break; }
10
+ if (end < 0) { if (bytes.length > MAX_LSP_HEADER) throw Error("LSP header exceeds 8 KiB"); return null; }
11
+ if (end > MAX_LSP_HEADER || bytes.subarray(0, end).some(byte => byte > 127)) throw Error("invalid LSP ASCII header");
12
+ const headers = new TextDecoder("ascii").decode(bytes.subarray(0, end)).split("\r\n");
13
+ if (headers.some(header => !/^[a-zA-Z0-9-]+:[\t\x20-\x7e]*$/.test(header))) throw Error("invalid LSP header field");
14
+ const lengths = headers.filter(header => /^content-length:/i.test(header));
15
+ if (lengths.length !== 1 || !/^content-length:\s*[0-9]+\s*$/i.test(lengths[0]!)) throw Error("invalid LSP Content-Length");
16
+ const types = headers.filter(header => /^content-type:/i.test(header));
17
+ if (types.length > 1) throw Error("ambiguous LSP Content-Type");
18
+ const charsets = types.flatMap(header => header.split(";").slice(1).filter(parameter => /^\s*charset\s*=/i.test(parameter)));
19
+ if (charsets.length > 1 || charsets.some(parameter => !/^\s*charset\s*=\s*(utf-?8|"utf-?8")\s*$/i.test(parameter))) throw Error("unsupported LSP charset");
20
+ const length = Number(lengths[0]!.split(":")[1]!.trim());
21
+ if (!Number.isSafeInteger(length) || length > MAX_LSP_FRAME) throw Error("LSP frame exceeds 8 MiB");
22
+ const consumed = end + 4 + length;
23
+ return bytes.length < consumed ? null : { body: bytes.slice(end + 4, consumed), consumed };
24
+ }
25
+ /** Unversioned server diagnostics cannot prove that they describe the current owned file. */
26
+ export function diagnosticFreshness(observedVersion: number | undefined, currentVersion: number): "current" | "stale" | "unversioned" {
27
+ return observedVersion === undefined ? "unversioned" : observedVersion === currentVersion ? "current" : "stale";
28
+ }
29
+ export function encodeLspFrame(value: unknown): Uint8Array {
30
+ const body = new TextEncoder().encode(JSON.stringify(value));
31
+ if (body.length > MAX_LSP_FRAME) throw Error("LSP frame exceeds 8 MiB");
32
+ const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
33
+ const frame = new Uint8Array(header.length + body.length); frame.set(header); frame.set(body, header.length); return frame;
34
+ }
@@ -0,0 +1,68 @@
1
+ /** Explicit MCP configuration is authority; remote tool annotations never grant permissions (D11/D13). */
2
+ import { webUrl } from "./web-content.ts";
3
+
4
+ export interface McpOAuthConfig { clientId?: string; clientSecretEnv?: string; scope?: string; redirectUrl?: string }
5
+ interface McpCommonConfig { enabled?: boolean; timeoutMs?: number }
6
+ export type McpServerConfig = McpCommonConfig & ({ transport: "stdio"; command: string; args?: string[]; env?: Record<string, string> }
7
+ | { transport: "http"; url: string; bearerTokenEnv?: string; headers?: Record<string, string>; headersEnv?: Record<string, string>; oauth?: boolean | McpOAuthConfig });
8
+ export type McpServers = Readonly<Record<string, McpServerConfig>>;
9
+ function object(value: unknown, label: string): Record<string, unknown> {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
11
+ return value as Record<string, unknown>;
12
+ }
13
+ function strings(value: unknown, label: string, envNames = false): Record<string, string> {
14
+ const source = object(value, label), result: Record<string, string> = Object.create(null);
15
+ for (const [key, entry] of Object.entries(source)) {
16
+ if (typeof entry !== "string" || /[\r\n\u0000]/.test(entry) || /[\r\n\u0000]/.test(key)) throw Error(`${label} must contain single-line string values`);
17
+ if (envNames && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry)) throw Error(`${label} values must name environment variables`);
18
+ result[key] = entry;
19
+ }
20
+ return result;
21
+ }
22
+ function optionalString(source: Record<string, unknown>, key: string, envName = false): string | undefined {
23
+ const value = source[key];
24
+ if (value === undefined) return undefined;
25
+ if (typeof value !== "string" || !value || /[\r\n\u0000]/.test(value) || envName && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw Error(`${key} must be a valid ${envName ? "environment variable name" : "single-line string"}`);
26
+ return value;
27
+ }
28
+ export function mcpCallbackUrl(value: string): string {
29
+ const parsed = new URL(webUrl(value));
30
+ if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1" || parsed.search || !parsed.port || !parsed.pathname.startsWith("/"))
31
+ throw Error("OAuth redirectUrl must be an HTTP 127.0.0.1 URL with an explicit port and no query");
32
+ return parsed.href;
33
+ }
34
+ function oauth(value: unknown): boolean | McpOAuthConfig {
35
+ if (typeof value === "boolean") return value;
36
+ const source = object(value, "oauth");
37
+ if (Object.keys(source).some(key => !["clientId", "clientSecretEnv", "scope", "redirectUrl"].includes(key))) throw Error("oauth has an unsupported field");
38
+ const clientId = optionalString(source, "clientId"), clientSecretEnv = optionalString(source, "clientSecretEnv", true), scope = optionalString(source, "scope"), redirectUrl = optionalString(source, "redirectUrl");
39
+ if (clientSecretEnv && !clientId) throw Error("oauth.clientSecretEnv requires oauth.clientId");
40
+ return { ...(clientId ? { clientId } : {}), ...(clientSecretEnv ? { clientSecretEnv } : {}), ...(scope ? { scope } : {}), ...(redirectUrl ? { redirectUrl: mcpCallbackUrl(redirectUrl) } : {}) };
41
+ }
42
+ export function parseMcpServers(value: unknown): McpServers {
43
+ if (value === undefined || value === null) return {};
44
+ const source = object(value, "mcpServers"), result: Record<string, McpServerConfig> = Object.create(null);
45
+ for (const [name, raw] of Object.entries(source)) {
46
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) throw Error("MCP server names must contain 1 to 64 letters, digits, underscores or hyphens");
47
+ const config = object(raw, `MCP server ${name}`), enabled = config["enabled"], timeout = config["timeoutMs"];
48
+ if (enabled !== undefined && typeof enabled !== "boolean") throw Error(`MCP server ${name}.enabled must be boolean`);
49
+ // Assumed cap protects startup and tool execution from a misconfigured peer, not an OS sandbox.
50
+ if (timeout !== undefined && (!Number.isSafeInteger(timeout) || Number(timeout) < 1 || Number(timeout) > 300_000)) throw Error("MCP timeoutMs must be an integer from 1 to 300000");
51
+ const common = { ...(typeof enabled === "boolean" ? { enabled } : {}), ...(typeof timeout === "number" ? { timeoutMs: timeout } : {}) };
52
+ if (config["transport"] === "stdio") {
53
+ if (Object.keys(config).some(key => !["transport", "command", "args", "env", "enabled", "timeoutMs"].includes(key))) throw Error(`MCP server ${name} has an unsupported stdio field`);
54
+ const command = optionalString(config, "command"), args = config["args"], env = config["env"];
55
+ if (!command) throw Error(`MCP server ${name}.command is required`);
56
+ if (args !== undefined && (!Array.isArray(args) || !args.every(arg => typeof arg === "string" && !arg.includes("\u0000")))) throw Error("MCP args must be strings without NUL");
57
+ result[name] = { transport: "stdio", command, ...common, ...(Array.isArray(args) ? { args: args.slice() as string[] } : {}), ...(env === undefined ? {} : { env: strings(env, "env") }) };
58
+ } else if (config["transport"] === "http") {
59
+ if (Object.keys(config).some(key => !["transport", "url", "bearerTokenEnv", "headers", "headersEnv", "oauth", "enabled", "timeoutMs"].includes(key))) throw Error(`MCP server ${name} has an unsupported HTTP field`);
60
+ const token = optionalString(config, "bearerTokenEnv", true), headers = config["headers"], headersEnv = config["headersEnv"];
61
+ if (config["oauth"] && token) throw Error("Use bearerTokenEnv or oauth for one MCP server, not both");
62
+ result[name] = { transport: "http", url: webUrl(config["url"]), ...common, ...(token ? { bearerTokenEnv: token } : {}),
63
+ ...(headers === undefined ? {} : { headers: strings(headers, "headers") }), ...(headersEnv === undefined ? {} : { headersEnv: strings(headersEnv, "headersEnv", true) }),
64
+ ...(config["oauth"] === undefined ? {} : { oauth: oauth(config["oauth"]) }) };
65
+ } else throw Error(`MCP server ${name}.transport must be stdio or http`);
66
+ }
67
+ return result;
68
+ }
@@ -0,0 +1,28 @@
1
+ /** User selections become startup preferences; restored session state stays in the log (D8/Q.25). */
2
+ import { recordRecent } from "./model-picker.ts";
3
+ import { modelKey } from "./session-position.ts";
4
+ import { settingsToWrite, type Settings } from "./settings.ts";
5
+ import type { ThinkingLevel } from "./thinking.ts";
6
+
7
+ interface ModelIdentity { readonly provider: string; readonly id: string }
8
+
9
+ /** Keep the effective effort with its model, without changing another model's saved effort. */
10
+ export function modelPreferencePatch(model: ModelIdentity, recent: readonly string[], effort: ThinkingLevel): Settings {
11
+ return { defaultProvider: model.provider, defaultModel: model.id, recentModels: recordRecent(recent, modelKey(model)),
12
+ modelThinkingLevels: { [modelKey(model)]: effort } };
13
+ }
14
+
15
+ export function thinkingPreferencePatch(model: ModelIdentity, effort: ThinkingLevel): Settings {
16
+ return { defaultThinking: effort, modelThinkingLevels: { [modelKey(model)]: effort } };
17
+ }
18
+
19
+ /** Patch only known preferences; retain future keys and nested fields not owned by this selection. */
20
+ export function mergePreferenceJson(previous: Readonly<Record<string, unknown>>, patch: Settings): Record<string, unknown> {
21
+ const next = { ...previous, ...settingsToWrite(patch) };
22
+ for (const key of ["terminal", "images", "markdown", "modelThinkingLevels"] as const) {
23
+ if (patch[key] === undefined) continue;
24
+ const old = previous[key];
25
+ next[key] = { ...(old !== null && typeof old === "object" && !Array.isArray(old) ? old : {}), ...patch[key] };
26
+ }
27
+ return next;
28
+ }
@@ -0,0 +1,130 @@
1
+ /** Codex patch grammar with exact, unambiguous matching (D9 / R4). No I/O. */
2
+ import { assertNever } from "./exhaustive.ts";
3
+
4
+ // Assumed resource ceilings, not measured: reject oversized plans before retaining hunks.
5
+ export const MAX_PATCH_CHARS = 2 * 1024 * 1024;
6
+ export const MAX_PATCH_FILES = 100;
7
+ export interface PatchChunk {
8
+ oldLines: readonly string[];
9
+ newLines: readonly string[];
10
+ anchor?: string;
11
+ endOfFile: boolean;
12
+ }
13
+ export type PatchOperation =
14
+ | { kind: "add"; path: string; content: string }
15
+ | { kind: "delete"; path: string }
16
+ | { kind: "update"; path: string; movePath?: string; chunks: readonly PatchChunk[] };
17
+ export class PatchError extends Error {
18
+ readonly code: "invalid_input" | "not_found" | "ambiguous_edit";
19
+ constructor(code: PatchError["code"], message: string) { super(message); this.code = code; }
20
+ }
21
+ const invalid = (message: string): never => { throw new PatchError("invalid_input", message); };
22
+ const pathValue = (text: string): string => {
23
+ if (!text.trim() || text.includes("\0")) return invalid("patch paths must be nonempty and contain no NUL");
24
+ return text;
25
+ };
26
+ const fileHeader = (line: string) => /^\*\*\* (?:Add|Delete|Update) File: /.test(line);
27
+
28
+ /** Parse all operations before the store resolves any target or changes a byte. */
29
+ export function parsePatch(text: string): PatchOperation[] {
30
+ if (text.length > MAX_PATCH_CHARS) return invalid("patch exceeds the character budget");
31
+ const lines = text.replaceAll("\r\n", "\n").trim().split("\n");
32
+ if (lines[0] !== "*** Begin Patch" || lines.at(-1) !== "*** End Patch")
33
+ return invalid("patch must begin with *** Begin Patch and end with *** End Patch");
34
+ const operations: PatchOperation[] = [];
35
+ let index = 1;
36
+ while (index < lines.length - 1) {
37
+ if (operations.length >= MAX_PATCH_FILES) return invalid("patch exceeds the file budget");
38
+ const header = lines[index++]!;
39
+ if (header.startsWith("*** Add File: ")) {
40
+ const path = pathValue(header.slice("*** Add File: ".length));
41
+ const added: string[] = [];
42
+ while (index < lines.length - 1 && !fileHeader(lines[index]!)) {
43
+ const line = lines[index++]!;
44
+ if (!line.startsWith("+")) return invalid(`added lines must start with +: ${path}`);
45
+ added.push(line.slice(1));
46
+ }
47
+ operations.push({ kind: "add", path, content: added.length ? added.join("\n") + "\n" : "" });
48
+ continue;
49
+ }
50
+ if (header.startsWith("*** Delete File: ")) {
51
+ operations.push({ kind: "delete", path: pathValue(header.slice("*** Delete File: ".length)) });
52
+ continue;
53
+ }
54
+ if (!header.startsWith("*** Update File: ")) return invalid(`unexpected patch line ${index}: ${header}`);
55
+ const path = pathValue(header.slice("*** Update File: ".length));
56
+ const moveLine = lines[index];
57
+ const movePath = moveLine?.startsWith("*** Move to: ") ? pathValue(moveLine.slice("*** Move to: ".length)) : undefined;
58
+ if (movePath !== undefined) index++;
59
+ const chunks: PatchChunk[] = [];
60
+ while (index < lines.length - 1 && !fileHeader(lines[index]!)) {
61
+ const marker = lines[index++]!;
62
+ if (marker !== "@@" && !marker.startsWith("@@ ")) return invalid(`update hunks must start with @@: ${path}`);
63
+ const oldLines: string[] = [], newLines: string[] = [];
64
+ let endOfFile = false, changed = false;
65
+ while (index < lines.length - 1 && !fileHeader(lines[index]!) && !lines[index]!.startsWith("@@")) {
66
+ const line = lines[index++]!;
67
+ if (line === "*** End of File") { endOfFile = true; break; }
68
+ const prefix = line[0];
69
+ if (prefix !== " " && prefix !== "+" && prefix !== "-") return invalid(`invalid update line: ${path}`);
70
+ if (prefix !== "+") oldLines.push(line.slice(1));
71
+ if (prefix !== "-") newLines.push(line.slice(1));
72
+ if (prefix !== " ") changed = true;
73
+ }
74
+ if (!changed) return invalid(`update hunk makes no change: ${path}`);
75
+ chunks.push({ oldLines, newLines, endOfFile, ...(marker === "@@" ? {} : { anchor: marker.slice(3) }) });
76
+ if (endOfFile && index < lines.length - 1 && !fileHeader(lines[index]!)) return invalid(`End of File must finish the update: ${path}`);
77
+ }
78
+ if (!chunks.length && movePath === undefined) return invalid(`update has no hunks: ${path}`);
79
+ operations.push({ kind: "update", path, chunks, ...(movePath === undefined ? {} : { movePath }) });
80
+ }
81
+ if (!operations.length) return invalid("patch is empty");
82
+ return operations;
83
+ }
84
+
85
+ /** Keep BOM, line endings and final-newline status; ambiguous contexts require a reread. */
86
+ export function applyPatchChunks(before: string, chunks: readonly PatchChunk[]): string {
87
+ const bom = before.startsWith("\uFEFF") ? "\uFEFF" : "";
88
+ const body = before.slice(bom.length);
89
+ const ending = body.includes("\r\n") ? "\r\n" : "\n";
90
+ const normalized = body.replaceAll("\r\n", "\n");
91
+ if (normalized.includes("\r")) return invalid("patch updates do not support bare CR line endings");
92
+ if (ending === "\r\n" && body.replaceAll("\r\n", "").includes("\n"))
93
+ return invalid("patch updates require a consistent line ending; mixed LF/CRLF bytes were preserved");
94
+ const finalNewline = normalized.endsWith("\n");
95
+ const lines = normalized ? normalized.split("\n") : [];
96
+ if (finalNewline) lines.pop();
97
+ let cursor = 0;
98
+ for (const chunk of chunks) {
99
+ let start = cursor;
100
+ if (chunk.anchor !== undefined) {
101
+ const anchors = lines.flatMap((line, at) => at >= cursor && line === chunk.anchor ? [at] : []);
102
+ if (anchors.length !== 1) throw new PatchError(anchors.length ? "ambiguous_edit" : "not_found", "patch anchor must match exactly once after the preceding hunk");
103
+ start = anchors[0]! + 1;
104
+ }
105
+ const matches: number[] = [];
106
+ for (let at = start; at <= lines.length - chunk.oldLines.length; at++) {
107
+ if (chunk.endOfFile && at + chunk.oldLines.length !== lines.length) continue;
108
+ if (chunk.oldLines.every((line, offset) => lines[at + offset] === line)) matches.push(at);
109
+ }
110
+ if (!chunk.oldLines.length && !chunk.endOfFile) {
111
+ if (chunk.anchor !== undefined || lines.length === 0) matches.splice(0, matches.length, start);
112
+ else throw new PatchError("ambiguous_edit", "insertion needs an anchor, End of File, or an empty source");
113
+ }
114
+ if (matches.length !== 1) throw new PatchError(matches.length ? "ambiguous_edit" : "not_found", "patch context must match exactly once; read current content and add context");
115
+ const at = matches[0]!;
116
+ lines.splice(at, chunk.oldLines.length, ...chunk.newLines);
117
+ cursor = at + chunk.newLines.length;
118
+ }
119
+ return bom + lines.join(ending) + (finalNewline && lines.length ? ending : "");
120
+ }
121
+
122
+ export function patchPaths(operations: readonly PatchOperation[]): string[] {
123
+ return operations.flatMap(operation => {
124
+ switch (operation.kind) {
125
+ case "add": case "delete": return [operation.path];
126
+ case "update": return operation.movePath === undefined ? [operation.path] : [operation.path, operation.movePath];
127
+ default: return assertNever(operation, "patchPaths");
128
+ }
129
+ });
130
+ }
@@ -3,8 +3,8 @@
3
3
  * call is allow / ask / deny.
4
4
  *
5
5
  * Approval is the last look at what is about to run, not a boundary.
6
- * Path tools are already confined to the workspace root; bash is not,
7
- * which is why bash defaults to ask and everything else to allow.
6
+ * Path tools are already confined to the workspace root; host commands
7
+ * are not, which is why shell entry points default to ask.
8
8
  *
9
9
  * This module must stay free of I/O, clocks, and randomness. The CLI
10
10
  * compiles flags and session memory into a rule list; this file only
@@ -19,6 +19,8 @@ export interface PermissionRequest {
19
19
  readonly tool: string;
20
20
  /** Exact command string for bash; omitted for tools that have none. */
21
21
  readonly command?: string;
22
+ /** Normalized workspace-relative or external absolute file resources. */
23
+ readonly paths?: readonly string[];
22
24
  }
23
25
 
24
26
  export interface PermissionRule {
@@ -27,6 +29,11 @@ export interface PermissionRule {
27
29
  readonly tool?: string;
28
30
  /** Absent matches every command. When set, matches only this exact string. */
29
31
  readonly command?: string;
32
+ /** Literal argv prefix, matched only for a single command without expansion. */
33
+ readonly commandPrefix?: readonly string[];
34
+ readonly path?: string;
35
+ /** Portable path glob: *, ?, and **; no brace or character-class expansion. */
36
+ readonly pathGlob?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -39,6 +46,8 @@ export type ToolSource = "builtin" | "extension";
39
46
  /** Task capabilities are frozen once at session creation (O.7); not permissions. */
40
47
  export const TOOL_PROFILES = {
41
48
  coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note"],
49
+ // An explicit preset preserves the measured legacy ten-tool coding contract.
50
+ foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
42
51
  readonly: ["read", "grep", "find", "history", "note"],
43
52
  research: ["read", "grep", "find", "history", "note", "branch", "resolve"],
44
53
  data: ["read", "grep", "find", "history", "note", "query"],
@@ -75,11 +84,16 @@ export interface ToolSelection {
75
84
  export const ALLOW_TOOLS_FLAG = "--allow-tools";
76
85
 
77
86
  /**
78
- * Product default. bash asks because it is not confined to the workspace
79
- * root; every other built-in is allow because the path tools are.
87
+ * Product default. Shell entry points ask because their effects are not
88
+ * confined to the workspace root; path tools keep their own confinement.
80
89
  */
81
90
  export function defaultRules(): readonly PermissionRule[] {
82
91
  return [
92
+ // Templates are ordinary source; actual environment values deserve the
93
+ // same last look as commands. Indirect readers also check read resources.
94
+ { action: "allow", tool: "read", pathGlob: "**/.env.example" },
95
+ { action: "ask", tool: "read", pathGlob: "**/.env" },
96
+ { action: "ask", tool: "read", pathGlob: "**/.env.*" },
83
97
  { action: "ask", tool: "bash" },
84
98
  // Same reach as bash. write_stdin needs no rule of its own: it can only
85
99
  // address a session exec_command already started, under this approval.
@@ -91,9 +105,15 @@ export function defaultRules(): readonly PermissionRule[] {
91
105
  }
92
106
 
93
107
  /**
94
- * `--allow-tools` compiled to rules, in front of the defaults so they win.
108
+ * CLI opt-ins compiled to one process-scoped rule list. YOLO permits every
109
+ * tool; capability admission and path confinement still apply (D13).
110
+ * Neither this flag nor remembered approvals become a persisted policy.
95
111
  */
96
- export function rulesWithAllowTools(allowTools: readonly string[]): PermissionRule[] {
112
+ export function rulesWithAllowTools(
113
+ allowTools: readonly string[],
114
+ options: { readonly yolo?: boolean } = {},
115
+ ): PermissionRule[] {
116
+ if (options.yolo === true) return [{ action: "allow" }];
97
117
  return [
98
118
  ...allowTools.map((tool) => ({ action: "allow" as const, tool })),
99
119
  ...defaultRules(),
@@ -103,24 +123,146 @@ export function rulesWithAllowTools(allowTools: readonly string[]): PermissionRu
103
123
  export function ruleMatches(rule: PermissionRule, request: PermissionRequest): boolean {
104
124
  if (rule.tool !== undefined && rule.tool !== request.tool) return false;
105
125
  if (rule.command !== undefined && rule.command !== request.command) return false;
126
+ if (rule.commandPrefix !== undefined) {
127
+ const words = request.command === undefined ? null : literalCommandWords(request.command);
128
+ if (!words || rule.commandPrefix.length === 0 || rule.commandPrefix.some((word, i) => words[i] !== word)) return false;
129
+ }
130
+ if (rule.path !== undefined && !request.paths?.some(path => normalizePermissionPath(path) === normalizePermissionPath(rule.path!))) return false;
131
+ if (rule.pathGlob !== undefined && !request.paths?.some(path => permissionPathMatches(rule.pathGlob!, path))) return false;
106
132
  return true;
107
133
  }
108
134
 
109
135
  /**
110
136
  * First matching rule wins. An empty list allows: the tool tests pass no
111
137
  * rules because they are testing the tool, not the gate. The CLI always
112
- * supplies defaultRules().
138
+ * supplies the current process's compiled policy.
113
139
  */
114
140
  export function decide(
115
141
  rules: readonly PermissionRule[],
116
142
  request: PermissionRequest,
117
143
  ): PermissionAction {
144
+ // A multi-file call needs every resource admitted. A broad allow for its
145
+ // first path must not conceal a denial on a later file (D13 / R13).
146
+ if (request.paths && request.paths.length > 1) {
147
+ const actions = request.paths.map(path => decide(rules, { ...request, paths: [path] }));
148
+ return actions.includes("deny") ? "deny" : actions.includes("ask") ? "ask" : "allow";
149
+ }
118
150
  for (const rule of rules) {
119
151
  if (ruleMatches(rule, request)) return rule.action;
120
152
  }
121
153
  return "allow";
122
154
  }
123
155
 
156
+ /** Configured denial is a ceiling; in-memory approval can only resolve asks. */
157
+ export function decideWithSessionRules(
158
+ configured: readonly PermissionRule[], remembered: readonly PermissionRule[], request: PermissionRequest,
159
+ ): PermissionAction {
160
+ if (decide(configured, request) === "deny") return "deny";
161
+ return decide([...remembered, ...configured], request);
162
+ }
163
+
164
+ /** Lexical normalization is pure; the store adds canonical symlink resources. */
165
+ export function normalizePermissionPath(path: string): string {
166
+ const absolute = path.startsWith("/");
167
+ const parts: string[] = [];
168
+ for (const part of path.split("/")) {
169
+ if (!part || part === ".") continue;
170
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== "..") parts.pop();
171
+ else if (part === ".." && absolute) continue;
172
+ else parts.push(part);
173
+ }
174
+ return (absolute ? "/" : "") + parts.join("/") || ".";
175
+ }
176
+
177
+ /** Dynamic programming avoids exponential regexp backtracking on model input. */
178
+ export function permissionPathMatches(pattern: string, path: string): boolean {
179
+ const glob = normalizePermissionPath(pattern), value = normalizePermissionPath(path);
180
+ let row = Array<boolean>(value.length + 1).fill(false); row[0] = true;
181
+ for (let i = 0; i < glob.length; i++) {
182
+ const char = glob[i]!;
183
+ const next = Array<boolean>(value.length + 1).fill(false);
184
+ if (char === "*") {
185
+ const deep = glob[i + 1] === "*";
186
+ if (deep) i++;
187
+ // **/ also matches zero directories, as in **/.env at the root.
188
+ const directories = deep && glob[i + 1] === "/";
189
+ if (directories) i++;
190
+ next[0] = row[0]!;
191
+ for (let j = 1; j <= value.length; j++) {
192
+ next[j] = row[j]! || ((deep || value[j - 1] !== "/") && next[j - 1]!);
193
+ if (directories && value[j - 1] !== "/") next[j] = row[j]!;
194
+ }
195
+ // A globstar followed by slash may consume complete directories only.
196
+ if (directories) {
197
+ let reachable = row[0]!;
198
+ for (let j = 1; j <= value.length; j++) {
199
+ reachable = reachable || row[j]!;
200
+ next[j] = row[j]! || (value[j - 1] === "/" && reachable);
201
+ }
202
+ }
203
+ } else {
204
+ for (let j = 1; j <= value.length; j++) next[j] = row[j - 1]! &&
205
+ (char === "?" ? value[j - 1] !== "/" : char === value[j - 1]);
206
+ }
207
+ row = next;
208
+ }
209
+ return row[value.length]!;
210
+ }
211
+
212
+ /**
213
+ * This is a prefix matcher, never a command-danger classifier. Unknown shell
214
+ * syntax fails to match and therefore retains the full-command ask (D13).
215
+ */
216
+ export function literalCommandWords(command: string): string[] | null {
217
+ const words: string[] = [];
218
+ let word = "", started = false, quote: "'" | '"' | null = null;
219
+ for (let i = 0; i < command.length; i++) {
220
+ const char = command[i]!;
221
+ if (char === "\n" || char === "\r" || char === "\0") return null;
222
+ if (quote === "'") {
223
+ if (char === "'") quote = null; else word += char;
224
+ continue;
225
+ }
226
+ if (char === "\\") {
227
+ const escaped = command[++i];
228
+ if (escaped === undefined || escaped === "\n" || escaped === "\r") return null;
229
+ // Bash double quotes retain backslash before ordinary characters.
230
+ word += quote === '"' && !['$', '`', '"', "\\"].includes(escaped) ? `\\${escaped}` : escaped;
231
+ started = true; continue;
232
+ }
233
+ if (quote === '"') {
234
+ if (char === '"') quote = null;
235
+ else if (char === "$" || char === "`") return null;
236
+ else word += char;
237
+ continue;
238
+ }
239
+ if (char === "'" || char === '"') { quote = char; started = true; continue; }
240
+ if (char === " " || char === "\t") { if (started) words.push(word); word = ""; started = false; continue; }
241
+ if (";|&<>($`*?[]{}~#".includes(char)) return null;
242
+ word += char; started = true;
243
+ }
244
+ if (quote !== null) return null;
245
+ if (started) words.push(word);
246
+ return words.length ? words : null;
247
+ }
248
+
249
+ /** Unknown config keys are errors: a typo must not silently weaken a policy. */
250
+ export function parsePermissionRule(value: unknown): PermissionRule {
251
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("permission rule must be an object");
252
+ const rule = value as Record<string, unknown>;
253
+ if (Object.keys(rule).some(key => !["action", "tool", "command", "commandPrefix", "path", "pathGlob"].includes(key))) throw Error("unknown permission rule field");
254
+ if (!["allow", "ask", "deny"].includes(String(rule["action"]))) throw Error("permission rule action must be allow, ask or deny");
255
+ for (const key of ["tool", "command", "path", "pathGlob"]) {
256
+ if (rule[key] !== undefined && (typeof rule[key] !== "string" || !String(rule[key]).trim() || String(rule[key]).includes("\0"))) throw Error(`permission ${key} must be a nonempty string`);
257
+ }
258
+ if (rule["path"] !== undefined && rule["pathGlob"] !== undefined) throw Error("permission path and pathGlob are mutually exclusive");
259
+ if (typeof rule["pathGlob"] === "string" && /[\[\]{}]/.test(rule["pathGlob"])) throw Error("permission pathGlob supports only *, ? and **");
260
+ const prefix = rule["commandPrefix"];
261
+ if (prefix !== undefined && (!Array.isArray(prefix) || prefix.length === 0 || prefix.some(word => typeof word !== "string" || !word || /[\r\n\0]/.test(word)))) throw Error("permission commandPrefix must contain literal argv tokens");
262
+ if (prefix !== undefined && rule["command"] !== undefined) throw Error("permission command and commandPrefix are mutually exclusive");
263
+ return structuredClone(value) as PermissionRule;
264
+ }
265
+
124
266
  /**
125
267
  * Comma-separated flag values. Empty tokens are dropped so `--tools a,,b`
126
268
  * does not invent a blank name.
@@ -180,12 +322,15 @@ export function selectTools<T extends { name: string; source?: ToolSource }>(
180
322
  }
181
323
 
182
324
  export function permissionRequest(name: string, input: unknown): PermissionRequest {
183
- if (!["bash", "exec_command", "environment"].includes(name) || typeof input !== "object" || input === null) {
325
+ if (typeof input !== "object" || input === null) {
184
326
  return { tool: name };
185
327
  }
186
- const command = (input as { readonly [k: string]: unknown })[name === "exec_command" ? "cmd" : "command"];
187
- if (typeof command !== "string") return { tool: name };
188
- return { tool: name, command };
328
+ const fields = input as { readonly [k: string]: unknown };
329
+ const command = ["bash", "exec_command", "environment"].includes(name) ? fields[name === "exec_command" ? "cmd" : "command"] : undefined;
330
+ const paths = typeof fields["path"] === "string" ? [fields["path"]]
331
+ : Array.isArray(fields["paths"]) && fields["paths"].every(path => typeof path === "string") ? fields["paths"] as string[] : undefined;
332
+ return { tool: name, ...(typeof command === "string" ? { command } : {}),
333
+ ...(paths ? { paths: paths.map(normalizePermissionPath) } : {}) };
189
334
  }
190
335
 
191
336
  /**