zigmini 1.0.0-beta.3 → 1.1.0-beta.2

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 (138) hide show
  1. package/README.ko.md +29 -2
  2. package/README.md +29 -2
  3. package/assets/agent/gateway-request-rules.md +9 -2
  4. package/assets/agent/gateway-rules.md +13 -6
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * 294 item 6: confined code execution for registered agents (plan C6).
3
+ *
4
+ * eval runs only in the engine's existing QuickJS cell (kernels/sandbox/sandbox-cell.ts) with the sandbox forced on.
5
+ * Every tool the cell calls goes through the agent authority first. bash/powershell/command monitors run under
6
+ * sandbox-exec with agent-execution.sb, in a work root that holds only copies of files the authority allowed.
7
+ * The executor never writes to the host outside that root and never calls a settings or permission API.
8
+ */
9
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync } from "node:fs";
10
+ import { dirname, isAbsolute, join, normalize } from "node:path";
11
+ import type { AuthorizationOutcome } from "../../src/agent/tool-permission-types.ts";
12
+ import { SANDBOX_CELL_MODULE } from "../../src/engine/sandbox-cell.ts";
13
+
14
+ export const SANDBOX_PROFILE = join(import.meta.dir, "agent-execution.sb");
15
+ export const EVAL_MEMORY_MB = 64;
16
+ export const EVAL_TIMEOUT_SECONDS = 300;
17
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
18
+ const BASH = "/bin/bash";
19
+
20
+ export interface AuthorityCall { readonly toolName: string; readonly args: unknown; readonly operation: string }
21
+ export type Authorize = (call: AuthorityCall) => AuthorizationOutcome | Promise<AuthorizationOutcome>;
22
+ export interface ToolResult { readonly content: readonly unknown[]; readonly details?: unknown }
23
+ export type ExecuteTool = (toolName: string, params: unknown, options?: { signal?: AbortSignal }) => Promise<ToolResult>;
24
+ export interface ResolvedSandbox { readonly enabled: boolean; readonly memoryMb: number; readonly timeoutSeconds: number }
25
+ export type Shell = "bash" | "powershell";
26
+
27
+ /** The cell's limits: always isolated, never above the engine's 64 MiB / 300 s defaults. A model cannot turn it off. */
28
+ export function forcedSandbox(requested: Partial<ResolvedSandbox> = {}): ResolvedSandbox {
29
+ const memory = requested.memoryMb ?? EVAL_MEMORY_MB;
30
+ const timeout = requested.timeoutSeconds ?? EVAL_TIMEOUT_SECONDS;
31
+ return {
32
+ enabled: true,
33
+ memoryMb: memory > 0 ? Math.min(memory, EVAL_MEMORY_MB) : EVAL_MEMORY_MB,
34
+ timeoutSeconds: timeout > 0 ? Math.min(timeout, EVAL_TIMEOUT_SECONDS) : EVAL_TIMEOUT_SECONDS,
35
+ };
36
+ }
37
+
38
+ function denied(outcome: AuthorizationOutcome): Error | null {
39
+ return outcome.decision === "allow" ? null : new Error(`agent_tool_denied:${outcome.reason}`);
40
+ }
41
+
42
+ /** Wraps the engine's executeTool so every proxied call passes the agent authority right before it runs. */
43
+ export function authorizedExecuteTool(authorize: Authorize, executeTool: ExecuteTool): ExecuteTool {
44
+ return async (toolName, params, options) => {
45
+ const error = denied(await authorize({ toolName, args: params, operation: toolName }));
46
+ if (error) throw error;
47
+ return executeTool(toolName, params, options);
48
+ };
49
+ }
50
+
51
+ /** A copy target must be a plain relative path inside the work root. */
52
+ export function checkCopyTarget(target: string): string {
53
+ const clean = normalize(target);
54
+ if (target.length === 0 || target.includes("\0") || isAbsolute(target) || clean === "." || target.split("/").includes(".."))
55
+ throw new Error("agent_exec_copy_target");
56
+ return clean;
57
+ }
58
+
59
+ export interface CopyRequest { readonly source: string; readonly target: string }
60
+
61
+ /** Creates a fresh work root under `parent` and copies only the files the authority allows for read. */
62
+ export async function prepareWorkRoot(input: { parent: string; files: readonly CopyRequest[]; authorize: Authorize }): Promise<string> {
63
+ const targets = input.files.map((file) => checkCopyTarget(file.target));
64
+ for (const file of input.files) {
65
+ const error = denied(await input.authorize({ toolName: "read", args: { path: file.source }, operation: "read" }));
66
+ if (error) throw error;
67
+ }
68
+ const root = realpathSync(mkdtempSync(join(input.parent, "zgw-agent-exec-")));
69
+ input.files.forEach((file, index) => {
70
+ const destination = join(root, targets[index]!);
71
+ mkdirSync(dirname(destination), { recursive: true });
72
+ copyFileSync(file.source, destination);
73
+ });
74
+ return root;
75
+ }
76
+
77
+ /** The only variables a confined subprocess sees. No gateway token or runtime home is inherited. */
78
+ export function sandboxEnv(root: string): Record<string, string> {
79
+ return { HOME: root, TMPDIR: root, PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8" };
80
+ }
81
+
82
+ function checkRoot(root: string): string {
83
+ if (!isAbsolute(root) || root.split("/").includes("..") || normalize(root) !== root) throw new Error("agent_exec_root");
84
+ return root;
85
+ }
86
+
87
+ export function sandboxArgs(input: { root: string; shell: Shell; script: string; powershellPath?: string }): string[] {
88
+ const root = checkRoot(input.root);
89
+ let interpreter = BASH;
90
+ let invoke = ["-c"];
91
+ if (input.shell === "powershell") {
92
+ if (!input.powershellPath) throw new Error("agent_exec_powershell_missing");
93
+ interpreter = input.powershellPath;
94
+ invoke = ["-NoProfile", "-NonInteractive", "-Command"];
95
+ }
96
+ return [SANDBOX_EXEC, "-f", SANDBOX_PROFILE, "-D", `WORK_ROOT=${root}`, "-D", `INTERPRETER=${interpreter}`,
97
+ "-D", `INTERPRETER_DIR=${dirname(interpreter)}`, interpreter, ...invoke, input.script];
98
+ }
99
+
100
+ /** The execution handle shape the engine's process sessions provide; Bun.spawn by default. */
101
+ export interface ExecHandle { readonly stdout: ReadableStream<Uint8Array>; readonly stderr: ReadableStream<Uint8Array>; readonly exited: Promise<number>; kill(): void }
102
+ export type Spawn = (argv: readonly string[], options: { cwd: string; env: Record<string, string> }) => ExecHandle;
103
+ const bunSpawn: Spawn = (argv, options) => {
104
+ const child = Bun.spawn([...argv], { cwd: options.cwd, env: options.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
105
+ return { stdout: child.stdout, stderr: child.stderr, exited: child.exited, kill: () => child.kill("SIGKILL") };
106
+ };
107
+
108
+ export interface RestrictedResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; readonly timedOut: boolean }
109
+
110
+ export async function runRestricted(input: {
111
+ root: string; shell: Shell; script: string; powershellPath?: string; timeoutMs?: number; signal?: AbortSignal; spawn?: Spawn;
112
+ }): Promise<RestrictedResult> {
113
+ const argv = sandboxArgs(input);
114
+ const handle = (input.spawn ?? bunSpawn)(argv, { cwd: input.root, env: sandboxEnv(input.root) });
115
+ let timedOut = false;
116
+ const timer = setTimeout(() => { timedOut = true; handle.kill(); }, input.timeoutMs ?? EVAL_TIMEOUT_SECONDS * 1_000);
117
+ const onAbort = () => handle.kill();
118
+ input.signal?.addEventListener("abort", onAbort, { once: true });
119
+ try {
120
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(handle.stdout).text(), new Response(handle.stderr).text(), handle.exited]);
121
+ return { exitCode, stdout, stderr, timedOut };
122
+ } finally {
123
+ clearTimeout(timer);
124
+ input.signal?.removeEventListener("abort", onAbort);
125
+ }
126
+ }
127
+
128
+ export interface EvalResult { readonly ok: boolean; readonly output: string; readonly valueRepr?: string; readonly error?: { name: string; message: string } }
129
+
130
+ /** Runs a cell in the engine's existing QuickJS sandbox. No host kernel fallback. */
131
+ export async function runAgentEval(input: {
132
+ code: string; engineRoot: string; toolNames: () => readonly string[]; executeTool: ExecuteTool; authorize: Authorize;
133
+ requested?: Partial<ResolvedSandbox>; signal?: AbortSignal;
134
+ }): Promise<EvalResult> {
135
+ const cellModule = join(input.engineRoot, SANDBOX_CELL_MODULE);
136
+ if (!existsSync(cellModule)) throw new Error("agent_eval_engine_missing");
137
+ const { sandboxCellExecutor } = await import(cellModule);
138
+ const executor = sandboxCellExecutor(input.code, {
139
+ sandbox: forcedSandbox(input.requested),
140
+ executeTool: authorizedExecuteTool(input.authorize, input.executeTool),
141
+ toolNames: input.toolNames,
142
+ });
143
+ const output: string[] = [];
144
+ const outcome = await executor({
145
+ signal: input.signal ?? new AbortController().signal,
146
+ emit: (message: { type: string; data?: string }) => { if (message.type === "text" && message.data) output.push(message.data); },
147
+ });
148
+ return { ok: outcome.ok, output: output.join(""), valueRepr: outcome.valueRepr, error: outcome.error };
149
+ }
150
+
151
+ /** Moves one exact temp folder to the macOS Trash. Relative paths and '..' are refused. */
152
+ export async function moveToTrash(path: string): Promise<void> {
153
+ checkRoot(path);
154
+ if (!existsSync(path)) return;
155
+ const child = Bun.spawn(["/usr/bin/trash", path], { stdout: "ignore", stderr: "pipe" });
156
+ const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
157
+ if (code !== 0) throw new Error(`agent_exec_trash_failed:${code}:${stderr.trim()}`);
158
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * 294 item 8: resource scope checks for the remaining tool classes (plan C1/C6/C8/C10, interfaces I04-I12).
3
+ *
4
+ * The catalog (src/agent/tool-catalog.ts) decides whether a tool and operation are granted at all. This module adds the
5
+ * resource half at the real adapter boundary: each restriction adapter turns the call arguments into exact resources
6
+ * (`path:`, `origin:`, `provider:`, `mcp:`, `session:`, `child:`, `exec:`, `schedule:`, `memory:`, `args:`) and an active
7
+ * grant must cover every one of them. `run` executes the real tool only after the check and hands it a guard that
8
+ * re-checks redirects, resolved paths and nested tool calls with the same grants.
9
+ *
10
+ * Order (C10): access state -> lifecycle/authority epoch -> exact permission source and resources -> external action
11
+ * ticket -> fence at execution. No source overrides an earlier deny. Protected targets (gateway origins, loopback,
12
+ * the permission ledger and runtime/settings roots) are refused before any grant is looked at.
13
+ *
14
+ * Indirect execution (exec tool proxy, MCP proxy, child, scheduled run) uses `ceiling`: the effective grants are the
15
+ * intersection of the agent's own grants and the ceiling, never more than either side.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { realpathSync } from "node:fs";
19
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
20
+ import { authorizeTool, describeTool } from "../../src/agent/tool-catalog.ts";
21
+ import type { AgentAccessView, DenyReason, RegisteredToolMeta, ToolDescriptor, ToolGrant } from "../../src/agent/tool-permission-types.ts";
22
+ import { isProtectedUrl, originOf } from "../../src/agent/aside-targets.ts";
23
+
24
+ /** A grant with the resources the operator confirmed. `path:` entries cover their subtree; every other entry is exact. */
25
+ export interface ScopedGrant extends ToolGrant {
26
+ readonly resources: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * The adapter grants from the real ledger rows (AgentToolPermissionStore.policyGrants). A legacy row without stored
31
+ * resources (null) gets none: it still covers resource-free classes as before, but every scoped class denies it.
32
+ */
33
+ export function ledgerGrants(rows: readonly (ToolGrant & { readonly resources?: readonly string[] | null })[]): ScopedGrant[] {
34
+ return rows.map((row) => ({
35
+ id: row.id, agentId: row.agentId, incarnation: row.incarnation, authorityEpoch: row.authorityEpoch, toolKey: row.toolKey,
36
+ operations: row.operations, state: row.state, revision: row.revision, resources: row.resources ?? [],
37
+ }));
38
+ }
39
+
40
+ /** A 293 selected attachment copy (I04/X1/X9). Its own authority, not a persistent tool grant. */
41
+ export interface SelectedAttachment {
42
+ readonly attachmentId: string;
43
+ readonly recipientAgentId: string;
44
+ readonly authorityEpoch: number;
45
+ }
46
+
47
+ export interface AdapterContext {
48
+ readonly view: AgentAccessView;
49
+ readonly role: "agent" | "work";
50
+ readonly grants: readonly ScopedGrant[];
51
+ /** Indirect execution: effective grants are the intersection with these. null for a direct call. */
52
+ readonly ceiling: readonly ScopedGrant[] | null;
53
+ /** The agent's work root; relative paths resolve against it. */
54
+ readonly root: string;
55
+ /** The agent's own memory folder. Memory tools write only below it. */
56
+ readonly memoryRoot: string;
57
+ readonly sessionId: string;
58
+ readonly owned: { readonly children: readonly string[]; readonly sessions: readonly string[]; readonly execs: readonly string[] };
59
+ readonly attachments: readonly SelectedAttachment[];
60
+ /** Resolves the inner tool and arguments of an MCP proxy call. null when it cannot be resolved. */
61
+ readonly resolveProxy: (args: Record<string, unknown>) => { tool: RegisteredToolMeta; args: Record<string, unknown> } | null;
62
+ /** Consumes the one-shot external action ticket for this exact argument digest (C8). */
63
+ readonly consumeTicket: (agentId: string, argsDigest: string) => boolean;
64
+ }
65
+
66
+ export interface AdapterDeps {
67
+ readonly gatewayOrigins: readonly string[];
68
+ /** Ledger, runtime home, secrets and engine settings. Never readable or writable through any adapter. */
69
+ readonly protectedRoots: readonly string[];
70
+ readonly realpath?: (path: string) => string;
71
+ }
72
+
73
+ export interface ToolCall {
74
+ readonly tool: RegisteredToolMeta;
75
+ readonly operation: string;
76
+ readonly args: Record<string, unknown>;
77
+ }
78
+
79
+ export type AdapterDeny = DenyReason | "scope_not_granted" | "protected_path" | "protected_origin" | "invalid_target" | "not_owned"
80
+ | "full_access_child" | "action_ticket_required" | "user_answer_proxy" | "adapter_elsewhere" | "nesting_too_deep";
81
+
82
+ export type AdapterOutcome =
83
+ | { readonly decision: "allow"; readonly grantId: string | null; readonly resources: readonly string[]; readonly descriptor: ToolDescriptor }
84
+ | { readonly decision: "deny"; readonly reason: AdapterDeny }
85
+ | { readonly decision: "hold"; readonly reason: "tool_changed" };
86
+
87
+ export class AdapterDenied extends Error {
88
+ constructor(readonly reason: AdapterDeny) {
89
+ super(`agent_tool_denied:${reason}`);
90
+ }
91
+ }
92
+
93
+ const MAX_DEPTH = 4;
94
+ const SPAWN = new Set(["task", "task_create", "workflow", "workpool", "team_create"]);
95
+ const SCHEDULE = new Set(["schedule_wakeup", "schedule_prompt"]);
96
+
97
+ function canonical(value: unknown): unknown {
98
+ if (Array.isArray(value)) return value.map(canonical);
99
+ if (value && typeof value === "object")
100
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical((value as Record<string, unknown>)[key])]));
101
+ return value;
102
+ }
103
+
104
+ export const argsDigest = (args: unknown) => createHash("sha256").update(JSON.stringify(canonical(args ?? null))).digest("hex");
105
+
106
+ /** Real path of a file that may not exist yet: the nearest existing parent is resolved and the rest appended. */
107
+ export function realTarget(path: string): string {
108
+ try {
109
+ return realpathSync(path);
110
+ } catch {
111
+ const parent = dirname(path);
112
+ if (parent === path) return path;
113
+ return resolve(realTarget(parent), basename(path));
114
+ }
115
+ }
116
+
117
+ const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
118
+
119
+ /** True when `have` covers `want`: a `path:` entry covers its subtree, every other kind must match exactly. */
120
+ export function covers(have: string, want: string): boolean {
121
+ if (have.startsWith("path:") && want.startsWith("path:")) return within(want.slice(5), have.slice(5));
122
+ return have === want;
123
+ }
124
+
125
+ const sameKey = (a: ToolGrant, b: ToolGrant) =>
126
+ a.toolKey.sourceKind === b.toolKey.sourceKind && a.toolKey.ownerId === b.toolKey.ownerId
127
+ && a.toolKey.originalName === b.toolKey.originalName && a.toolKey.registrationDigest === b.toolKey.registrationDigest;
128
+
129
+ /** Only what both sides allow: same tool registration, shared operations, and the narrower of each resource pair. */
130
+ export function intersectGrants(own: readonly ScopedGrant[], ceiling: readonly ScopedGrant[]): ScopedGrant[] {
131
+ const out: ScopedGrant[] = [];
132
+ for (const grant of own) {
133
+ for (const limit of ceiling) {
134
+ if (!sameKey(grant, limit)) continue;
135
+ const operations = grant.operations.filter((operation) => limit.operations.includes(operation));
136
+ const resources = [...new Set([
137
+ ...grant.resources.filter((r) => limit.resources.some((l) => covers(l, r))),
138
+ ...limit.resources.filter((l) => grant.resources.some((r) => covers(r, l))),
139
+ ])];
140
+ if (operations.length > 0 && resources.length > 0) out.push({ ...grant, operations, resources });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** Child grants (C1 sub-task row): the parent's current effective grants limited to the requested tool names. */
147
+ export function childGrants(parent: readonly ScopedGrant[], requestedTools: readonly string[]): ScopedGrant[] {
148
+ return parent.filter((grant) => requestedTools.includes(grant.toolKey.originalName));
149
+ }
150
+
151
+ const str = (value: unknown) => (typeof value === "string" && value.length > 0 && !value.includes("\0") ? value : null);
152
+ const list = (value: unknown) => (Array.isArray(value) ? value : value === undefined ? [] : [value]);
153
+
154
+ /** Every target path of an apply_patch body. */
155
+ export function patchPaths(input: string): string[] {
156
+ const out: string[] = [];
157
+ for (const line of input.split("\n")) {
158
+ const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) ?? /^\*\*\* Move to: (.+)$/.exec(line);
159
+ if (match?.[1]) out.push(match[1].trim());
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** Local file paths a show_html_page document embeds. */
165
+ export function htmlPaths(html: string): string[] {
166
+ return [...html.matchAll(/\bsrc\s*=\s*["'](\/[^"']+)["']/g)].map((match) => match[1] as string);
167
+ }
168
+
169
+ type Resolved = { readonly resources: string[] } | { readonly deny: AdapterDeny };
170
+
171
+ export class AgentToolAdapters {
172
+ private readonly realpath: (path: string) => string;
173
+
174
+ constructor(private readonly deps: AdapterDeps) {
175
+ this.realpath = deps.realpath ?? realTarget;
176
+ }
177
+
178
+ /** One canonical `path:` resource, or a deny for protected or malformed targets. Checked lexically and after symlinks. */
179
+ path(ctx: AdapterContext, raw: unknown): Resolved {
180
+ const value = str(raw);
181
+ if (value === null) return { deny: "invalid_target" };
182
+ const lexical = isAbsolute(value) ? resolve(value) : resolve(ctx.root, value);
183
+ const real = this.realpath(lexical);
184
+ const roots = this.deps.protectedRoots.flatMap((root) => [root, this.realpath(root)]);
185
+ if (roots.some((root) => within(lexical, root) || within(real, root))) return { deny: "protected_path" };
186
+ return { resources: [`path:${real}`] };
187
+ }
188
+
189
+ private paths(ctx: AdapterContext, raws: readonly unknown[]): Resolved {
190
+ if (raws.length === 0) return { deny: "invalid_target" };
191
+ const resources: string[] = [];
192
+ for (const raw of raws) {
193
+ const one = this.path(ctx, raw);
194
+ if ("deny" in one) return one;
195
+ resources.push(...one.resources);
196
+ }
197
+ return { resources };
198
+ }
199
+
200
+ url(raw: unknown): Resolved {
201
+ const value = str(raw);
202
+ if (value === null || originOf(value) === null) return { deny: "invalid_target" };
203
+ if (isProtectedUrl(value, this.deps.gatewayOrigins)) return { deny: "protected_origin" };
204
+ return { resources: [`origin:${originOf(value)}`] };
205
+ }
206
+
207
+ /** Resources one call touches, by the descriptor's restriction adapter. */
208
+ resources(ctx: AdapterContext, d: ToolDescriptor, call: ToolCall): Resolved {
209
+ const a = call.args;
210
+ const name = d.key.originalName;
211
+ switch (d.restrictionAdapter) {
212
+ case "path_scope": {
213
+ if (name === "apply_patch") return this.paths(ctx, patchPaths(String(a.input ?? a.patch ?? "")));
214
+ const raws = [...list(a.path), ...list(a.paths), ...list(a.file_path), ...list(a.filePath)];
215
+ return this.paths(ctx, raws.length > 0 ? raws : [ctx.root]);
216
+ }
217
+ case "web_origin": {
218
+ if (a.url !== undefined) return this.url(a.url);
219
+ if (str(a.query ?? a.libraryName ?? a.libraryId) === null) return { deny: "invalid_target" };
220
+ return { resources: [`provider:${d.key.ownerId}/${name}`] };
221
+ }
222
+ case "media_io": {
223
+ const resources: string[] = [];
224
+ for (const raw of [...list(a.path), ...list(a.paths), ...list(a.image), ...list(a.images), ...list(a.file_path)]) {
225
+ const one = typeof raw === "string" && /^https?:/i.test(raw) ? this.url(raw) : this.path(ctx, raw);
226
+ if ("deny" in one) return one;
227
+ resources.push(...one.resources);
228
+ }
229
+ if (a.output !== undefined) {
230
+ const out = this.path(ctx, a.output);
231
+ if ("deny" in out) return out;
232
+ resources.push(...out.resources);
233
+ }
234
+ resources.push(`provider:${d.key.ownerId}/${name}`);
235
+ return { resources };
236
+ }
237
+ case "display_surface": {
238
+ const embedded = htmlPaths(String(a.html ?? ""));
239
+ const files = embedded.length > 0 ? this.paths(ctx, embedded) : { resources: [] as string[] };
240
+ return "deny" in files ? files : { resources: [...files.resources, "display:self"] };
241
+ }
242
+ case "mcp_resource": {
243
+ const server = str(a.server);
244
+ return server === null ? { deny: "invalid_target" } : { resources: [`mcp:${server}`] };
245
+ }
246
+ case "self_identity": {
247
+ if (call.operation === "read_self") return { resources: [`memory:${ctx.view.agentId}`] };
248
+ const target = this.path(ctx, a.file_path ?? a.path ?? ctx.memoryRoot);
249
+ if ("deny" in target) return target;
250
+ const memory = this.realpath(resolve(ctx.memoryRoot));
251
+ if (!within((target.resources[0] as string).slice(5), memory)) return { deny: "not_owned" };
252
+ return { resources: [`memory:${ctx.view.agentId}`] };
253
+ }
254
+ case "owned_session": {
255
+ if (name === "thread_answer") return { deny: "user_answer_proxy" };
256
+ if (name === "thread_create" || name === "thread_list" || name === "thread_bindings") return { resources: [`session:${name}`] };
257
+ const id = str(a.thread_id ?? a.threadId ?? a.session_id ?? a.target);
258
+ if (id === null) return { deny: "invalid_target" };
259
+ return ctx.owned.sessions.includes(id) ? { resources: [`session:${id}`] } : { deny: "not_owned" };
260
+ }
261
+ case "owned_child": {
262
+ if (SPAWN.has(name)) {
263
+ if (a.preset === "full-access" || a.fullAccess === true || a.permission === "full-access") return { deny: "full_access_child" };
264
+ return { resources: ["child:new"] };
265
+ }
266
+ const id = str(a.task_id ?? a.taskId ?? a.name ?? a.team);
267
+ if (id === null) return { deny: "invalid_target" };
268
+ return ctx.owned.children.includes(id) ? { resources: [`child:${id}`] } : { deny: "not_owned" };
269
+ }
270
+ case "self_state": {
271
+ if (!SCHEDULE.has(name)) return { resources: [`self:${ctx.sessionId}`] };
272
+ const target = a.target ?? a.session ?? ctx.sessionId;
273
+ return target === ctx.sessionId ? { resources: [`schedule:${ctx.sessionId}`] } : { deny: "not_owned" };
274
+ }
275
+ case "restricted_exec": {
276
+ if (name === "monitor" && a.path !== undefined) return this.path(ctx, a.path);
277
+ return { resources: [`exec:${ctx.root}`] };
278
+ }
279
+ case "owned_exec_handle": {
280
+ const id = str(a.bash_id ?? a.id);
281
+ if (id === null) return { deny: "invalid_target" };
282
+ return ctx.owned.execs.includes(id) ? { resources: [`exec-handle:${id}`] } : { deny: "not_owned" };
283
+ }
284
+ case "exact_args":
285
+ return { resources: [`args:${argsDigest(a)}`] };
286
+ case "metadata_only":
287
+ case "own_history":
288
+ case "role_only":
289
+ return { resources: [] };
290
+ case "browser_target":
291
+ return { deny: "adapter_elsewhere" };
292
+ default:
293
+ return { deny: "not_grantable" };
294
+ }
295
+ }
296
+
297
+ /** The full check for one call. Never executes anything. */
298
+ authorize(ctx: AdapterContext, call: ToolCall, depth = 0): AdapterOutcome {
299
+ if (depth > MAX_DEPTH) return { decision: "deny", reason: "nesting_too_deep" };
300
+ const { view } = ctx;
301
+ if (view.accessState !== "open") return { decision: "deny", reason: "agent_access_blocked" };
302
+ const descriptor = describeTool(call.tool);
303
+ if (descriptor.restrictionAdapter === "mcp_proxy") {
304
+ const inner = ctx.resolveProxy(call.args);
305
+ if (inner === null) return { decision: "deny", reason: "proxy_target_unresolved" };
306
+ return this.authorize(ctx, { tool: inner.tool, operation: call.operation, args: inner.args }, depth + 1);
307
+ }
308
+ if (descriptor.restrictionAdapter === "attachment_selection") return this.attachment(ctx, call, descriptor);
309
+ const resolved = this.resources(ctx, descriptor, call);
310
+ if ("deny" in resolved) return { decision: "deny", reason: resolved.deny };
311
+ const effective = ctx.ceiling === null ? ctx.grants : intersectGrants(ctx.grants, ctx.ceiling);
312
+ const candidates = effective.filter((g) => g.toolKey.sourceKind === descriptor.key.sourceKind && g.toolKey.ownerId === descriptor.key.ownerId
313
+ && g.toolKey.originalName === descriptor.key.originalName);
314
+ let last: AdapterOutcome = { decision: "deny", reason: "no_grant" };
315
+ if (candidates.length === 0) {
316
+ const base = authorizeTool({ descriptor, grant: null, access: view, operation: call.operation, role: ctx.role });
317
+ if (base.decision !== "allow") return base;
318
+ return this.ticket(ctx, call, { decision: "allow", grantId: null, resources: resolved.resources, descriptor });
319
+ }
320
+ for (const grant of candidates) {
321
+ const base = authorizeTool({ descriptor, grant, access: view, operation: call.operation, role: ctx.role });
322
+ if (base.decision !== "allow") {
323
+ last = base;
324
+ continue;
325
+ }
326
+ if (!this.covered(grant, resolved.resources)) {
327
+ last = { decision: "deny", reason: "scope_not_granted" };
328
+ continue;
329
+ }
330
+ return this.ticket(ctx, call, { decision: "allow", grantId: grant.id, resources: resolved.resources, descriptor });
331
+ }
332
+ return last;
333
+ }
334
+
335
+ /** Grant `path:` entries are compared by their real path too, like the requested targets. */
336
+ private covered(grant: ScopedGrant, wants: readonly string[]): boolean {
337
+ const haves = grant.resources.map((have) => (have.startsWith("path:") ? `path:${this.realpath(have.slice(5))}` : have));
338
+ return wants.every((want) => haves.some((have) => covers(have, want)));
339
+ }
340
+
341
+ private attachment(ctx: AdapterContext, call: ToolCall, descriptor: ToolDescriptor): AdapterOutcome {
342
+ const id = str(call.args.attachmentId ?? call.args.attachment_id);
343
+ if (id === null) return { decision: "deny", reason: "invalid_target" };
344
+ const ok = ctx.attachments.some((s) => s.attachmentId === id && s.recipientAgentId === ctx.view.agentId && s.authorityEpoch === ctx.view.authorityEpoch);
345
+ return ok ? { decision: "allow", grantId: null, resources: [`attachment:${id}`], descriptor } : { decision: "deny", reason: "no_grant" };
346
+ }
347
+
348
+ /** External effects need the one-shot ticket for the exact arguments, on top of the tool grant (C8). */
349
+ private ticket(ctx: AdapterContext, call: ToolCall, allow: AdapterOutcome & { decision: "allow" }): AdapterOutcome {
350
+ if (allow.descriptor.effect !== "external_write") return allow;
351
+ return ctx.consumeTicket(ctx.view.agentId, argsDigest(call.args)) ? allow : { decision: "deny", reason: "action_ticket_required" };
352
+ }
353
+
354
+ /**
355
+ * Authorize, then run the real tool once. The guard re-checks every later target with the same context: redirect
356
+ * hops, resolved paths, nested tool calls (intersected with the effective grants), and child grant sets. A call allowed
357
+ * without a grant (bootstrap, selected attachment) has no scope, so the guard refuses every later path or URL.
358
+ */
359
+ async run<T>(ctx: AdapterContext, call: ToolCall, execute: (args: Record<string, unknown>, guard: AdapterGuard) => Promise<T>, depth = 0): Promise<T> {
360
+ const outcome = this.authorize(ctx, call, depth);
361
+ if (outcome.decision === "hold") throw new AdapterDenied("grant_not_active");
362
+ if (outcome.decision === "deny") throw new AdapterDenied(outcome.reason);
363
+ const effective = ctx.ceiling === null ? ctx.grants : intersectGrants(ctx.grants, ctx.ceiling);
364
+ const scope = outcome.grantId === null ? null : effective.find((g) => g.id === outcome.grantId) ?? null;
365
+ const guard: AdapterGuard = {
366
+ url: (url) => {
367
+ const one = this.url(url);
368
+ if ("deny" in one) throw new AdapterDenied(one.deny);
369
+ if (scope === null || !this.covered(scope, one.resources)) throw new AdapterDenied("scope_not_granted");
370
+ },
371
+ path: (path) => {
372
+ const one = this.path(ctx, path);
373
+ if ("deny" in one) throw new AdapterDenied(one.deny);
374
+ if (scope === null || !this.covered(scope, one.resources)) throw new AdapterDenied("scope_not_granted");
375
+ },
376
+ nested: (inner, innerExecute) => this.run({ ...ctx, ceiling: effective }, inner, innerExecute, depth + 1),
377
+ child: (requestedTools) => childGrants(effective, requestedTools),
378
+ };
379
+ return execute(call.args, guard);
380
+ }
381
+ }
382
+
383
+ export interface AdapterGuard {
384
+ readonly url: (url: string) => void;
385
+ readonly path: (path: string) => void;
386
+ readonly nested: <U>(call: ToolCall, execute: (args: Record<string, unknown>, guard: AdapterGuard) => Promise<U>) => Promise<U>;
387
+ readonly child: (requestedTools: readonly string[]) => ScopedGrant[];
388
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 294 item 5: the session (bridge) side of a granted agent tool operation. The gateway side (enginePolicy, delivery,
3
+ * recipient check) is src/agent/tool-delivery.ts.
4
+ *
5
+ * - agentToolGatewayClient: the signed /internal/agent-tools/* client, using the shared managed-call proof.
6
+ * - installAgentToolBinding: keeps the rotated bind secret for the session.
7
+ * - execSessionSpawn: item 6 runRestricted over the engine's exec-session handle.
8
+ */
9
+ import { createSecretKey, randomBytes, type KeyObject } from "node:crypto";
10
+ import { parseManagedCallIdentity, type ManagedCallIdentity } from "../../src/agent/managed-call.ts";
11
+ import { runRestricted, type ExecHandle, type RestrictedResult, type Shell, type Spawn } from "./agent-execution.ts";
12
+ import { buildManagedCallProof, type ManagedCallContext } from "./managed-call.ts";
13
+
14
+ export const AGENT_TOOL_ROUTES = {
15
+ request: "/internal/agent-tools/request",
16
+ action: "/internal/agent-tools/action",
17
+ reserve: "/internal/agent-tools/reserve",
18
+ start: "/internal/agent-tools/start",
19
+ result: "/internal/agent-tools/result",
20
+ policy: "/internal/agent-tools/policy",
21
+ policyAck: "/internal/agent-tools/policy-ack",
22
+ } as const;
23
+ export type AgentToolRoute = keyof typeof AGENT_TOOL_ROUTES;
24
+ export const agentToolCall = (toolCallId: string) => `agent-tool:${toolCallId}`;
25
+
26
+ /** Signed client for the internal routes. Identity comes from the trusted binding, never from the body. */
27
+ export function agentToolGatewayClient(input: {
28
+ context: (toolCallId: string) => ManagedCallContext;
29
+ base: string;
30
+ token: () => string;
31
+ fetch?: typeof fetch;
32
+ }) {
33
+ const send = async (route: AgentToolRoute, toolCallId: string, body: unknown): Promise<unknown> => {
34
+ const path = AGENT_TOOL_ROUTES[route];
35
+ const method = route === "policy" ? "GET" : "POST";
36
+ const bytes = new TextEncoder().encode(method === "GET" ? "" : JSON.stringify(body));
37
+ const proof = buildManagedCallProof(input.context(toolCallId), { method, route: path, operation: `agent_tool.${route}`, body: bytes, nonce: randomBytes(32).toString("hex") });
38
+ const response = await (input.fetch ?? fetch)(new URL(path, input.base), {
39
+ method, redirect: "error", ...(method === "GET" ? {} : { body: bytes }),
40
+ headers: { authorization: `Bearer ${input.token()}`, "content-type": "application/json", "x-zigmini-managed-call": Buffer.from(JSON.stringify(proof)).toString("base64") },
41
+ });
42
+ const result: unknown = await response.json();
43
+ if (!response.ok) throw new Error(typeof (result as { error?: unknown })?.error === "string" ? (result as { error: string }).error : `agent_tool_http_${response.status}`);
44
+ return result;
45
+ };
46
+ return { send };
47
+ }
48
+ export type { ManagedCallIdentity };
49
+
50
+ /** The engine's process-session handle (bash/monitor). Item 6 runs the confined command through it. */
51
+ export interface EngineExecSessions {
52
+ spawn(argv: readonly string[], options: { cwd: string; env: Record<string, string>; stdin: "ignore" }): ExecHandle;
53
+ }
54
+ export function execSessionSpawn(sessions: EngineExecSessions): Spawn {
55
+ return (argv, options) => sessions.spawn(argv, { cwd: options.cwd, env: options.env, stdin: "ignore" });
56
+ }
57
+ export function runConfined(sessions: EngineExecSessions, input: { root: string; shell: Shell; script: string; signal?: AbortSignal; timeoutMs?: number }): Promise<RestrictedResult> {
58
+ return runRestricted({ ...input, spawn: execSessionSpawn(sessions) });
59
+ }
60
+
61
+ /** Session side of the Hub bind (zgw.agent_tool_bind): keeps the rotated secret to sign /internal/agent-tools calls. */
62
+ export interface AgentToolBindExtension {
63
+ readonly sessionContext: Readonly<Record<string, string>>;
64
+ readonly rpc: { handle(name: string, handler: (data: unknown) => unknown): void };
65
+ on(event: "session_start" | "session_shutdown", handler: (event: unknown, ctx: { readonly sessionManager: ManagedCallContext["nativeInstance"] }) => unknown): void;
66
+ }
67
+ export function installAgentToolBinding(pi: AgentToolBindExtension): {
68
+ context(toolCallId: string, inputId: string, activationId?: string | null): ManagedCallContext | null;
69
+ /** The bound agent incarnation (lifecycle epoch); null before the Hub bound this session. */
70
+ lifecycleEpoch(): number | null;
71
+ } {
72
+ let manager: ManagedCallContext["nativeInstance"] | undefined;
73
+ let bound: { identity: Omit<ManagedCallIdentity, "inputId" | "toolCallId" | "logicalCallId" | "activationId">; secret: KeyObject } | null = null;
74
+ pi.on("session_start", (_event, ctx) => { manager = ctx.sessionManager; bound = null; });
75
+ pi.on("session_shutdown", () => { manager = undefined; bound = null; });
76
+ pi.rpc.handle("zgw.agent_tool_bind", (data) => {
77
+ const value = data as { secret?: unknown; identity?: Record<string, unknown> } | null;
78
+ if (!value || typeof value.secret !== "string" || !/^[a-f0-9]{64}$/.test(value.secret) || !value.identity || !manager) throw new Error("agent_tool_bind_invalid");
79
+ const identity = parseManagedCallIdentity({ ...value.identity, inputId: "pending", toolCallId: "pending", logicalCallId: "pending", activationId: null });
80
+ if (identity.role !== "agent" || identity.agentId !== pi.sessionContext.zgw_agent || identity.convId !== pi.sessionContext.zgw_conv ||
81
+ identity.durableSessionId !== manager.getSessionId() || identity.sessionPath !== manager.getSessionFile()) throw new Error("agent_tool_bind_mismatch");
82
+ const { inputId: _i, toolCallId: _t, logicalCallId: _l, activationId: _a, ...rest } = identity;
83
+ bound = { identity: rest, secret: createSecretKey(Buffer.from(value.secret, "hex")) };
84
+ return { ok: true };
85
+ });
86
+ return {
87
+ lifecycleEpoch: () => bound?.identity.lifecycleEpoch ?? null,
88
+ context(toolCallId, inputId, activationId = null) {
89
+ const current = bound, native = manager;
90
+ if (!current || !native) return null;
91
+ const identity = parseManagedCallIdentity({ ...current.identity, inputId, toolCallId, logicalCallId: agentToolCall(toolCallId), activationId });
92
+ return { native: { sessionManager: native }, nativeInstance: native, identity, secret: current.secret,
93
+ currentIdentity: () => (bound === current ? identity : null) };
94
+ },
95
+ };
96
+ }