privateer-agent 0.13.0 → 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.
package/README.md CHANGED
@@ -54,6 +54,10 @@ Privateer runs in three places, over one account and one config: the **terminal*
54
54
  - **Privacy you can verify, not just trust.** Confidential-enclave (TEE) inference is
55
55
  cryptographically **attested** — not a policy promise — and an **on-device PII gate**
56
56
  warns before structured personal data ever leaves your machine for an unverified model.
57
+ - **A private subagent for your other agents.** Claude Code, Codex, or your own orchestrator
58
+ can hand Privateer the files it shouldn't read itself: `privateer -p --private "…"` reads
59
+ them on your machine, reasons over them in an attested enclave, and returns only the
60
+ answer. See [Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
57
61
  - **It's Pi underneath.** Privateer is a distribution of the [Pi](https://pi.dev) coding agent —
58
62
  every Pi extension, skill, and command works, and Privateer's own features are just extensions
59
63
  you can read, swap, or build on. Nothing to compile. See [Built on Pi](#built-on-pi).
@@ -545,6 +549,8 @@ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
545
549
  Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
546
550
  every approval arrives as a `session/request_permission` call it answers. The wire format,
547
551
  option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
552
+ Handing it data your program shouldn't see? Set `"private": true` in the `acp` block; see
553
+ [Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
548
554
 
549
555
  ### One-shot runs (`-p`) and spending
550
556
 
@@ -566,6 +572,36 @@ call. `media_capabilities` shows the same prices. A call that can't be priced is
566
572
  under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
567
573
  open or running in the background.
568
574
 
575
+ ## Hand off sensitive work to Privateer
576
+
577
+ Your coding agent's context goes to its provider. When a task touches files that
578
+ shouldn't (patient records, a customer export, payroll, a database dump), the agent can
579
+ hand that part to Privateer instead of reading the files itself:
580
+
581
+ ```bash
582
+ privateer -p --private --model privateer/tinfoil/gemma4-31b \
583
+ "Read customers/export.csv. Reply with the number of customers per country. No row-level data."
584
+ ```
585
+
586
+ The calling agent passes file *names*. Privateer reads the files on this machine, reasons
587
+ over them on a verified-private model, and prints only the answer. `--private` is what
588
+ makes that a guarantee rather than a hope:
589
+
590
+ - **No file reaches an unverified model.** Before every tool call the model must be
591
+ `tee-verified` (an enclave quote Privateer checked itself) or `local`. A ZDR route, a
592
+ standard provider, or a failed attestation refuses the tool.
593
+ - **Nothing leaves through a tool.** Only on-machine tools run. Web, MCP, media, uploads
594
+ and network shell commands (`curl`, `ssh`, `git push`, …) are refused.
595
+ - **Nothing lifts it.** It sits above permission modes and approvals, `--no-quarter`
596
+ included, and subagents inherit it.
597
+ - **The caller sees only the answer.** `-p` prints the reply. `--private --mode json`,
598
+ which would stream the file contents, is refused.
599
+
600
+ The reply itself is the one channel left, so ask for aggregates, yes/no answers, or a
601
+ summary, and have Privateer write detail to a file. Setup for Claude Code and other agents,
602
+ the ACP route for your own programs, prompt patterns, and the honest limits are in
603
+ [`docs/private-handoff.md`](docs/private-handoff.md).
604
+
569
605
  ## Connectors — MCP
570
606
 
571
607
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -737,7 +773,10 @@ agent is stuck),
737
773
  `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
738
774
  [`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
739
775
  `privateer -p … [--allow-spend …] [--approve-in-app]` (see
740
- [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --no-quarter`, `privateer --version`.
776
+ [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --private` (tools run only on a
777
+ verified-private model and never reach the network; see
778
+ [Hand off sensitive work](#hand-off-sensitive-work-to-privateer)), `privateer --no-quarter`,
779
+ `privateer --version`.
741
780
  An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
742
781
 
743
782
  ## Develop
@@ -6,6 +6,7 @@ export const BILLED_TOOLS: string[];
6
6
  export const CLI_SPEND_ENV: string;
7
7
  export const APPROVE_IN_APP_ENV: string;
8
8
  export const PI_SUBCOMMANDS: string[];
9
+ export const PRIVATE_ENV: string;
9
10
 
10
11
  export interface CliSpendGrant {
11
12
  tools: string[];
@@ -22,4 +23,10 @@ export function extractHeadlessFlags(args: string[]): {
22
23
  error?: string;
23
24
  };
24
25
 
26
+ /** Strips `--private` out of `args` in place; true when it was there. */
27
+ export function extractPrivateFlag(args: string[]): boolean;
28
+
29
+ /** Why these args can't run under --private, or null. */
30
+ export function privateProblem(args: string[]): string | null;
31
+
25
32
  export function authProblem(args: string[], cmd?: string): string | null;
@@ -137,6 +137,46 @@ export function extractHeadlessFlags(args) {
137
137
  };
138
138
  }
139
139
 
140
+ /** The env var carrying `--private` (permissions/privateMode.ts) into every gate. */
141
+ export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
142
+
143
+ /**
144
+ * Strip `--private` from `args` (in place). Returns true when it was there.
145
+ *
146
+ * Valid for any launch — the TUI, `-p`, `acp` — because it only ever makes a run
147
+ * stricter: tools run only while the model is verified-private, and only tools that
148
+ * stay on this machine. Unlike the spend env vars, an exported PRIVATEER_PRIVATE is
149
+ * honoured too, for the same reason.
150
+ */
151
+ export function extractPrivateFlag(args) {
152
+ let found = false;
153
+ for (let i = 0; i < args.length; i++) {
154
+ if (args[i] === "--") break;
155
+ if (args[i] === "--private") {
156
+ args.splice(i, 1);
157
+ i--;
158
+ found = true;
159
+ }
160
+ }
161
+ return found;
162
+ }
163
+
164
+ /**
165
+ * Why these args can't run in private mode, or null. `--mode json` streams every event
166
+ * to stdout, tool results included — the contents of every file the run read, handed to
167
+ * the very caller private mode exists to keep them from. `-p` prints only the answer.
168
+ */
169
+ export function privateProblem(args) {
170
+ for (let i = 0; i < args.length; i++) {
171
+ const a = args[i];
172
+ if (a === "--") break;
173
+ if ((a === "--mode" && args[i + 1] === "json") || a === "--mode=json") {
174
+ return "--private can't be combined with --mode json: JSON mode streams every tool result (the files this run reads) to stdout. Use -p, which prints only the answer, or `privateer acp`.";
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+
140
180
  // Pi's own subcommands. Pi recognizes each only as args[0], so the launcher must hand
141
181
  // them over with NOTHING in front — the normal launch prepends --model, -e and --skill,
142
182
  // which is exactly how `privateer auth check` used to become a chat message.
@@ -29,7 +29,7 @@ import path from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
- import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
32
+ import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
33
33
  import { runToCompletion } from "./run-to-completion.mjs";
34
34
  import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
35
35
  import { configureCompileCache } from "./startup-cache.mjs";
@@ -248,6 +248,16 @@ delete process.env[APPROVE_IN_APP_ENV];
248
248
  if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
249
249
  if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
250
250
  }
251
+ // --private: NOT cleared from the inherited env first, unlike the two above — it can only
252
+ // tighten a run, so an orchestrator exporting it for every child is the point.
253
+ if (extractPrivateFlag(args)) process.env[PRIVATE_ENV] = "1";
254
+ if (/^(1|true|yes)$/i.test((process.env[PRIVATE_ENV] ?? "").trim())) {
255
+ const problem = privateProblem(args);
256
+ if (problem) {
257
+ process.stderr.write(`privateer: ${problem}\n`);
258
+ process.exit(2);
259
+ }
260
+ }
251
261
 
252
262
  const sub = args[0];
253
263
 
@@ -281,17 +281,21 @@ function contextLine(p: Palette): string {
281
281
  }
282
282
 
283
283
  // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
284
- // A hand-curated highlights list (newest first). Not the full changelog — just the two
285
- // or three things a returning user should notice. `cmd`, when present, is rendered in the
286
- // accent color so the actionable bit stands out from the prose. Trim this as it ages.
287
- const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
288
- { text: "Open and switch named terminals —", cmd: "/term" },
289
- { text: "Voice follows the window you're in" },
290
- { text: "Searchable model picker —", cmd: "/models" },
291
- { text: "Start over with a new agent —", cmd: "/fresh" },
292
- ];
284
+ // A hand-curated highlights list (newest first), kept in src/config/whatsNew.json so the
285
+ // release guard (scripts/check-whats-new.mjs) can read it without loading the TUI. Not the
286
+ // full changelog — just the two to four things a returning user should notice. `cmd`, when
287
+ // present, is rendered in the accent color so the actionable bit stands out from the prose.
288
+ const WHATS_NEW: Array<{ text: string; cmd?: string }> = (() => {
289
+ try {
290
+ const list = JSON.parse(readFileSync(new URL("../src/config/whatsNew.json", import.meta.url), "utf8"));
291
+ return Array.isArray(list?.items) ? list.items : [];
292
+ } catch {
293
+ return []; // a banner without the list beats no banner
294
+ }
295
+ })();
293
296
 
294
297
  function whatsNewRows(p: Palette): string[] {
298
+ if (!WHATS_NEW.length) return [];
295
299
  const head = `${p.BOLD}${p.INK}✦ What's new${p.RESET}`;
296
300
  const items = WHATS_NEW.map(
297
301
  ({ text, cmd }) =>
@@ -322,7 +326,8 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
322
326
  const packs = packNotice(p);
323
327
  if (packs) text.push(packs);
324
328
  // A blank spacer, then the What's New block — set off below the identity lines.
325
- text.push("", ...whatsNewRows(p));
329
+ const news = whatsNewRows(p);
330
+ if (news.length) text.push("", ...news);
326
331
 
327
332
  // Zip the mark and the text column by row. Rows past the mark's height get a blank
328
333
  // gutter of the mark's width, so the text stays in one column throughout.
@@ -40,6 +40,7 @@ import { updatePostureBadge } from "./privateer-posture.ts";
40
40
  import { childSpendAllows } from "../src/permissions/childSpend.ts";
41
41
  import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
42
42
  import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
43
+ import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
43
44
  import type { PermissionRequest } from "../src/permissions/gate.ts";
44
45
  import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
45
46
  import { describeErrorText } from "../src/engine/errors.ts";
@@ -694,7 +695,9 @@ export default function privateerControl(pi: any): void {
694
695
  // prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
695
696
  // lifts the gate anyway.
696
697
  function uncoveredBilledTools(): string[] {
697
- if (noQuarterActive()) return [];
698
+ // Private mode refuses every billed tool outright (they send text off the machine),
699
+ // so "add --allow-spend" would be advice that cannot work.
700
+ if (noQuarterActive() || privateModeFromEnv()) return [];
698
701
  const active: string[] = (() => {
699
702
  try {
700
703
  return pi.getActiveTools?.() ?? [];
@@ -750,6 +753,25 @@ export default function privateerControl(pi: any): void {
750
753
  });
751
754
  pi.on("session_shutdown", () => appApprover?.close());
752
755
 
756
+ // Private mode (src/permissions/privateMode.ts), told to the model before it plans:
757
+ // otherwise it reaches for web_search, gets refused, and narrates the refusal instead
758
+ // of doing the local work. And the reply is the one channel the gate can't close, so
759
+ // the model is asked to keep raw records out of it — a request, not a control.
760
+ pi.on("before_agent_start", (event: any) => {
761
+ if (!privateModeFromEnv()) return;
762
+ return {
763
+ systemPrompt:
764
+ `${event.systemPrompt}\n\n# Private mode\n` +
765
+ "This run handles data that must not leave this machine except through a verified-private model. " +
766
+ "Only local tools work: read, grep, find, ls, write, edit, and shell commands that don't use the network. " +
767
+ "Web, MCP, media and upload tools, and network commands, are refused. Every tool is refused if the model " +
768
+ "can't be verified as private; if that happens, stop and say so.\n" +
769
+ "Your reply leaves private mode: whoever asked will see it. Answer exactly what was asked. Don't quote raw " +
770
+ "records, names, emails, identifiers or other personal data from the files unless the task explicitly asks " +
771
+ "for them. When asked for detail, write it to a file and reply with the path.",
772
+ };
773
+ });
774
+
753
775
  // Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
754
776
  // "Connection error." — and exits. Follow it with what that means and what to do,
755
777
  // on stderr so stdout stays the answer (or the JSON stream).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "dev": "node bin/privateer-launch.mjs",
65
65
  "dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
66
66
  "typecheck": "tsc --noEmit",
67
+ "check:whats-new": "node scripts/check-whats-new.mjs",
67
68
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
68
69
  },
69
70
  "engines": {
package/src/acp/run.ts CHANGED
@@ -21,7 +21,8 @@
21
21
  // "model": "openrouter/openai/gpt-4o-mini", // optional
22
22
  // "tools": ["read","grep","find","ls"], // optional ceiling
23
23
  // "posture": "approve", // readonly | approve | auto
24
- // "cwd": "/path/to/project" // optional; else this process's cwd
24
+ // "cwd": "/path/to/project", // optional; else this process's cwd
25
+ // "private": true // optional; see permissions/privateMode.ts
25
26
  // }
26
27
  // }
27
28
  //
@@ -115,6 +116,10 @@ export async function runAcp(): Promise<void> {
115
116
  : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
116
117
  const posture: Posture = normalizePosture(block.posture) ?? "approve";
117
118
  const baseCwd: string = block.cwd ?? process.cwd();
119
+ // Private mode: `acp.private`, or `privateer acp --private` / PRIVATEER_PRIVATE (read by
120
+ // the gate itself). The handoff surface for an agent that must not see the data.
121
+ const { privateModeFromEnv, modelTier, PRIVATE_TIERS } = await import("../permissions/privateMode.ts");
122
+ const privateMode: boolean = block.private === true || privateModeFromEnv();
118
123
 
119
124
  // The gate. Identical posture semantics to the channels runtime: "readonly" maps to
120
125
  // plan mode (hard-deny writes), "auto" relaxes non-dangerous actions, and every ask
@@ -129,6 +134,7 @@ export async function runAcp(): Promise<void> {
129
134
  confineToCwd: true,
130
135
  getRemote: () => true,
131
136
  getAutoApprove: () => posture === "auto",
137
+ getPrivate: () => privateMode,
132
138
  async localAsk() {
133
139
  return "deny";
134
140
  },
@@ -272,8 +278,22 @@ export async function runAcp(): Promise<void> {
272
278
  const modelCount = listModels()?.available.length ?? 0;
273
279
  log(
274
280
  `ready — model ${defaultModel}, ${modelCount} selectable, ` +
275
- `ceiling [${tools.join(", ")}], posture ${posture}`,
281
+ `ceiling [${tools.join(", ")}], posture ${posture}${privateMode ? ", PRIVATE" : ""}`,
276
282
  );
283
+ // Say up front when private mode will refuse every tool, rather than letting the
284
+ // caller find out from its first denial. The gate re-checks per call regardless.
285
+ if (privateMode) {
286
+ try {
287
+ const tier = await modelTier({ provider: model.provider, id: model.id, baseUrl: model.baseUrl });
288
+ log(
289
+ PRIVATE_TIERS.has(tier)
290
+ ? `private mode: ${defaultModel} is ${tier}`
291
+ : `private mode: ${defaultModel} is ${tier}, NOT verified-private — every tool will be refused. Pick a TEE or local model.`,
292
+ );
293
+ } catch (e) {
294
+ log(`private mode: could not verify ${defaultModel} (${e instanceof Error ? e.message : String(e)}) — tools will be refused until it verifies`);
295
+ }
296
+ }
277
297
 
278
298
  // One Pi session per ACP session. A single subscription routes streamed text into a
279
299
  // mutable holder, which the running turn owns — safe because a session runs at most
@@ -353,7 +373,12 @@ export async function runAcp(): Promise<void> {
353
373
  // its own `hasConfiguredAuth` precheck before it emits `before_agent_start`,
354
374
  // where providers/account.ts installs the equivalent net.
355
375
  await ensureAccountArmed(currentProvider);
356
- await session.prompt(text);
376
+ // Prompt text is the HOST's, so it is never a command: with expansion on, Pi
377
+ // runs a leading `/name` as an extension command, and anyone who can post to
378
+ // the host (a Buzz channel, a calling agent) could send `/privacy off` or
379
+ // `/privacy allow *` — the second persisted to config.json. Text goes to the
380
+ // model as text.
381
+ await session.prompt(text, { expandPromptTemplates: false });
357
382
  } catch (e) {
358
383
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
359
384
  } finally {
@@ -371,7 +396,7 @@ export async function runAcp(): Promise<void> {
371
396
 
372
397
  let agent: InstanceType<typeof PrivateerAcpAgent> | undefined;
373
398
  new AgentSideConnection((conn) => {
374
- agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log });
399
+ agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log, private: privateMode });
375
400
  return agent;
376
401
  }, stream);
377
402
 
package/src/acp/server.ts CHANGED
Binary file
@@ -332,7 +332,12 @@ async function main() {
332
332
  // in providers/account.ts hangs off `before_agent_start`, which pi's prompt()
333
333
  // never reaches — it throws on its own `hasConfiguredAuth` precheck first.
334
334
  if (model?.provider === "privateer") await ensureAccountArmed(undefined);
335
- await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () => session.prompt(text));
335
+ // A chat message is never a command: with expansion on, Pi runs a leading
336
+ // `/name` as an extension command, so any chat member could send `/privacy off`
337
+ // or `/privacy allow *` (persisted to config.json). Same rule as ACP.
338
+ await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () =>
339
+ session.prompt(text, { expandPromptTemplates: false }),
340
+ );
336
341
  } catch (e) {
337
342
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
338
343
  } finally {
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
+ "reviewedFor": "0.14.0",
4
+ "items": [
5
+ { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
6
+ { "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
7
+ { "text": "Voice follows the window you're in", "since": "0.13.0" },
8
+ { "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
9
+ ]
10
+ }
@@ -18,6 +18,7 @@ import { classifyToolCall } from "../permissions/classify.ts";
18
18
  import { redactText } from "../util/redact.ts";
19
19
  import { coauthorGitCommits } from "../util/coauthor.ts";
20
20
  import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
21
+ import { privateModeFromEnv, privateRefusal, type ModelRef } from "../permissions/privateMode.ts";
21
22
 
22
23
  // The per-tool_call hook context we read (structural subset of Pi's ctx — kept
23
24
  // local so the gate stays import-order-safe and unit-testable).
@@ -25,6 +26,8 @@ export interface ToolCallCtx {
25
26
  signal?: AbortSignal;
26
27
  hasUI?: boolean;
27
28
  mode?: "tui" | "rpc" | "json" | "print" | string;
29
+ // The session's current model — what private mode checks before any tool runs.
30
+ model?: ModelRef;
28
31
  // Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
29
32
  // select(title, options: string[]) → chosen string | undefined; confirm(title, message).
30
33
  ui?: {
@@ -43,6 +46,10 @@ export interface GateController {
43
46
  denylist?: string[];
44
47
  cwd: string;
45
48
  confineToCwd?: boolean;
49
+ // Unattended confinement: deny outside-cwd and protected access in every mode, and
50
+ // treat a read of a credential store as protected even inside cwd. See
51
+ // ModeGate.hardConfine and ScopeOptions.guardSecretReads.
52
+ hardConfine?: boolean;
46
53
  getRemote?(): boolean;
47
54
  // The controller raised the flag: total bypass for remote turns — see
48
55
  // ModeGate.getNoQuarter.
@@ -54,6 +61,11 @@ export interface GateController {
54
61
  // launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
55
62
  // action with no prompt.
56
63
  getSkipAllPermissions?(): boolean;
64
+ // Private mode (permissions/privateMode.ts): no tool runs unless the session's model is
65
+ // verified-private, and only tools that stay on this machine run at all. ORed with
66
+ // PRIVATEER_PRIVATE, which any controller honours, so a controller can switch it on but
67
+ // never off.
68
+ getPrivate?(): boolean;
57
69
  // Billing tools the operator authorized before this run started, so an unattended
58
70
  // session can spend what it was told it may spend instead of denying every media
59
71
  // call for want of a human. Lifts `alwaysAsk` and nothing else — see
@@ -138,10 +150,18 @@ export async function decideToolCall(
138
150
  };
139
151
  }
140
152
 
153
+ // Private mode sits above every mode, allowlist and approval: nothing a person or a
154
+ // host answers can show a file to an unverified model or send it off the machine.
155
+ if (ctrl.getPrivate?.() || privateModeFromEnv()) {
156
+ const refusal = await privateRefusal(toolName, input, ctx.model);
157
+ if (refusal) return { block: true, reason: refusal };
158
+ }
159
+
141
160
  const req = classifyToolCall(toolName, input, {
142
161
  cwd: ctrl.cwd,
143
162
  confineToCwd: ctrl.confineToCwd,
144
163
  allowedOutsideRoots: ctrl.allowedOutsideRoots,
164
+ guardSecretReads: ctrl.hardConfine,
145
165
  });
146
166
  if (!req) return undefined; // no gate needed — read-only/in-scope/meta
147
167
 
@@ -181,6 +201,7 @@ export async function decideToolCall(
181
201
  getNoQuarter: ctrl.getNoQuarter,
182
202
  getAutoApprove: ctrl.getAutoApprove,
183
203
  getSkipAllPermissions: ctrl.getSkipAllPermissions,
204
+ hardConfine: ctrl.hardConfine,
184
205
  isSpendPreauthorized: ctrl.isSpendPreauthorized
185
206
  ? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
186
207
  : undefined,
@@ -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
  }
@@ -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
+ }
@@ -1,4 +1,7 @@
1
- import { basename } from "node:path";
1
+ import { basename, dirname, join, sep } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { realpathSync } from "node:fs";
4
+ import { agentDir, globalDir } from "../config/paths.ts";
2
5
 
3
6
  // Files we never auto-edit, even under acceptEdits/allowlist: shell rc / git /
4
7
  // package-manager / secrets that a coding task should not silently rewrite. A
@@ -27,3 +30,35 @@ export function isProtectedPath(p: string): boolean {
27
30
  const name = basename(p);
28
31
  return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
29
32
  }
33
+
34
+ // Where this machine keeps credentials that are never a coding task's business: the
35
+ // standard per-user secret directories, and the files at the top of our OWN state
36
+ // dirs — credentials.json, agent/auth.json, terminal-key.json, config.json (bot
37
+ // tokens, provider keys), mcp.json, account-trust.json and the rest. Only the TOP
38
+ // level of ~/.privateer and its agent dir: their subdirectories (routines output,
39
+ // projects, sessions) are ordinary data a run may legitimately be pointed at.
40
+ //
41
+ // Consulted only by surfaces that opt in (ScopeOptions.guardSecretReads — the
42
+ // harbor's unattended runs), where a READ of one of these is refused even inside the
43
+ // working directory: an unattended run that fetches a web page is one prompt
44
+ // injection away from reading a token and putting it in the next URL it fetches.
45
+ const SECRET_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".docker", join(".config", "gh"), join(".config", "gcloud")];
46
+
47
+ function canonical(p: string): string {
48
+ try {
49
+ return realpathSync(p);
50
+ } catch {
51
+ return p;
52
+ }
53
+ }
54
+
55
+ function within(root: string, abs: string): boolean {
56
+ return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
57
+ }
58
+
59
+ export function isCredentialStorePath(abs: string): boolean {
60
+ const home = homedir();
61
+ if (SECRET_DIRS.some((d) => within(canonical(join(home, d)), abs))) return true;
62
+ const parent = dirname(abs);
63
+ return parent === canonical(globalDir()) || parent === canonical(agentDir());
64
+ }
@@ -29,6 +29,8 @@ import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.
29
29
  import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
30
30
  import { createUIContext } from "../ext/headlessUi.ts";
31
31
  import { noQuarterActive } from "../permissions/noQuarter.ts";
32
+ import type { AccountLease } from "../harbor/accountLease.ts";
33
+ import { closeSession } from "../harbor/closeSession.ts";
32
34
 
33
35
  export interface LiveTaskHandle {
34
36
  termId: string;
@@ -41,6 +43,10 @@ export interface LiveTaskDeps {
41
43
  parseSpec: (spec: string) => { provider: string; modelId: string };
42
44
  log: (msg: string) => void;
43
45
  onClosed: (termId: string) => void;
46
+ // The harbor's shared account session (harbor/accountLease.ts). The credential slot is
47
+ // one per process, so a spawn minting and revoking its own killed whatever routine was
48
+ // mid-turn beside it. Absent → this session mints and revokes its own (standalone use).
49
+ account?: AccountLease;
44
50
  // Deliver the session's closing answer durably (the harbor seals it to the account
45
51
  // outbox, so it lands in the app's inbox). A live spawn's feed is otherwise purely
46
52
  // ephemeral: close the screen, reap the session, and everything it said is gone —
@@ -72,6 +78,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
72
78
  let initialPromptSent = false;
73
79
  let stopped = false;
74
80
  let spawnedAccount = false;
81
+ let leasedAccount = false;
75
82
 
76
83
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
77
84
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -111,10 +118,16 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
111
118
  if (attachTimer) clearTimeout(attachTimer);
112
119
  if (lifeTimer) clearTimeout(lifeTimer);
113
120
  try { relay?.stop(); } catch { /* already stopped */ }
121
+ if (session) {
122
+ try { await session.abort?.(); } catch { /* nothing in flight */ }
123
+ await closeSession(session); // MCP connections, listeners — see closeSession.ts
124
+ }
114
125
  attachments.cleanup(); // drop the scratch dir holding inbound file bytes
115
126
  // Revoke ONLY this session's account inference session so it doesn't linger in the
116
127
  // app's Linked Devices; the harbor's own child session stays alive. Best-effort.
117
- if (spawnedAccount) {
128
+ if (leasedAccount) {
129
+ await deps.account!.release();
130
+ } else if (spawnedAccount) {
118
131
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
119
132
  // Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
120
133
  // must not delete an interactive terminal's entry (see providers/account.ts).
@@ -228,7 +241,10 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
228
241
  });
229
242
 
230
243
  const { provider, modelId } = deps.parseSpec(modelSpec);
231
- if (provider === "privateer") {
244
+ if (provider === "privateer" && deps.account) {
245
+ leasedAccount = true; // set before the await, so stop() always releases what we took
246
+ await deps.account.acquire();
247
+ } else if (provider === "privateer") {
232
248
  try {
233
249
  const creds = await spawnAccountCredentials();
234
250
  await persistAccountCredential(creds);