privateer-agent 0.12.58 → 0.14.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,76 @@
1
+ // One account inference session for the whole harbor, shared by every run in flight.
2
+ //
3
+ // The account credential is a single slot per process — `_account` in auth/privateer.ts,
4
+ // one row per pid in accountSessions.ts, one `privateer` entry in auth.json — but the
5
+ // harbor runs several sessions at once: overlapping ticks, a submitted task, a workflow
6
+ // step and a live spawn can all be mid-turn together. Each used to mint its own session
7
+ // on start and revoke "the" session on finish, so the first run to finish revoked
8
+ // whichever session was minted LAST (another run's, mid-turn → 401) and its own leaked
9
+ // server-side until the TTL, with its registry row overwritten so no later launch could
10
+ // reclaim it.
11
+ //
12
+ // A lease fits what the slot can actually hold: the first run to need the account mints
13
+ // it, later runs share it, and the last one out revokes it. Pure — the mint and revoke
14
+ // are injected — so the ordering is testable without a server.
15
+
16
+ export interface AccountLeaseDeps {
17
+ /** Mint the account session and arm it (persist + remember). Throws on failure. */
18
+ mint: () => Promise<void>;
19
+ /** Revoke the session and drop its persisted copy. Best-effort; must not throw. */
20
+ revoke: () => Promise<void>;
21
+ log?: (msg: string) => void;
22
+ }
23
+
24
+ export interface AccountLease {
25
+ /** Take a reference. Resolves true when the account session is armed. ALWAYS pair with release(). */
26
+ acquire(): Promise<boolean>;
27
+ /** Drop a reference; the last one out revokes the session. */
28
+ release(): Promise<void>;
29
+ /** References held right now (tests, status). */
30
+ readonly refs: number;
31
+ }
32
+
33
+ export function createAccountLease(deps: AccountLeaseDeps): AccountLease {
34
+ let refs = 0;
35
+ // The session the current holders share: resolves true once armed, false if the mint
36
+ // failed. Undefined when nobody holds one.
37
+ let current: Promise<boolean> | undefined;
38
+ // The previous session's revoke. A mint waits on it, so a run that starts as the last
39
+ // one ends never has its fresh credential dropped by the old one's teardown.
40
+ let teardown: Promise<void> = Promise.resolve();
41
+
42
+ return {
43
+ get refs() {
44
+ return refs;
45
+ },
46
+ async acquire() {
47
+ refs++;
48
+ if (!current) {
49
+ current = teardown.then(deps.mint).then(
50
+ () => true,
51
+ (e) => {
52
+ deps.log?.(` account channel unavailable: ${e instanceof Error ? e.message : String(e)}`);
53
+ return false;
54
+ },
55
+ );
56
+ }
57
+ const mine = current;
58
+ const ok = await mine;
59
+ // A failed mint is not cached: the next run should try again rather than inherit it.
60
+ if (!ok && current === mine) current = undefined;
61
+ return ok;
62
+ },
63
+ async release() {
64
+ if (refs === 0) return;
65
+ refs--;
66
+ if (refs > 0) return;
67
+ const held = current;
68
+ current = undefined;
69
+ if (!held) return;
70
+ teardown = teardown.then(async () => {
71
+ if (await held) await deps.revoke().catch(() => {});
72
+ });
73
+ await teardown;
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,20 @@
1
+ // Tear a headless session all the way down. dispose() alone stops the agent but never
2
+ // emits session_shutdown — that is AgentSessionRuntime's job, and we build sessions
3
+ // without one — so every extension that cleans up on it (pi-mcp-adapter's connections,
4
+ // OAuth runtime and lifecycle state among them) leaked once per run, for the life of a
5
+ // resident process. Reason "new", not "quit": to our own extensions "quit" means the
6
+ // PROCESS is exiting (privateer-brand revokes the machine's sessions on it), and the
7
+ // harbor process lives on. Best-effort: teardown must never fail a finished run.
8
+ export async function closeSession(session: any): Promise<void> {
9
+ try {
10
+ const runner = session?.extensionRunner;
11
+ if (runner?.hasHandlers?.("session_shutdown")) await runner.emit({ type: "session_shutdown", reason: "new" });
12
+ } catch {
13
+ /* a handler threw — dispose anyway */
14
+ }
15
+ try {
16
+ session?.dispose?.();
17
+ } catch {
18
+ /* already disposed */
19
+ }
20
+ }
@@ -64,6 +64,8 @@ import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOu
64
64
  import { redactText, collectSecrets } from "../util/redact.ts";
65
65
  import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
66
66
  import { serializeBuild } from "./buildLock.ts";
67
+ import { createAccountLease } from "./accountLease.ts";
68
+ import { closeSession } from "./closeSession.ts";
67
69
  import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
68
70
  import { relayExposureAllowed } from "../config/relayExposure.ts";
69
71
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
@@ -134,6 +136,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
134
136
  // endpoint or an expired OAuth token from stalling a scheduled run.
135
137
  const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
136
138
  const WARM_POLL_MS = 250;
139
+ // Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
140
+ // step). Nothing else bounds a provider stream that stops sending without closing, and a
141
+ // run stuck there holds its routine's `running` slot until the harbor restarts — the
142
+ // routine silently never fires again. Live spawns have their own cap (liveTaskSession).
143
+ const RUN_TIMEOUT_MS = Number(process.env.PRIVATEER_RUN_TIMEOUT_MS) || 30 * 60_000;
137
144
 
138
145
  // Distinguishes concurrent runs in the child-spend registry. A counter rather than a
139
146
  // timestamp: two runs starting in the same millisecond would share a key, and the second
@@ -177,6 +184,12 @@ function log(msg: string): void {
177
184
  process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
178
185
  }
179
186
 
187
+ // Identity of a queued outbox item, for removing the ones a flush delivered from a
188
+ // queue that may have grown while it was posting.
189
+ function pendingKey(p: PendingCloud): string {
190
+ return JSON.stringify([p.routine, p.at, p.kind ?? "routine", p.status, p.content]);
191
+ }
192
+
180
193
  // Connector notes are things the RUN could not do — a selected connector that isn't
181
194
  // configured, or one that wouldn't hand over a tool list. They ride the result
182
195
  // because the alternative is the failure mode RoutineIssues exists to prevent: an
@@ -271,6 +284,25 @@ export class Harbor {
271
284
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
272
285
  // terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
273
286
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
287
+ // The one account inference session every concurrent run shares — see accountLease.ts.
288
+ private readonly account = createAccountLease({
289
+ mint: async () => {
290
+ const creds = await acquireAccountCredential();
291
+ await persistAccountCredential(creds);
292
+ rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
293
+ },
294
+ revoke: async () => {
295
+ try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
296
+ // Ownership-checked: an interactive terminal on this machine shares auth.json,
297
+ // and its entry must survive a harbor run's teardown (see providers/account.ts).
298
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
299
+ },
300
+ log,
301
+ });
302
+ // The cloud-outbox flush in progress, if any. Every tick starts one; without this two
303
+ // could post the same items (duplicates in the Inbox) and the slower one's save would
304
+ // overwrite what the faster one left.
305
+ private flushing?: Promise<void>;
274
306
 
275
307
  // App-facing routine management (list/save/delete/pause/run) over the harbor's
276
308
  // relay. Run-now is injected here since only the harbor can actually fire one;
@@ -633,22 +665,33 @@ export class Harbor {
633
665
  return sealToOutbox(name, at, status, content, kind, media, source);
634
666
  }
635
667
 
636
- private async flushPendingCloud(): Promise<void> {
668
+ private flushPendingCloud(): Promise<void> {
669
+ this.flushing ??= this.doFlushPendingCloud()
670
+ .catch((e) => log(`outbox flush failed: ${e instanceof Error ? e.message : String(e)}`))
671
+ .finally(() => {
672
+ this.flushing = undefined;
673
+ });
674
+ return this.flushing;
675
+ }
676
+
677
+ private async doFlushPendingCloud(): Promise<void> {
637
678
  if (!hasCredentials()) return;
638
679
  const queue = loadPendingCloud();
639
680
  if (queue.length === 0) return;
640
- const remaining: PendingCloud[] = [];
681
+ const sent = new Set<string>();
641
682
  for (const p of queue) {
642
- if (
643
- remaining.length === 0 &&
644
- (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))
645
- ) continue;
646
- remaining.push(p);
647
- }
648
- if (remaining.length !== queue.length) {
649
- savePendingCloud(remaining);
650
- log(`flushed ${queue.length - remaining.length} pending cloud outbox item(s); ${remaining.length} remaining`);
683
+ // Stop at the first failure: order is preserved, and whatever broke this one
684
+ // (offline, no outbox key yet) will break the rest too.
685
+ if (!(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))) break;
686
+ sent.add(pendingKey(p));
651
687
  }
688
+ if (sent.size === 0) return;
689
+ // Re-read rather than save what we loaded: a run that finished while we were
690
+ // posting queued its result in the meantime, and overwriting with our snapshot
691
+ // would delete it.
692
+ const remaining = loadPendingCloud().filter((p) => !sent.has(pendingKey(p)));
693
+ savePendingCloud(remaining);
694
+ log(`flushed ${sent.size} pending cloud outbox item(s); ${remaining.length} remaining`);
652
695
  }
653
696
 
654
697
  private primeSchedule(): void {
@@ -778,32 +821,56 @@ export class Harbor {
778
821
  const toInbox = routine.delivery.includes("cloud");
779
822
  const media = toInbox ? new ResultMedia() : undefined;
780
823
 
781
- const { out, status, error, notes } = await this.runSession({
782
- prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
783
- cwd: routine.cwd,
784
- model: modelSpec,
785
- tools: routine.tools ?? [],
786
- media,
787
- });
788
-
789
- const content = formatResult(routine, out, status, error, notes);
790
- const report = await deliver(routine, content, status, {
791
- media: media?.list() ?? [],
792
- pushRelay: this.pushRelay,
793
- pushCloud: this.pushCloud,
794
- webhooks: config.webhooks,
795
- redact: (text) => redactText(text, collectSecrets(config.providers)),
796
- });
797
- log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
824
+ let status: "ok" | "error" = "error";
825
+ let error: string | undefined;
826
+ let delivered: string[] = [];
827
+ try {
828
+ const run = await this.runSession({
829
+ prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
830
+ cwd: routine.cwd,
831
+ model: modelSpec,
832
+ tools: routine.tools ?? [],
833
+ media,
834
+ });
835
+ ({ status, error } = run);
798
836
 
799
- this.persistRun(routine.id, {
800
- lastRun: new Date().toISOString(),
801
- lastStatus: status,
802
- lastError: error,
803
- ...advanceAfterRun(routine),
804
- });
805
- this.running.delete(routine.id);
806
- return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
837
+ const content = formatResult(routine, run.out, status, error, run.notes);
838
+ try {
839
+ const report = await deliver(routine, content, status, {
840
+ media: media?.list() ?? [],
841
+ pushRelay: this.pushRelay,
842
+ pushCloud: this.pushCloud,
843
+ webhooks: config.webhooks,
844
+ redact: (text) => redactText(text, collectSecrets(config.providers)),
845
+ });
846
+ delivered = report.delivered;
847
+ } catch (e) {
848
+ // A full disk, an unwritable output dir. The run happened and was paid for; say
849
+ // so in the log and on the routine rather than throwing past the schedule
850
+ // advance below — that throw is what used to re-fire (and re-bill) the routine
851
+ // on every restart.
852
+ error = `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
853
+ status = "error";
854
+ }
855
+ log(` "${routine.name}" ${status}; delivered via ${delivered.join(", ") || "(none)"}${error && delivered.length === 0 ? ` (${error})` : ""}`);
856
+ } finally {
857
+ // Always advance the schedule and free the slot, whatever threw above. Without the
858
+ // finally, one exception left the routine marked running forever (it never fired
859
+ // again) — or, as an unhandled rejection, crashed the harbor before nextRun moved,
860
+ // so the restart fired it again straight away.
861
+ try {
862
+ this.persistRun(routine.id, {
863
+ lastRun: new Date().toISOString(),
864
+ lastStatus: status,
865
+ lastError: error,
866
+ ...advanceAfterRun(routine),
867
+ });
868
+ } catch (e) {
869
+ log(` couldn't record the run of "${routine.name}": ${e instanceof Error ? e.message : String(e)}`);
870
+ }
871
+ this.running.delete(routine.id);
872
+ }
873
+ return { ok: status === "ok", message: delivered.join(", ") || undefined };
807
874
  }
808
875
 
809
876
  // Build the Pi services for one unattended run: the auto-approve gate, the account
@@ -831,6 +898,11 @@ export class Harbor {
831
898
  allowedOutsideRoots: [],
832
899
  cwd,
833
900
  confineToCwd: true,
901
+ // Bypass mode lets anything through that the allow-list registered — including
902
+ // reads outside cwd and of protected files, which bypass allows outright. A run
903
+ // nobody is watching, that may well have fetched a hostile web page, must not
904
+ // read ~/.ssh or our own auth.json and put it in the next URL: refuse both.
905
+ hardConfine: true,
834
906
  async localAsk() {
835
907
  return "deny";
836
908
  },
@@ -954,7 +1026,7 @@ export class Harbor {
954
1026
  await new Promise((r) => setTimeout(r, WARM_POLL_MS));
955
1027
  }
956
1028
  } finally {
957
- session.dispose?.();
1029
+ await closeSession(session);
958
1030
  }
959
1031
  } catch (e) {
960
1032
  log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -977,7 +1049,8 @@ export class Harbor {
977
1049
  let out = "";
978
1050
  let status: "ok" | "error" = "ok";
979
1051
  let error: string | undefined;
980
- let spawnedAccount = false;
1052
+ let leasedAccount = false;
1053
+ let session: any;
981
1054
  const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
982
1055
  // attach_to_result rides with the staging area rather than the allow-list: it is
983
1056
  // how the answer is DELIVERED, not a capability the run gains. A routine that
@@ -997,14 +1070,10 @@ export class Harbor {
997
1070
 
998
1071
  const { provider, modelId } = parseSpec(spec.model);
999
1072
  if (provider === "privateer") {
1000
- try {
1001
- const creds = await acquireAccountCredential();
1002
- await persistAccountCredential(creds);
1003
- rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
1004
- spawnedAccount = true;
1005
- } catch (e) {
1006
- log(` account channel unavailable: ${(e as Error).message}`);
1007
- }
1073
+ // Shared with every other run in flight — see accountLease.ts. A failed mint is
1074
+ // logged by the lease; the run goes ahead and fails on auth with that context.
1075
+ leasedAccount = true;
1076
+ await this.account.acquire();
1008
1077
  }
1009
1078
 
1010
1079
  const model = (modelRegistryOf(services) as any).find(provider, modelId);
@@ -1012,12 +1081,12 @@ export class Harbor {
1012
1081
  status = "error";
1013
1082
  error = `model ${provider}/${modelId} not found`;
1014
1083
  } else {
1015
- const { session } = await createAgentSessionFromServices({
1084
+ ({ session } = await createAgentSessionFromServices({
1016
1085
  services,
1017
1086
  sessionManager: SessionManager.inMemory(spec.cwd),
1018
1087
  model,
1019
1088
  tools: allowedTools,
1020
- } as any);
1089
+ } as any));
1021
1090
  const adapter = createEngineEventAdapter();
1022
1091
  session.subscribe((ev: any) => {
1023
1092
  for (const ee of adapter.toEngineEvents(ev)) {
@@ -1028,21 +1097,30 @@ export class Harbor {
1028
1097
  }
1029
1098
  }
1030
1099
  });
1031
- await session.prompt(spec.prompt);
1100
+ let timedOut = false;
1101
+ const deadline = setTimeout(() => {
1102
+ timedOut = true;
1103
+ void Promise.resolve(session.abort?.()).catch(() => {});
1104
+ }, RUN_TIMEOUT_MS);
1105
+ try {
1106
+ await session.prompt(spec.prompt);
1107
+ } finally {
1108
+ clearTimeout(deadline);
1109
+ }
1110
+ if (timedOut) {
1111
+ status = "error";
1112
+ error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
1113
+ }
1032
1114
  }
1033
1115
  } catch (err) {
1034
1116
  status = "error";
1035
1117
  error = err instanceof Error ? err.message : String(err);
1036
1118
  } finally {
1037
- // Revoke ONLY this run's account inference session (the harbor's own child API
1038
- // session — relay/outbox — stays alive until shutdown). Drop Pi's persisted copy
1039
- // too so a later run's fallback never reuses a revoked token. Best-effort.
1040
- if (spawnedAccount) {
1041
- try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
1042
- // Ownership-checked: an interactive terminal on this machine shares auth.json,
1043
- // and its entry must survive a harbor run's teardown (see providers/account.ts).
1044
- try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
1045
- }
1119
+ if (session) await closeSession(session);
1120
+ // Drop this run's reference to the shared account session; the last run out
1121
+ // revokes it (and its persisted copy). The harbor's own child API session —
1122
+ // relay/outbox — is separate and stays alive until shutdown.
1123
+ if (leasedAccount) await this.account.release();
1046
1124
  // Drop this run's child grant. Unconditional and last: a grant left behind would
1047
1125
  // authorize the NEXT run's subagents for tools that run never named.
1048
1126
  releaseChildSpend();
@@ -1108,6 +1186,7 @@ export class Harbor {
1108
1186
  defaultModel: loadHarborConfig().defaultModel,
1109
1187
  parseSpec,
1110
1188
  log,
1189
+ account: this.account,
1111
1190
  onClosed: (id) => this.liveTasks.delete(id),
1112
1191
  // A live spawn's feed lives only in the attached app; when the session ends
1113
1192
  // (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
@@ -1401,6 +1480,12 @@ export function runHarbor(): void {
1401
1480
  };
1402
1481
  process.on("SIGINT", shutdown);
1403
1482
  process.on("SIGTERM", shutdown);
1483
+ // A resident service: one stray rejection from a fire-and-forget promise (a relay
1484
+ // callback, a run started from IPC) must not take every routine down with it — and,
1485
+ // under launchd/systemd, restart into the same state and do it again. Log it instead.
1486
+ process.on("unhandledRejection", (reason) => {
1487
+ log(`unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1488
+ });
1404
1489
  harbor.start().catch(async (err) => {
1405
1490
  if (err instanceof HarborAlreadyRunningError) {
1406
1491
  // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
@@ -2,7 +2,7 @@ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:pat
2
2
  import { realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
- import { isProtectedPath } from "./protected.ts";
5
+ import { isProtectedPath, isCredentialStorePath } from "./protected.ts";
6
6
  import type { PermissionRequest } from "./gate.ts";
7
7
 
8
8
  // NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
@@ -19,6 +19,11 @@ export interface ScopeOptions {
19
19
  cwd: string;
20
20
  confineToCwd?: boolean; // default true
21
21
  allowedOutsideRoots?: string[];
22
+ // Gate a READ of a protected file or credential store (isCredentialStorePath) even
23
+ // inside cwd, flagged `protected`. Off by default — an interactive session reading
24
+ // its own project's .env is normal. The harbor's unattended runs turn it on and pair
25
+ // it with GateController.hardConfine, which refuses anything flagged.
26
+ guardSecretReads?: boolean;
22
27
  }
23
28
 
24
29
  // Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
@@ -619,13 +624,16 @@ export function classifyToolCall(
619
624
  const p = firstPath(obj);
620
625
  if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
621
626
  const abs = resolveInCwd(scope.cwd, p);
622
- if (!isOutsideScope(scope, abs)) return null;
627
+ const outside = isOutsideScope(scope, abs);
628
+ const secret = scope.guardSecretReads === true && (isProtectedPath(abs) || isCredentialStorePath(abs));
629
+ if (!outside && !secret) return null;
623
630
  return {
624
631
  tool: toolName,
625
632
  kind: "read",
626
- title: "Read outside working directory",
633
+ title: secret ? "Read a protected file" : "Read outside working directory",
627
634
  detail: abs,
628
- outside: true,
635
+ outside,
636
+ ...(secret ? { protected: true } : {}),
629
637
  path: abs,
630
638
  };
631
639
  }
@@ -648,6 +656,28 @@ export function classifyToolCall(
648
656
  };
649
657
  }
650
658
 
659
+ // terminal (src/tools/terminal.ts) — named windows the user watches. Opening one
660
+ // with a command RUNS that command, so it is bash-kind with the command as the
661
+ // detail: the same danger list, allowlist and plan-mode refusal as typing it into
662
+ // bash. A bare shell is bash-kind too (it puts a new window on the user's screen),
663
+ // and so is close, which ends whatever runs in the window. Focus and list only move
664
+ // a window to the front or read names: no gate, in any mode.
665
+ if (name === "terminal") {
666
+ const action = str(obj.action) || "list";
667
+ if (action === "focus" || action === "list") return null;
668
+ const label = str(obj.name);
669
+ if (action === "close") {
670
+ return { tool: toolName, kind: "bash", title: `Close terminal "${label}"`, detail: `close terminal ${label}` };
671
+ }
672
+ const command = str(obj.command).trim();
673
+ return {
674
+ tool: toolName,
675
+ kind: "bash",
676
+ title: command ? `Open terminal "${label}" running a command` : `Open terminal "${label}"`,
677
+ detail: command || `open a shell${obj.cwd ? ` in ${str(obj.cwd)}` : ""}`,
678
+ };
679
+ }
680
+
651
681
  // Network reads (web fetch / search / http).
652
682
  if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
653
683
  return {
@@ -37,6 +37,15 @@ export function looksLikeSecretExfil(command: string): boolean {
37
37
  return SECRET_FILE.test(command) && NETWORK_SINK.test(command);
38
38
  }
39
39
 
40
+ // Does this command look like it can move bytes off the machine? Private mode refuses
41
+ // these outright (permissions/privateMode.ts). Same heuristic as the exfil check, plus
42
+ // the git and package-manager verbs that upload: a regex over the raw text, not a shell
43
+ // parser, so it is a net, not a proof.
44
+ const UPLOAD_VERB = /\bgit\s+push\b|\b(npm|pnpm|yarn)\s+publish\b/i;
45
+ export function reachesNetwork(command: string): boolean {
46
+ return NETWORK_SINK.test(command) || UPLOAD_VERB.test(command);
47
+ }
48
+
40
49
  export function matchesDenylist(command: string, patterns: string[]): boolean {
41
50
  return patterns.some((src) => {
42
51
  if (!src.trim()) return false;
@@ -69,6 +69,12 @@ export interface ModeGateDeps {
69
69
  // budget RECORDS what it allows — asking it about a call the mode would refuse anyway
70
70
  // would spend budget on nothing.
71
71
  isSpendPreauthorized?: (req: PermissionRequest) => boolean | Promise<boolean>;
72
+ // Refuse every request that leaves cwd or touches a protected file, whatever the mode.
73
+ // For the harbor's unattended runs, which run in `bypass` (their safety is the tool
74
+ // allow-list) — and bypass allows outside/protected outright (mode.ts), which left
75
+ // their `confineToCwd` meaning nothing. Sits below no-quarter only: an operator who
76
+ // launched with --no-quarter asked for no gate at all.
77
+ hardConfine?: boolean;
72
78
  }
73
79
 
74
80
  // The permission gate used by the live TUI. It first applies the mode/allowlist
@@ -85,6 +91,8 @@ export class ModeGate implements PermissionGate {
85
91
  // first check so nothing below can force an "ask" back on.
86
92
  if (this.deps.getSkipAllPermissions?.()) return "allow";
87
93
 
94
+ if (this.deps.hardConfine && (req.outside || req.protected)) return "deny";
95
+
88
96
  const denylist = this.deps.denylist ?? [];
89
97
  const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
90
98
 
@@ -0,0 +1,136 @@
1
+ // Private mode — the contract another agent relies on when it hands Privateer data it
2
+ // must not see itself.
3
+ //
4
+ // The handoff: an agent running on a model that is NOT private (a hosted frontier model,
5
+ // say) has a task that touches sensitive files. It keeps them out of its own context by
6
+ // asking Privateer to do the reading: `privateer -p --private "…"`, or an ACP session
7
+ // with `acp.private`. That only works if Privateer refuses to put those files in front of
8
+ // a model that isn't private either. A config that quietly fell back to a BYO-key model,
9
+ // a sealed shim that never came up, an attestation that came back yellow: each would
10
+ // send the very data the caller was keeping out of its own provider to someone else's.
11
+ //
12
+ // So private mode is enforced where the data enters the model: at the TOOL CALL. Before
13
+ // any tool runs, the session's current model must resolve to a verified-private tier —
14
+ // `tee-verified` (an enclave quote we checked) or `local` (on this machine). Anything
15
+ // else, including a check that throws, blocks the tool. The caller's own prompt still
16
+ // reaches the model, by design: it is the caller's text, and the caller already holds it.
17
+ // What never reaches an unverified model is anything Privateer READ on the caller's
18
+ // behalf.
19
+ //
20
+ // The second half is egress. A private model does not make a tool call private: a
21
+ // web_fetch carrying a file's contents leaves the machine no matter where inference ran.
22
+ // Private mode therefore runs on an ALLOWLIST of tools that stay on this machine, and
23
+ // refuses shell commands that reach the network, before any mode or approval is
24
+ // consulted — so no-quarter, `acp.posture: "auto"` and an approval cannot lift it.
25
+ //
26
+ // Turned on by `--private` (the launcher sets PRIVATEER_PRIVATE=1) or `acp.private`.
27
+ // The env var is honoured from the ambient environment on purpose: unlike the spend
28
+ // grants it can only ever make a run STRICTER, so an orchestrator may export it once
29
+ // for every Privateer it spawns. Subagent children inherit it with the rest of the env.
30
+ //
31
+ // IMPORT-SAFETY: the gate imports this module statically, and the gate must stay
32
+ // import-order-safe, so the Pi-touching posture code is imported dynamically below.
33
+
34
+ import type { PrivacyTier } from "pi-privacy";
35
+ import { reachesNetwork } from "./danger.ts";
36
+
37
+ export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
38
+
39
+ /** True when the environment asks for private mode. */
40
+ export function privateModeFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ const v = (env[PRIVATE_ENV] ?? "").trim().toLowerCase();
42
+ return v === "1" || v === "true" || v === "yes";
43
+ }
44
+
45
+ /** The tiers a sensitive file may be shown to. Nothing weaker, and nothing asserted. */
46
+ export const PRIVATE_TIERS: ReadonlySet<PrivacyTier> = new Set<PrivacyTier>(["tee-verified", "local"]);
47
+
48
+ /**
49
+ * Tools that keep their effects on this machine. Everything else (web, crypto lookups,
50
+ * media generation, MCP, app/library uploads, routines, screen control) is refused in
51
+ * private mode, because each one can carry what was read to a third party. `bash` and
52
+ * `terminal` are here but also pass through reachesNetwork().
53
+ */
54
+ export const PRIVATE_TOOLS: ReadonlySet<string> = new Set([
55
+ "read",
56
+ "grep",
57
+ "find",
58
+ "ls",
59
+ "write",
60
+ "edit",
61
+ "bash",
62
+ "terminal",
63
+ // Children inherit PRIVATEER_PRIVATE and run this same check against their own model.
64
+ "subagent",
65
+ "contact_supervisor",
66
+ "intercom",
67
+ ]);
68
+
69
+ export interface ModelRef {
70
+ provider?: string;
71
+ id?: string;
72
+ baseUrl?: string;
73
+ }
74
+
75
+ const TTL_MS = 60_000;
76
+ const tierCache = new Map<string, { tier: PrivacyTier; at: number }>();
77
+
78
+ /** Test seam: swap how a model's tier is resolved. */
79
+ let resolveTierImpl: (m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef) => Promise<PrivacyTier> = defaultResolveTier;
80
+ export function setTierResolverForTests(fn: typeof resolveTierImpl | undefined): void {
81
+ resolveTierImpl = fn ?? defaultResolveTier;
82
+ tierCache.clear();
83
+ }
84
+
85
+ async function defaultResolveTier(m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef): Promise<PrivacyTier> {
86
+ // The account channel's verdict is ours to give (see config/privacyPolicy.ts
87
+ // resolveTier); every other provider is pi-privacy's, which attests TEE providers live
88
+ // and never returns tee-verified from a static claim.
89
+ if (m.provider === "privateer") {
90
+ const { accountPosture } = await import("../providers/account.ts");
91
+ return (await accountPosture(m.id)).tier;
92
+ }
93
+ const { verifyModelPosture } = await import("pi-privacy");
94
+ const apiKey = m.provider === "nearai" ? process.env.NEARAI_API_KEY : undefined;
95
+ return (await verifyModelPosture(m.provider, m.id, { baseUrl: m.baseUrl, apiKey })).tier;
96
+ }
97
+
98
+ /** The model's privacy tier, from a short cache. A failure is not a tier — it throws. */
99
+ export async function modelTier(model: ModelRef): Promise<PrivacyTier> {
100
+ if (!model.provider || !model.id) throw new Error("no model selected");
101
+ const key = `${model.provider}/${model.id}@${model.baseUrl ?? ""}`;
102
+ const hit = tierCache.get(key);
103
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.tier;
104
+ const tier = await resolveTierImpl({ ...model, provider: model.provider, id: model.id });
105
+ tierCache.set(key, { tier, at: Date.now() });
106
+ return tier;
107
+ }
108
+
109
+ const label = (m: ModelRef | undefined) => (m?.provider && m?.id ? `${m.provider}/${m.id}` : "no model");
110
+
111
+ /**
112
+ * Why this tool call may not run in private mode, or undefined when it may. Never
113
+ * throws: any failure to establish privacy is itself the refusal.
114
+ */
115
+ export async function privateRefusal(toolName: string, input: unknown, model: ModelRef | undefined): Promise<string | undefined> {
116
+ if (!PRIVATE_TOOLS.has(toolName)) {
117
+ return `${toolName} is unavailable in private mode: it can carry what this session has read off this machine. Work with local files only.`;
118
+ }
119
+ if (toolName === "bash" || toolName === "terminal") {
120
+ const obj = (input ?? {}) as Record<string, unknown>;
121
+ const command = String(obj.command ?? obj.cmd ?? obj.script ?? "");
122
+ if (reachesNetwork(command)) {
123
+ return `This command reaches the network, which private mode refuses: it could carry what this session has read off this machine. Use local commands only.`;
124
+ }
125
+ }
126
+ let tier: PrivacyTier;
127
+ try {
128
+ tier = await modelTier(model ?? {});
129
+ } catch (e) {
130
+ return `Private mode could not verify ${label(model)} (${e instanceof Error ? e.message : String(e)}), so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
131
+ }
132
+ if (!PRIVATE_TIERS.has(tier)) {
133
+ return `Private mode: ${label(model)} is "${tier}", not a verified-private model, so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
134
+ }
135
+ return undefined;
136
+ }