@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.
- package/CHANGELOG.md +20 -0
- package/README.md +41 -10
- package/docs/app-server.md +35 -4
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/providers.md +4 -0
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/preferences.ts +20 -0
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +84 -0
- package/src/apps/shell.ts +88 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +180 -25
- package/src/apps/ui/app.ts +74 -91
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/completion-review.ts +13 -4
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +156 -11
- package/src/core/question.ts +94 -0
- package/src/core/review.ts +68 -0
- package/src/core/settings.ts +1 -1
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +10 -3
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +48 -16
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/resources.ts +13 -22
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/control-panel.ts +7 -5
- package/src/tui/ui/control-sidebar.ts +49 -0
- 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
|
-
...
|
|
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
|
-
(
|
|
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
|
+
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -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;
|
|
7
|
-
* which is why
|
|
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.
|
|
79
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
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 (
|
|
325
|
+
if (typeof input !== "object" || input === null) {
|
|
184
326
|
return { tool: name };
|
|
185
327
|
}
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
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
|
/**
|