privateer-agent 0.11.0 → 0.12.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.
@@ -0,0 +1,71 @@
1
+ // The moat's extension inventory, read from moatManifest.json — one list, four consumers.
2
+ //
3
+ // WHY A MANIFEST. Shipping an extension used to mean four edits in three files that
4
+ // nothing connects: bin/privateer-launch.mjs's MANAGED array (removes the stale shim),
5
+ // its shim() call (installs the new one), extensionsControl's RESERVED set (stops the
6
+ // app managing it as a user package), and the profile's extensionFactories list. Miss
7
+ // one and the failure is silent in a different way each time — a lingering shim pointing
8
+ // at a deleted file, an extension that never loads, or a moat package the app offers to
9
+ // uninstall. The launcher-vs-MANAGED pair had a test (tests/extensionLoad.test.ts) purely
10
+ // to catch the omission; the RESERVED half had none, and had already drifted —
11
+ // privateer-models and privateer-connect were shimmed but never reserved. Deriving all
12
+ // four from one file makes the drift unrepresentable rather than tested-for.
13
+ //
14
+ // WHY JSON. bin/privateer-launch.mjs reads this too, and it runs BEFORE the pi patches
15
+ // are applied, with no bundler and no dependencies — it can parse JSON and nothing else.
16
+ // A .ts manifest would need the launcher to load tsx, which is exactly the ordering the
17
+ // launcher exists to avoid.
18
+ //
19
+ // IMPORT-SAFETY: no Pi imports, no side effects — safe to load from anywhere, including
20
+ // pre-boot and from a jiti-loaded extension (the readFileSync + import.meta.url pattern
21
+ // is the one extensions/privateer-brand.ts already proves under Pi's loader).
22
+
23
+ import { readFileSync } from "node:fs";
24
+
25
+ /**
26
+ * One shipped extension. `entry` is a repo-relative path to a first-party extension;
27
+ * `dep` is a node_modules specifier as [packageName, ...pathSegments], resolved through
28
+ * the node_modules chain at launch (npm hoists, so a fixed path would miss). Exactly one
29
+ * of the two is set.
30
+ */
31
+ export interface MoatShim {
32
+ name: string;
33
+ entry?: string;
34
+ dep?: string[];
35
+ note?: string;
36
+ }
37
+
38
+ interface Manifest {
39
+ shims: MoatShim[];
40
+ // Names we no longer install but still SWEEP from the agent dir on every launch, so a
41
+ // shim written by an older version can't linger and load a package we've since dropped.
42
+ retired: string[];
43
+ // Extra npm names the app must not manage: the scoped published names of packages we
44
+ // shim under a bare alias. A user hand-adding "@juicesharp/rpiv-web-tools" to settings
45
+ // "packages" would otherwise load a second copy alongside the launcher's shim.
46
+ reservedAliases: string[];
47
+ }
48
+
49
+ const manifest: Manifest = JSON.parse(
50
+ readFileSync(new URL("./moatManifest.json", import.meta.url), "utf8"),
51
+ );
52
+
53
+ /** Every extension the launcher installs a shim for, in load order. */
54
+ export const MOAT_SHIMS: readonly MoatShim[] = Object.freeze(manifest.shims);
55
+
56
+ /**
57
+ * Names the launcher clears from the agent dir's extensions/ before installing: everything
58
+ * we ship plus everything we used to ship. The launcher reads the JSON directly (it cannot
59
+ * import TS), so this is the TS-side mirror — tests/extensionLoad.test.ts asserts they agree.
60
+ */
61
+ export function managedNames(): string[] {
62
+ return [...manifest.shims.map((s) => s.name), ...manifest.retired];
63
+ }
64
+
65
+ /**
66
+ * Names the app's extension manager must refuse to add or remove. A superset of the
67
+ * managed names: also the scoped npm names behind our bare aliases.
68
+ */
69
+ export function reservedNames(): string[] {
70
+ return [...managedNames(), ...manifest.reservedAliases];
71
+ }
@@ -13,11 +13,9 @@ import {
13
13
  import { agentDir, configPath } from "../config/paths.ts";
14
14
  import { agentVersion } from "../config/version.ts";
15
15
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
16
- import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
17
- import { makePiPrivacyExtension } from "pi-privacy";
16
+ import { type GateController } from "../ext/permissionGate.ts";
17
+ import { moatResourceOptions } from "../config/moat.ts";
18
18
  import {
19
- makeAccountProvider,
20
- privateerChannel,
21
19
  rememberAccountCredential,
22
20
  dropPersistedAccountCredential,
23
21
  } from "../providers/account.ts";
@@ -33,10 +31,10 @@ import type { Workflow, Step } from "../workflows/schema.ts";
33
31
  import { readRunningPlatforms } from "../channels/status.ts";
34
32
  import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
35
33
  import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
36
- import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
34
+ import { verifyChannelSave } from "../crypto/accountVerify.ts";
37
35
  import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
38
36
  import { authorizeControl } from "../remote/controlAuth.ts";
39
- import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke, defaultDeviceLabel } from "../auth/privateer.ts";
37
+ import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
40
38
  import {
41
39
  loadRoutines,
42
40
  upsertRoutine,
@@ -56,14 +54,14 @@ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigg
56
54
  import { splitRoutineTools } from "../routines/toolSelect.ts";
57
55
  import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
58
56
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
59
- import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
57
+ import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
60
58
  import { redactText, collectSecrets } from "../util/redact.ts";
61
59
  import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
62
60
  import { serializeBuild } from "./buildLock.ts";
63
- import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
64
- import { markHarborDaemon } from "../config/harborDaemon.ts";
65
- import { markInlineMoat } from "../config/inlineMoat.ts";
66
- import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
61
+ import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
62
+ import { WEB_TOOL_NAMES } from "../tools/web.ts";
63
+ import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
64
+ import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
67
65
 
68
66
  // The safe, read-only toolset for unattended runs — Pi builtins with no
69
67
  // write/edit/bash, so a routine firing with nobody watching can't mutate the
@@ -79,23 +77,43 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
79
77
  // the user hand-write an allow-list for it was the whole friction.
80
78
  const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
81
79
 
82
- // Resolve a run's builtin allow-list. An explicit list wins, minus any web tools when
83
- // web access is off — a routine saved while it was on must not silently reference a
84
- // tool that no longer registers.
80
+ // Media generation (src/tools/media.ts) plus local composition (videoCompose.ts).
81
+ //
82
+ // Unlike the web tools these are deliberately NOT in the default allow-list, even with
83
+ // the switch on. Generation writes files and spends real credit — cents an image, up to
84
+ // a dollar a video clip — so an unattended routine gets them only by NAMING them, which
85
+ // makes "this routine can bill me for video" a decision someone made rather than a
86
+ // default they inherited. `media_capabilities` is free and read-only but stays with its
87
+ // siblings: on its own it would be a tool that only ever reports what the run can't do.
88
+ // Composition (video_compose) is deliberately NOT in this list: it is local ffmpeg work
89
+ // with no account, no network and no spend, so it stays grantable even with generation
90
+ // switched off — finishing the clips a previous run produced is exactly when that
91
+ // matters. See MEDIA_ALL for what actually gets registered.
92
+ const MEDIA_GEN_TOOLS: string[] = [...MEDIA_TOOL_NAMES];
93
+ const MEDIA_ALL: string[] = [...MEDIA_TOOL_NAMES, ...COMPOSE_TOOL_NAMES];
94
+
95
+ // Resolve a run's builtin allow-list. An explicit list wins, minus any web or media
96
+ // tools whose switch is off — a routine saved while one was on must not silently
97
+ // reference a tool that no longer registers.
85
98
  function builtinToolsFor(explicit: string[]): string[] {
86
99
  const web = webEnabled();
100
+ const media = mediaEnabled();
87
101
  if (explicit.length > 0) {
88
- return web ? explicit : explicit.filter((t) => !WEB_TOOLS.includes(t));
102
+ return explicit.filter((t) => (web || !WEB_TOOLS.includes(t)) && (media || !MEDIA_GEN_TOOLS.includes(t)));
89
103
  }
90
104
  return web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS];
91
105
  }
92
106
 
107
+ /** Every media tool name a routine may name, for the app's tool picker / docs. */
108
+ export function mediaToolNames(): string[] {
109
+ return mediaEnabled() ? [...MEDIA_ALL] : [...COMPOSE_TOOL_NAMES];
110
+ }
111
+
93
112
  const TICK_MS = 60_000; // scan for due routines once a minute
94
113
  // Harbor hosted mode (isHosted): suspend after this much idle time with no work,
95
114
  // and stay up if a routine is due within the lead window (avoids suspend→wake churn).
96
115
  const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
97
116
  const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
98
- const MAX_CLOUD_PLAINTEXT = 45_000;
99
117
  // How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
100
118
  // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
101
119
  // a stuck graph from pinning a `running` slot forever when the controller wanders off.
@@ -266,8 +284,6 @@ export class Harbor {
266
284
  return "queued";
267
285
  };
268
286
 
269
- private outboxPub?: Uint8Array;
270
-
271
287
  private readonly pushCloud: CloudPusher = async (routine, content, status) => {
272
288
  const at = new Date().toISOString();
273
289
  if (await this.postOutbox(routine.name, at, status, content)) return "sent";
@@ -276,16 +292,6 @@ export class Harbor {
276
292
  };
277
293
 
278
294
  async start(): Promise<void> {
279
- // Mark the process before anything can create a session: the shipped TUI extensions
280
- // are auto-discovered from the shared agent dir into every session we run, and the
281
- // gate one has to stand its relay file tools down here so a live task's own pair
282
- // (bound to that task's live relay) isn't shadowed. See config/harborDaemon.ts.
283
- markHarborDaemon();
284
- // Same reason, one step further: every session this process builds (routines,
285
- // workflows, submitted tasks, live spawns) wires its own makePermissionGate, so
286
- // the discovered gate must not install a second one on top of it. See
287
- // config/inlineMoat.ts for what that collision costs.
288
- markInlineMoat();
289
295
  // Single-instance lock FIRST, before any other side effect: binding the IPC
290
296
  // socket is the machine's mutex. If a live harbor already holds it this throws
291
297
  // HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
@@ -566,55 +572,13 @@ export class Harbor {
566
572
  }
567
573
 
568
574
  // ── Cloud outbox (sealed store-and-forward) ────────────────────────────────
569
-
570
- private async ensureOutboxPub(): Promise<Uint8Array | undefined> {
571
- if (this.outboxPub) return this.outboxPub;
572
- try {
573
- const res = await apiRequest("/api/outbox/pubkey");
574
- if (!res.ok) return undefined;
575
- const data = (await res.json()) as { outboxPublicKey?: string | null; outboxPublicKeySig?: string | null };
576
- if (!data.outboxPublicKey || !data.outboxPublicKeySig) return undefined;
577
- // The key comes from the UNTRUSTED server. Verify the account's signature over it
578
- // against the account signing key we pinned at link — otherwise a malicious server
579
- // could substitute a key it controls and read every result we seal. Fail closed
580
- // (no pin, missing sig, or bad sig ⇒ don't seal): the `cloud` channel then falls
581
- // back to a local notice, so the result is deferred/kept, never leaked.
582
- const accountPub = loadAccountSignKey();
583
- if (!accountPub) return undefined;
584
- if (!verifyOutboxKey(accountPub, data.outboxPublicKey, data.outboxPublicKeySig)) return undefined;
585
- this.outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
586
- return this.outboxPub;
587
- } catch {
588
- return undefined;
589
- }
590
- }
591
-
592
- // This machine's origin tag, embedded (E2EE) in every sealed result so the app can
593
- // show WHICH box/environment produced it — the outbox record itself is account-only,
594
- // so attribution can only live inside the sealed blob (where hostnames are allowed;
595
- // the server never sees it). `id` is this install's stable relay id; `label` is the
596
- // hostname-based device name. Cached — it never changes for the process lifetime.
597
- private originCache?: { id: string; label: string };
598
- private machineOrigin(): { id: string; label: string } {
599
- if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
600
- return this.originCache;
601
- }
575
+ // The sealing itself now lives in ../outbox/cloudOutbox.ts — an interactive
576
+ // remote-drive session needs the same path when the app isn't there to receive a
577
+ // finished turn, and two copies of an E2EE wire format is one too many. Same
578
+ // caches, same fail-closed key verification; this is just the harbor's caller.
602
579
 
603
580
  private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: OutboxKind = "routine"): Promise<boolean> {
604
- const pub = await this.ensureOutboxPub();
605
- if (!pub) return false;
606
- const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
607
- const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
608
- try {
609
- const res = await apiRequest("/api/outbox", {
610
- method: "POST",
611
- headers: { "content-type": "application/json" },
612
- body: JSON.stringify({ sealed }),
613
- });
614
- return res.ok;
615
- } catch {
616
- return false;
617
- }
581
+ return sealToOutbox(name, at, status, content, kind);
618
582
  }
619
583
 
620
584
  private async flushPendingCloud(): Promise<void> {
@@ -790,50 +754,22 @@ export class Harbor {
790
754
  return "deny";
791
755
  },
792
756
  };
793
- // MCP adapter (Phase 5): registers the tools from the shared agent/mcp.json — the
794
- // same projection the app's MCP manager (mcpControl) writes over the relay. No
795
- // servers configured → a no-op. Dynamically imported so it loads only when a
796
- // session actually runs (Pi is already booted by here). The specifier is a
797
- // variable so tsc treats it as Promise<any> and doesn't pull the third-party
798
- // adapter's own .ts into our typecheck — same intent as the desktop's agentImport.
799
- const mcpAdapterSpec = "pi-mcp-adapter";
800
- const { default: mcpAdapter } = await import(mcpAdapterSpec);
757
+ // Every extension this session gets, in the one canonical order — including the MCP
758
+ // adapter (Phase 5), the web/media capability shaping, and the filter that ignores
759
+ // any moat shim an older release left in the shared agent dir (releases up to 0.11
760
+ // installed one per extension; the launcher sweeps them, but a daemon can outlive
761
+ // the launch that would have). See config/moat.ts.
762
+ const moat = await moatResourceOptions({ kind: "harbor-session", gate });
763
+ // MCP_DIRECT_TOOLS is read by the adapter when its factory RUNS, inside
764
+ // createAgentSessionServices — so it has to wrap the session creation, not the
765
+ // moatResourceOptions() call that imported it.
801
766
  const prevDirect = process.env.MCP_DIRECT_TOOLS;
802
767
  process.env.MCP_DIRECT_TOOLS = directTools.length > 0 ? directTools.join(",") : "__none__";
803
768
  try {
804
769
  return await createAgentSessionServices({
805
770
  cwd,
806
771
  agentDir: agentDir(),
807
- resourceLoaderOptions: {
808
- extensionFactories: [
809
- makePermissionGate(gate),
810
- // Per-model verified-TEE capability for pi-privacy's /models picker: show
811
- // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
812
- // when logged in; ZDR-channel models stay at their honest floor. The live
813
- // verdict still comes from accountPosture on select — this only lifts the label.
814
- //
815
- // ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
816
- // provider (its PUBLIC developer-key channel, one seed model), and Pi's
817
- // registerProvider REPLACES a provider's models and request config. It
818
- // must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
819
- // — otherwise the default model stops resolving and requests go to
820
- // api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
821
- // through extension discovery, where the order isn't ours to choose;
822
- // extensions/privateer-privacy.ts re-asserts the account registration
823
- // there. See registerAccountModels.)
824
- makePiPrivacyExtension({
825
- privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
826
- }),
827
- makeAccountProvider(),
828
- // Web access (src/tools/web.ts), when the agent is allowed it. Registered
829
- // here rather than picked up from extensions/ because the harbor never
830
- // installs the launcher's shims. Omitting the factory — not just dropping
831
- // the names from the allow-list — is what makes "web off" mean the tools
832
- // don't exist for this run at all.
833
- ...(webEnabled() ? [makeWebTools()] : []),
834
- mcpAdapter,
835
- ] as any,
836
- },
772
+ resourceLoaderOptions: moat as any,
837
773
  });
838
774
  } finally {
839
775
  if (prevDirect === undefined) delete process.env.MCP_DIRECT_TOOLS;
@@ -0,0 +1,95 @@
1
+ // The cloud outbox sender — sealed E2EE store-and-forward from this machine to
2
+ // the account's app.
3
+ //
4
+ // Lifted out of the harbor (which owned it privately) because it is no longer only
5
+ // the harbor's: an INTERACTIVE remote-drive session needs it too. When the app is
6
+ // closed — or its socket is simply gone — a driven turn still finishes, and until
7
+ // now its answer went nowhere: the relay drops frames with no controller attached,
8
+ // so the reply was written to a socket nobody was reading. Sealing it here puts it
9
+ // in the account's Inbox instead (see src/cli/chat.ts → deliverUnwatchedTurn).
10
+ //
11
+ // The terminal holds NO account key material: it seals TO the account's published
12
+ // X25519 public key and can never open what it (or any other terminal) wrote. That
13
+ // key comes from the UNTRUSTED server, so it is only used once the account's Ed25519
14
+ // signature over it verifies against the key pinned at link time. Fail closed: no
15
+ // pin, no signature, or a bad signature ⇒ we don't seal at all, and the caller falls
16
+ // back to its own durable channel (a queue, a file, a notice).
17
+ //
18
+ // Module-level caches (pubkey, machine origin) — one per process, exactly like the
19
+ // per-instance caches this replaced.
20
+
21
+ import { apiRequest, defaultDeviceLabel } from "../auth/privateer.ts";
22
+ import { loadAccountSignKey } from "../crypto/accountTrust.ts";
23
+ import { verifyOutboxKey } from "../crypto/accountVerify.ts";
24
+ import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
25
+ import { routineRelayId, type OutboxKind } from "../routines/store.ts";
26
+
27
+ export type { OutboxKind };
28
+
29
+ // Plaintext cap per sealed item. The server rejects anything over ~128 KB of
30
+ // base64; this keeps us well inside that with room for the envelope, and matches
31
+ // the mailbox's purpose — summaries and answers, not transcripts.
32
+ export const MAX_CLOUD_PLAINTEXT = 45_000;
33
+
34
+ let outboxPub: Uint8Array | undefined;
35
+ let originCache: { id: string; label: string } | undefined;
36
+
37
+ /** Fetch + verify the account's outbox public key (cached for the process). */
38
+ export async function ensureOutboxPub(): Promise<Uint8Array | undefined> {
39
+ if (outboxPub) return outboxPub;
40
+ try {
41
+ const res = await apiRequest("/api/outbox/pubkey");
42
+ if (!res.ok) return undefined;
43
+ const data = (await res.json()) as { outboxPublicKey?: string | null; outboxPublicKeySig?: string | null };
44
+ if (!data.outboxPublicKey || !data.outboxPublicKeySig) return undefined;
45
+ // Verify the account's signature over the key against the signing key pinned at
46
+ // link — otherwise a malicious server could substitute a key it controls and read
47
+ // every result we seal.
48
+ const accountPub = loadAccountSignKey();
49
+ if (!accountPub) return undefined;
50
+ if (!verifyOutboxKey(accountPub, data.outboxPublicKey, data.outboxPublicKeySig)) return undefined;
51
+ outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
52
+ return outboxPub;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * This machine's origin tag, embedded (E2EE) in every sealed result so the app can
60
+ * show WHICH box produced it. The outbox record itself is account-only, so attribution
61
+ * can only live inside the sealed blob — where a hostname is allowed, since the server
62
+ * never sees it. `id` is this install's stable relay id; `label` the device name.
63
+ */
64
+ export function machineOrigin(): { id: string; label: string } {
65
+ if (!originCache) originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
66
+ return originCache;
67
+ }
68
+
69
+ /**
70
+ * Seal one result to the account outbox and POST the ciphertext. Returns whether the
71
+ * server accepted it; false covers every failure (no verified key, offline, rejected)
72
+ * and means the caller must fall back to its own durable channel.
73
+ */
74
+ export async function postOutbox(
75
+ name: string,
76
+ at: string,
77
+ status: "ok" | "error",
78
+ content: string,
79
+ kind: OutboxKind = "routine",
80
+ ): Promise<boolean> {
81
+ const pub = await ensureOutboxPub();
82
+ if (!pub) return false;
83
+ const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
84
+ const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: machineOrigin() });
85
+ try {
86
+ const res = await apiRequest("/api/outbox", {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({ sealed }),
90
+ });
91
+ return res.ok;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
@@ -160,6 +160,42 @@ const EDIT_TOOLS = new Set(["edit", "edit_file", "str_replace", "str_replace_edi
160
160
  const WRITE_TOOLS = new Set(["write", "write_file", "create_file", "create", "save_attachment"]);
161
161
  const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
162
162
 
163
+ // Media tools (src/tools/media.ts, src/tools/videoCompose.ts). See the block in
164
+ // classifyToolCall — they are writes against a named output file, and the generation
165
+ // ones cost real money, which is what the title says out loud.
166
+ const MEDIA_TOOLS = new Set([
167
+ "generate_image",
168
+ "generate_video",
169
+ "generate_speech",
170
+ "generate_music",
171
+ "media_capabilities",
172
+ "video_compose",
173
+ ]);
174
+ // The generation tools egress model-chosen text (and any input file bytes) to
175
+ // Privateer's servers and onward to a provider — generate_music to one with no
176
+ // zero-retention endpoint — and each spends the account's credit. That egress +
177
+ // irreversible spend must never be auto-approved: classified as a plain `write`
178
+ // they were swallowed by acceptEdits (mode.ts:45) and by bypass/no-quarter, so a
179
+ // single injected call could leak context and bill the account with no dialog.
180
+ // `alwaysAsk` sits ABOVE bypass/acceptEdits/allowlist (mode.ts:37) and is never
181
+ // remembered, so every generation is a fresh human decision. video_compose is
182
+ // excluded on purpose — it is local ffmpeg, no egress and no spend — and
183
+ // media_capabilities is a read.
184
+ const MEDIA_GEN_TOOLS = new Set([
185
+ "generate_image",
186
+ "generate_video",
187
+ "generate_speech",
188
+ "generate_music",
189
+ ]);
190
+ const MEDIA_TITLES: Record<string, string> = {
191
+ generate_image: "Generate an image (billed to your Privateer account)",
192
+ generate_video: "Generate a video (billed to your Privateer account)",
193
+ generate_speech: "Generate speech (billed to your Privateer account)",
194
+ generate_music: "Generate music (billed; music prompts have no zero-retention option)",
195
+ video_compose: "Compose video/audio locally",
196
+ media_capabilities: "Read media capabilities",
197
+ };
198
+
163
199
  export function classifyToolCall(
164
200
  toolName: string,
165
201
  input: unknown,
@@ -189,6 +225,83 @@ export function classifyToolCall(
189
225
  };
190
226
  }
191
227
 
228
+ // Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
229
+ //
230
+ // Left to the unknown-tool branch at the bottom these classify as bash-kind, which
231
+ // prompts with a JSON blob nobody can read and — worse — DENIES outright in plan and
232
+ // readonly mode, where a media call is exactly as legitimate as any other write.
233
+ // They are writes: each produces one named file, and the generation ones also spend
234
+ // the account's credit, so the prompt should name the file and the cost.
235
+ //
236
+ // `outside` covers BOTH directions. The output is the obvious one. The inputs matter
237
+ // just as much for the generation tools: `images: ["~/.ssh/id_rsa.png"]` would upload
238
+ // a file from outside scope to our servers, so an out-of-scope INPUT has to prompt
239
+ // even when the output lands neatly in cwd.
240
+ if (MEDIA_TOOLS.has(name)) {
241
+ const compose = name === "video_compose";
242
+ const inputs = [
243
+ ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
244
+ str(obj.input),
245
+ str(obj.audio),
246
+ ...(Array.isArray(obj.images) ? (obj.images as unknown[]).map(str) : []),
247
+ str(obj.firstFrame),
248
+ str(obj.lastFrame),
249
+ ].filter(Boolean);
250
+ // Resolve each input once, then flag the two ways an input is sensitive: it leaves
251
+ // the working directory, or it is a guarded file (.env, keys, credentials, …). The
252
+ // generation tools base64 every input up to our servers, so a PROTECTED input is a
253
+ // credential-exfil risk even when the output lands neatly in cwd — and the human
254
+ // approving what looks like a thumbnail has to see which file is being read. Both
255
+ // therefore feed `protected`/`outside` and are named in the prompt detail below.
256
+ const resolvedInputs = inputs.map((p) => resolveInCwd(scope.cwd, p));
257
+ const outsideInputs = resolvedInputs.filter((a) => isOutsideScope(scope, a));
258
+ const protectedInputs = resolvedInputs.filter((a) => isProtectedPath(a));
259
+
260
+ const outPath = str(obj.path ?? obj.output);
261
+ // `probe` reads and writes nothing; so does any composition call with no output
262
+ // (which the tool itself rejects). Gate those only when they touch a sensitive input.
263
+ if (!outPath) {
264
+ if (compose || name === "media_capabilities") {
265
+ const flagged = protectedInputs[0] ?? outsideInputs[0];
266
+ if (!flagged) return null;
267
+ return {
268
+ tool: toolName,
269
+ kind: "read",
270
+ title: protectedInputs.length > 0 ? "Read a protected file" : "Read outside working directory",
271
+ detail: flagged,
272
+ protected: protectedInputs.length > 0,
273
+ outside: outsideInputs.length > 0,
274
+ path: flagged,
275
+ };
276
+ }
277
+ return unknownTarget(toolName, "write"); // a generation call with no destination
278
+ }
279
+
280
+ const absOut = resolveInCwd(scope.cwd, outPath);
281
+ const outputOutside = isOutsideScope(scope, absOut);
282
+ const outside = outputOutside || outsideInputs.length > 0;
283
+ // Name the sensitive input in the prompt — protected first (the more dangerous
284
+ // disclosure), then outside-scope. Base target is the output, absolute when it
285
+ // itself leaves cwd, else the path the caller wrote.
286
+ const inputNote = protectedInputs.length > 0
287
+ ? ` (reads protected file ${protectedInputs[0]}${protectedInputs.length > 1 ? ` +${protectedInputs.length - 1} more` : ""})`
288
+ : outsideInputs.length > 0
289
+ ? ` (reads ${outsideInputs[0]}, outside the working directory)`
290
+ : "";
291
+ return {
292
+ tool: toolName,
293
+ kind: "write",
294
+ title: outside
295
+ ? `${MEDIA_TITLES[name]} outside working directory`
296
+ : MEDIA_TITLES[name],
297
+ detail: `${outputOutside ? absOut : outPath}${inputNote}`,
298
+ protected: isProtectedPath(absOut) || protectedInputs.length > 0,
299
+ outside,
300
+ alwaysAsk: MEDIA_GEN_TOOLS.has(name),
301
+ path: absOut,
302
+ };
303
+ }
304
+
192
305
  // Shell — the whole command is the detail (danger scanning runs on it).
193
306
  if (BASH_TOOLS.has(name)) {
194
307
  const command = str(obj.command ?? obj.cmd ?? obj.script);