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.
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).
@@ -126,6 +130,9 @@ silently. The moat is swappable; the floor under it holds.
126
130
  and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
127
131
  machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
128
132
  makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
133
+ - **Named terminals.** The agent can open windows you watch — a dev server, a REPL, a second
134
+ agent — give each a name, and bring any of them to the front; `/term api` switches you there
135
+ yourself. macOS Terminal, iTerm2, and tmux anywhere. See [Named terminals](#named-terminals).
129
136
  - **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
130
137
  or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
131
138
  parallel sub-agents, and drop in skills — all gated like everything else.
@@ -359,8 +366,9 @@ One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab`
359
366
  by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
360
367
 
361
368
  Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
362
- speak. The mic opens only on something you did (`/talk`, the push-to-talk key, or a
363
- conversation turn you switched on); there is no wake word, capture is hard-capped, audio is
369
+ speak. The mic opens only on something you did (`/talk`, the push-to-talk key, a
370
+ conversation turn you switched on, or switching to a window while dictation or conversation
371
+ mode is on); there is no wake word, capture is hard-capped, audio is
364
372
  held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
365
373
  every tool call it leads to hits the same permission gate as anything you type. Recording
366
374
  needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
@@ -368,6 +376,37 @@ needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` o
368
376
  standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
369
377
  in any Pi agent.
370
378
 
379
+ ## Named terminals
380
+
381
+ Ask for "a terminal running the dev server" and the agent opens a new window, names it, and
382
+ puts it in front of you. Every Privateer window names itself too (after its folder, or the
383
+ name the agent gave it), so you and the agent can hop between them by name:
384
+
385
+ ```
386
+ /term pick a terminal to switch to
387
+ /term api switch to "api"
388
+ /term open api npm run dev open one yourself
389
+ /term name backend rename this window
390
+ /term close api close it, ending what runs there
391
+ /term list every named terminal (← marks this one)
392
+ ```
393
+
394
+ The agent does the same through its `terminal` tool. Opening a window with a command goes
395
+ through the permission gate exactly like running that command in bash; switching and listing
396
+ don't ask. The agent can't read what a window prints: that's what bash and background tasks
397
+ are for. These windows are for you.
398
+
399
+ Voice follows you between windows. Every window shares one voice setup, so with
400
+ dictation or conversation mode on, the window you switch to listens for your next prompt,
401
+ including a new agent the moment its window opens in front. The window you left pauses
402
+ its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
403
+ awareness; it needs a terminal that reports focus.)
404
+
405
+ Works in **macOS Terminal**, **iTerm2**, and inside **tmux** on any platform; other terminals
406
+ get told so. macOS may ask once for permission to let the terminal control itself. Names are
407
+ shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
408
+ that's been closed drops off the list on its own.
409
+
371
410
  ## The Privateer app
372
411
 
373
412
  The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
@@ -510,6 +549,8 @@ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
510
549
  Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
511
550
  every approval arrives as a `session/request_permission` call it answers. The wire format,
512
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).
513
554
 
514
555
  ### One-shot runs (`-p`) and spending
515
556
 
@@ -531,6 +572,36 @@ call. `media_capabilities` shows the same prices. A call that can't be priced is
531
572
  under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
532
573
  open or running in the background.
533
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
+
534
605
  ## Connectors — MCP
535
606
 
536
607
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -702,7 +773,10 @@ agent is stuck),
702
773
  `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
703
774
  [`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
704
775
  `privateer -p … [--allow-spend …] [--approve-in-app]` (see
705
- [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`.
706
780
  An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
707
781
 
708
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: "Searchable model picker —", cmd: "/models" },
289
- { text: "Start over with a new agent —", cmd: "/fresh" },
290
- { text: "Text-only models can read images now" },
291
- { text: "Live crypto prices and Solana wallets" },
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).
@@ -61,6 +61,7 @@ import {
61
61
  } from "privateer-speak";
62
62
  import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
63
63
  import { globalDir } from "../src/config/paths.ts";
64
+ import { thisTerminalIsFront } from "../src/terminals/index.ts";
64
65
  import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
65
66
 
66
67
  const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
@@ -358,7 +359,14 @@ export default function privateerSpeak(pi: any): void {
358
359
  // voiceCommands: saying one of these AS THE WHOLE UTTERANCE runs /fresh (see
359
360
  // extensions/privateer-fresh.ts) instead of sending the words to the model. Whole
360
361
  // utterance only, so "why did the fresh start fail" is still a question.
361
- makePiSpeakExtension({ configFile: join(globalDir(), "speak.json"), voiceCommands: VOICE_COMMANDS })(pi);
362
+ // isFocused: a window that has just started (say, one the agent opened with the
363
+ // terminal tool) asks Terminal/iTerm2 whether it's in front, so a hands-free mode
364
+ // carries on in the new agent without a keypress — see src/terminals/.
365
+ makePiSpeakExtension({
366
+ configFile: join(globalDir(), "speak.json"),
367
+ voiceCommands: VOICE_COMMANDS,
368
+ isFocused: thisTerminalIsFront,
369
+ })(pi);
362
370
  }
363
371
 
364
372
  const VOICE_COMMANDS: Record<string, string> = {
@@ -0,0 +1,73 @@
1
+ // Named terminals — the `terminal` tool and /term, so the agent can open windows the
2
+ // user watches (a dev server, a REPL, a second agent) and either of them can switch
3
+ // between those windows by name. See src/terminals/ for how windows are found and why
4
+ // only tmux, macOS Terminal and iTerm2 are driven.
5
+ //
6
+ // Interactive only: the window registers itself at session start, which a harbor or ACP
7
+ // host has no window to do, and the tool refuses without a UI.
8
+
9
+ import { closeTerminal, describeListing, focusTerminal, openTerminal, renameSelf, terminalListing, backend, claimSelf } from "../src/terminals/index.ts";
10
+ import { terminalToolDefinition } from "../src/tools/terminal.ts";
11
+
12
+ const USAGE =
13
+ "/term — pick a terminal to switch to · /term <name> — switch · /term open <name> [command] · " +
14
+ "/term name <name> — rename this one · /term close <name> · /term list";
15
+
16
+ export default function privateerTerminals(pi: any): void {
17
+ pi.registerTool?.(terminalToolDefinition);
18
+
19
+ // Name this window as soon as the session starts, so it can be switched back to from
20
+ // any other — not only after its first /term.
21
+ pi.on?.("session_start", (_e: any, ctx: any) => {
22
+ if (!ctx?.hasUI) return;
23
+ const b = backend();
24
+ if ("unsupported" in b) return;
25
+ void claimSelf(b, ctx.cwd ?? process.cwd()).catch(() => undefined);
26
+ });
27
+
28
+ pi.registerCommand?.("term", {
29
+ description: "Switch between named terminals, or open one",
30
+ handler: async (args: string, ctx: any) => {
31
+ const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
32
+ const cwd = ctx?.cwd ?? process.cwd();
33
+ const [sub = "", ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
34
+ const report = (o: { ok: boolean; message: string }) => notify(o.message, o.ok ? "info" : "warning");
35
+
36
+ switch (sub.toLowerCase()) {
37
+ case "": {
38
+ const l = await terminalListing(cwd);
39
+ if ("unsupported" in l) return notify(l.unsupported, "warning");
40
+ const others = l.records.filter((r) => r.id !== l.selfId);
41
+ if (!others.length) return notify(`No other named terminals. ${USAGE}`);
42
+ if (typeof ctx?.ui?.select !== "function") return notify(describeListing(l));
43
+ const labels = others.map((r) => `${r.name} ${r.command ?? r.cwd}`);
44
+ const picked = await ctx.ui.select("Switch to terminal", labels);
45
+ const rec = picked ? others[labels.indexOf(picked)] : undefined;
46
+ if (rec) report(await focusTerminal(rec.name, cwd));
47
+ return;
48
+ }
49
+ case "list": {
50
+ const l = await terminalListing(cwd);
51
+ return notify("unsupported" in l ? l.unsupported : describeListing(l), "unsupported" in l ? "warning" : "info");
52
+ }
53
+ case "open": {
54
+ const [name, ...cmd] = rest;
55
+ if (!name) return notify("Usage: /term open <name> [command]", "warning");
56
+ return report(await openTerminal({ name, cwd, ...(cmd.length ? { command: cmd.join(" ") } : {}) }));
57
+ }
58
+ case "name":
59
+ case "rename":
60
+ if (!rest.length) return notify("Usage: /term name <name>", "warning");
61
+ return report(await renameSelf(rest.join(" "), cwd));
62
+ case "close":
63
+ if (!rest.length) return notify("Usage: /term close <name>", "warning");
64
+ return report(await closeTerminal(rest.join(" "), cwd));
65
+ case "help":
66
+ return notify(USAGE);
67
+ default:
68
+ // Anything else is a name: /term api, /term "dev server".
69
+ return report(await focusTerminal([sub, ...rest].join(" "), cwd));
70
+ }
71
+ },
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.58",
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": {
@@ -86,7 +87,7 @@
86
87
  "pi-privacy": "0.14.1",
87
88
  "pi-subagents": "0.70.0",
88
89
  "picomatch": "4.0.5",
89
- "privateer-speak": "0.6.0",
90
+ "privateer-speak": "0.7.0",
90
91
  "privateer-workflow": "0.1.0",
91
92
  "tinfoil": "1.1.11",
92
93
  "tsx": "4.23.0",
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 {
@@ -20,6 +20,7 @@
20
20
  { "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
21
21
  { "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
22
22
  { "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
23
+ { "entry": "extensions/privateer-terminals.ts", "name": "privateer-terminals", "note": "named terminal windows — the terminal tool + /term to switch between them" },
23
24
  { "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
24
25
  { "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
25
26
  { "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
@@ -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,