privateer-agent 0.12.14 → 0.12.16

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
@@ -296,6 +296,20 @@ skipped entirely on an attested TEE or on-device channel, which provably can't r
296
296
  prompt anyway. It's best-effort structured-PII detection, labeled as such — a safety net, not
297
297
  a guarantee.
298
298
 
299
+ Pattern detection fires on anything email-*shaped*, so some of what it finds isn't personal
300
+ data at all. **`/privacy allow <value>`** is where you say so — an address
301
+ (`me@acme.com`), a domain (`@acme.com`), an IPv4 block (`10.0.0.0/8`), or any exact or
302
+ globbed value. Entries live in `privacy.piiAllow` in `~/.privateer/config.json`, apply from
303
+ the next turn (no relaunch), and persist across sessions; `/privacy` on its own lists them
304
+ and `/privacy unallow <value>` puts one back under the gate. Reserved shapes —
305
+ `example.com`, loopback, `noreply@…`, `@users.noreply.github.com` — are allowed out of the
306
+ box. `PI_PRIVACY_*` env vars and a `pi-privacy.config.json` are honoured too (a
307
+ project-local file can only ever make the gate *stricter*).
308
+
309
+ Under **no quarter** the gate doesn't ask — there's nobody to ask — so it redacts and sends,
310
+ and prints what it masked. That's the one case where a false positive changes what the model
311
+ sees without you seeing it first, which is why the notice tells you `/privacy allow` exists.
312
+
299
313
  ## Privateer account (billed inference)
300
314
 
301
315
  Instead of bringing your own key, run **`/signin`** to sign into a Privateer account. Your
@@ -396,6 +410,12 @@ Download for [macOS](https://privateer.pro/download/mac) (Apple silicon),
396
410
  [macOS Intel](https://privateer.pro/download/mac-intel), or
397
411
  [Windows](https://privateer.pro/download/windows).
398
412
 
413
+ Once it's installed, **`/desktop`** in the terminal brings it up — no Spotlight detour. It
414
+ opens the app, not a copy of this conversation: the desktop hosts its own session, so pick
415
+ the folder you were working in from **File ▸ Spawn Privateer at…** and it starts on the same
416
+ model and connectors this terminal uses (the per-folder defaults live in `~/.privateer`,
417
+ which both read).
418
+
399
419
  It's an early release and **not yet code-signed or notarized** — macOS will warn on first
400
420
  open. Routines and channels deliberately aren't hosted here: those belong to the always-on
401
421
  harbor, so background work still wants `privateer harbor install`.
@@ -640,6 +660,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
640
660
  | `/extensions` | list loaded Pi extensions |
641
661
  | `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
642
662
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
663
+ | `/desktop` | open the [desktop app](#desktop-app) — same login, same per-folder defaults |
643
664
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
644
665
 
645
666
  Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
@@ -0,0 +1,54 @@
1
+ // /desktop — bring up the Privateer desktop app from the terminal.
2
+ //
3
+ // The two front ends already share everything that matters: one ~/.privateer home,
4
+ // so one login, one model config, one MCP catalog, one set of per-folder spawn
5
+ // defaults. What was missing was a way across. Getting from a terminal to the app
6
+ // meant leaving the terminal — Spotlight, the Dock, a Start-menu hunt — which is
7
+ // exactly the kind of small friction that leaves a shipped app unopened.
8
+ //
9
+ // What it does NOT do is carry the conversation over: the desktop hosts its own
10
+ // in-process session and takes no folder argument (see src/config/desktopApp.ts),
11
+ // so this opens the app and says how to point a window at the folder you were just
12
+ // working in. When the app isn't installed we say so once, with the download page
13
+ // for this platform, and never again unasked — the working-line tip in
14
+ // privateer-hints.ts only fires on a machine that HAS it.
15
+
16
+ import {
17
+ desktopAppPath,
18
+ desktopDownloadAltUrl,
19
+ desktopDownloadUrl,
20
+ openDesktopApp,
21
+ } from "../src/config/desktopApp.ts";
22
+
23
+ export default function privateerDesktop(pi: any): void {
24
+ pi.registerCommand?.("desktop", {
25
+ description: "Open the Privateer desktop app — same login, same per-folder defaults",
26
+ handler: (_args: string, ctx: any) => {
27
+ const app = desktopAppPath();
28
+
29
+ if (app) {
30
+ const cwd = process.cwd();
31
+ if (openDesktopApp(app)) {
32
+ ctx?.ui?.notify?.(
33
+ `Opening the Privateer desktop app — same login and per-folder defaults as this terminal. ` +
34
+ `File ▸ Spawn Privateer at… points a window at ${cwd}.`,
35
+ "info",
36
+ );
37
+ } else {
38
+ ctx?.ui?.notify?.(`Could not launch ${app} — open it yourself and this terminal keeps working.`, "error");
39
+ }
40
+ return;
41
+ }
42
+
43
+ const url = desktopDownloadUrl();
44
+ const alt = desktopDownloadAltUrl(); // the other Mac build — see desktopApp.ts
45
+ ctx?.ui?.notify?.(
46
+ url
47
+ ? `The Privateer desktop app isn't installed here. Download: ${url}${alt ? ` (other Macs: ${alt})` : ""} — ` +
48
+ `it reads the same ~/.privateer, so it starts already signed in with your models and connectors.`
49
+ : `The desktop app ships for macOS and Windows only — on this platform the terminal agent is the app.`,
50
+ "info",
51
+ );
52
+ },
53
+ });
54
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from "../src/remote/subagentRelay.ts";
22
22
  import { RelayClient } from "../src/remote/relayClient.ts";
23
23
  import { makeSendFileTool } from "../src/tools/sendFile.ts";
24
+ import { makeSaveCargoTool } from "../src/tools/cargo.ts";
24
25
  import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
25
26
  import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
26
27
  import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
@@ -474,6 +475,11 @@ export default function privateerControl(pi: any): void {
474
475
  // driving. The daemon no longer loads this file, so there is nothing to shadow.
475
476
  pi.registerTool?.(makeSendFileTool(bridge));
476
477
  pi.registerTool?.(makeSaveAttachmentTool(attachments));
478
+ // save_cargo belongs with them: it is the same bridge, the same "needs a connected
479
+ // app" precondition, and the same failure mode if it were registered anywhere the
480
+ // relay isn't this file's. Unlike the pair it hands the app PLAINTEXT to encrypt —
481
+ // the terminal has no master key, so the round trip is the feature (cargoSave.ts).
482
+ pi.registerTool?.(makeSaveCargoTool(bridge));
477
483
 
478
484
  // Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
479
485
  // no one can approve, so a "default" gate would fail-closed on every tool and the
@@ -25,6 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { keyText } from "@earendil-works/pi-coding-agent";
27
27
  import { configPath, globalDir } from "../src/config/paths.ts";
28
+ import { desktopAppPath } from "../src/config/desktopApp.ts";
28
29
 
29
30
  const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
30
31
  const EVERY_MS = 12_000;
@@ -76,9 +77,28 @@ const HINTS: Array<() => string> = [
76
77
  ? `${k} is push-to-talk — /speak on reads the answer back`
77
78
  : `/talk types what you say — /speak on reads the answer back`;
78
79
  },
80
+ () => (haveDesktopApp() ? `/desktop opens the Privateer desktop app — same login, same folder defaults` : ""),
79
81
  () => `these tips are /hints — /hints off silences them`,
80
82
  ];
81
83
 
84
+ // A hint returns "" when it doesn't apply to THIS machine, and the rotation skips
85
+ // it. The desktop tip is the case that needs it: naming a command for an app the
86
+ // user has is discoverability, advertising one they haven't is an ad. Memoised
87
+ // because the rotation asks every 12 s and the answer is a stat() on a path that
88
+ // doesn't change under a running terminal (installing the app mid-session is worth
89
+ // a restart, not a filesystem poll).
90
+ let desktopSeen: boolean | undefined;
91
+ function haveDesktopApp(): boolean {
92
+ if (desktopSeen === undefined) {
93
+ try {
94
+ desktopSeen = desktopAppPath() !== null;
95
+ } catch {
96
+ desktopSeen = false;
97
+ }
98
+ }
99
+ return desktopSeen;
100
+ }
101
+
82
102
  // Default ON: absent file, absent block, or unreadable JSON all mean enabled.
83
103
  // Only an explicit { hints: { enabled: false } } turns the rotation off.
84
104
  function hintsEnabled(): boolean {
@@ -119,9 +139,20 @@ export default function privateerHints(pi: any): void {
119
139
  if (restoreDefault) uiRef?.setWorkingMessage?.();
120
140
  };
121
141
 
142
+ // The next hint that applies here, advancing the cursor past any that opted out.
143
+ // Bounded by the list length, so an all-empty list ends the rotation instead of
144
+ // spinning through it forever.
145
+ const nextHint = (): string => {
146
+ for (let i = 0; i < HINTS.length; i++) {
147
+ const text = HINTS[cursor++ % HINTS.length]();
148
+ if (text) return text;
149
+ }
150
+ return "";
151
+ };
152
+
122
153
  const showNext = (): void => {
123
- const hint = HINTS[cursor % HINTS.length]();
124
- cursor++;
154
+ const hint = nextHint();
155
+ if (!hint) return; // nothing applies on this machine — leave "Working..." alone
125
156
  uiRef?.setWorkingMessage?.(`Working... · tip: ${hint}`);
126
157
  timer = setTimeout(showNext, EVERY_MS);
127
158
  };
@@ -23,9 +23,8 @@
23
23
  // win regardless of the order pi discovers extensions in. This is purely a
24
24
  // display/resolution + routing list — posture and attestation are dispatcher-bound and
25
25
  // unaffected by the model set.
26
- import { makePiPrivacyExtension } from "pi-privacy";
27
26
  import { registerAccountModels } from "../src/providers/account.ts";
28
- import { sharedPrivacyOptions } from "../src/config/privacyPolicy.ts";
27
+ import { privacyExtension } from "../src/config/privacyPolicy.ts";
29
28
 
30
29
  // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), kimi-k2-6 first — the
31
30
  // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
@@ -55,9 +54,10 @@ function tinfoilModel(id: string) {
55
54
 
56
55
  // One configuration, shared with the factory-built copy in src/config/moat.ts — the tier
57
56
  // resolver for the private ACCOUNT channel, the unattended/no-quarter handling, the ingest
58
- // policy. Adding an option HERE rather than there is how this file and the moat drifted
59
- // twice; src/config/privacyPolicy.ts records what that cost.
60
- const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
57
+ // policy, the operator's PII allowlist and the `/privacy` command that maintains it.
58
+ // Adding an option HERE rather than there is how this file and the moat drifted twice;
59
+ // src/config/privacyPolicy.ts records what that cost.
60
+ const privacy = privacyExtension();
61
61
 
62
62
  export default function privateerPrivacy(pi: any): void {
63
63
  privacy(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.14",
3
+ "version": "0.12.16",
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",
@@ -81,7 +81,7 @@
81
81
  "@zed-industries/agent-client-protocol": "0.4.5",
82
82
  "patch-package": "8.0.1",
83
83
  "pi-mcp-adapter": "2.11.0",
84
- "pi-privacy": "0.11.0",
84
+ "pi-privacy": "0.13.0",
85
85
  "pi-subagents": "0.34.0",
86
86
  "picomatch": "4.0.5",
87
87
  "privateer-speak": "0.2.2",
package/src/cli/chat.ts CHANGED
@@ -14,6 +14,7 @@ import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
14
14
  import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
15
15
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
16
16
  import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
17
+ import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
17
18
 
18
19
  // This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
19
20
  // itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
@@ -47,6 +48,7 @@ async function main() {
47
48
  persistAccountCredential,
48
49
  dropPersistedAccountCredential,
49
50
  ensureAccountArmed,
51
+ verificationLink,
50
52
  } = await import("../providers/account.ts");
51
53
  const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
52
54
  const { agentVersion } = await import("../config/version.ts");
@@ -637,9 +639,24 @@ async function main() {
637
639
  try {
638
640
  const user = await priv.runDeviceLogin({
639
641
  onCode: (code: any) => {
640
- console.log(`\n${CYAN}Approve this terminal in the Privateer app:${RESET}`);
641
- console.log(` code: ${YELLOW}${code.user_code}${RESET}`);
642
- if (code.verification_uri_complete) console.log(` or open: ${DIM}${code.verification_uri_complete}${RESET}`);
642
+ // Browser-first, the same deal the TUI's /login widget makes: the URL carries
643
+ // the code, so the page lands straight on Authorize and the user clicks
644
+ // rather than types. verificationLink makes the server's scheme-less value
645
+ // absolute; canOpenBrowser decides the wording SYNCHRONOUSLY (SSH or a
646
+ // headless box keeps the old app-approve copy, because a launcher there
647
+ // would open on the wrong machine); and the link is printed either way, so
648
+ // an open that silently fails costs nothing.
649
+ const uri = verificationLink(code.verification_uri_complete ?? code.verification_uri);
650
+ const opening = Boolean(uri) && canOpenBrowser();
651
+ if (opening) void openInBrowser(uri);
652
+ console.log(
653
+ opening
654
+ ? `\n${CYAN}Authorize this terminal in the browser window that just opened:${RESET}`
655
+ : `\n${CYAN}Approve this terminal in the Privateer app:${RESET}`,
656
+ );
657
+ const match = opening ? `${DIM} — check it matches the one in your browser${RESET}` : "";
658
+ console.log(` code: ${YELLOW}${code.user_code}${RESET}${match}`);
659
+ if (uri) console.log(` ${opening ? "no browser? open" : "or open"}: ${DIM}${uri}${RESET}`);
643
660
  console.log(`${DIM} waiting for approval…${RESET}`);
644
661
  },
645
662
  });
@@ -0,0 +1,138 @@
1
+ // Where the Privateer DESKTOP app lives on this machine, and how to bring it up.
2
+ //
3
+ // The desktop app and this terminal are two front ends over one home: both read
4
+ // ~/.privateer, so they share the account login, the model config, the MCP catalog
5
+ // and the per-folder spawn defaults (config/spawns.ts). That makes "open the app"
6
+ // a genuinely useful thing for a terminal to offer — nothing is handed over, the
7
+ // state is already common — which is what /desktop (extensions/privateer-desktop.ts)
8
+ // does, and what the working-line tip in privateer-hints.ts points at.
9
+ //
10
+ // WHY DETECTION AND NOT JUST A DOWNLOAD LINK. A tip that advertises software the
11
+ // user hasn't got is an ad; one that names a command for software they HAVE is
12
+ // discoverability. So both consumers ask this module first, and the hint stays
13
+ // silent on a machine with no app installed.
14
+ //
15
+ // NO FOLDER HANDOFF. The app takes no path argument today — its second-instance
16
+ // handler just focuses the running window (desktop/src/main/main.mjs) — so /desktop
17
+ // opens the app, and the user picks this folder from File ▸ Spawn Privateer at…,
18
+ // where the spawn record this terminal already shares makes it start on the same
19
+ // model and connectors. If the app ever learns a folder argv, this is the one place
20
+ // that has to change.
21
+ //
22
+ // IMPORT-SAFETY: node builtins only, no Pi imports, no side effects — safe from an
23
+ // extension under jiti and from a pre-boot entry alike.
24
+
25
+ import { spawn } from "node:child_process";
26
+ import { existsSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { basename, join } from "node:path";
29
+
30
+ /** Per-platform download pages (README ▸ Desktop app). No Linux build exists. */
31
+ const DOWNLOAD_MAC = "https://privateer.pro/download/mac";
32
+ const DOWNLOAD_MAC_INTEL = "https://privateer.pro/download/mac-intel";
33
+ const DOWNLOAD_WINDOWS = "https://privateer.pro/download/windows";
34
+
35
+ /**
36
+ * The download page to lead with on THIS machine, or null where we ship no desktop
37
+ * build. macOS is split by CPU family — the arm64 dmg won't run on an Intel Mac —
38
+ * and process.arch is the interpreter's answer, not the machine's: an Apple-silicon
39
+ * Mac running a Rosetta node reports x64 and would be led to the Intel build. That
40
+ * is survivable rather than solved (detecting the translation means shelling out to
41
+ * `sysctl sysctl.proc_translated` for one link) because the OTHER build is offered
42
+ * alongside it — see desktopDownloadAltUrl.
43
+ */
44
+ export function desktopDownloadUrl(
45
+ platform: NodeJS.Platform = process.platform,
46
+ arch: string = process.arch,
47
+ ): string | null {
48
+ if (platform === "darwin") return arch === "x64" ? DOWNLOAD_MAC_INTEL : DOWNLOAD_MAC;
49
+ if (platform === "win32") return DOWNLOAD_WINDOWS;
50
+ return null; // electron-builder.yml targets mac + win only
51
+ }
52
+
53
+ /**
54
+ * The macOS build for the OTHER CPU family, so a Mac user is never one wrong arch
55
+ * away from a download that won't launch. Null everywhere else: Windows ships one
56
+ * x64 installer and there is no Linux build at all.
57
+ */
58
+ export function desktopDownloadAltUrl(
59
+ platform: NodeJS.Platform = process.platform,
60
+ arch: string = process.arch,
61
+ ): string | null {
62
+ if (platform !== "darwin") return null;
63
+ return arch === "x64" ? DOWNLOAD_MAC : DOWNLOAD_MAC_INTEL;
64
+ }
65
+
66
+ /**
67
+ * The installed app, or null. Ordered by confidence:
68
+ *
69
+ * 1. OUR OWN interpreter, when the terminal is running on the app's bundled Node.
70
+ * The desktop's CLI shim runs `privateer` through the app binary itself with
71
+ * ELECTRON_RUN_AS_NODE=1 (desktop/src/main/cliShim.mjs), so process.execPath IS
72
+ * the app — and it's the copy the user actually installed, wherever they put it.
73
+ * 2. The standard install locations. macOS drag-install goes to /Applications or
74
+ * ~/Applications; the Windows installer is per-user NSIS (perMachine: false) so
75
+ * %LOCALAPPDATA%\Programs\Privateer is the default, with Program Files covered
76
+ * for an install that chose it (allowToChangeInstallationDirectory: true).
77
+ *
78
+ * A user who installed somewhere else entirely reads as "not installed" — which
79
+ * costs them a download link they don't need, and never a wrong app launched.
80
+ */
81
+ export function desktopAppPath(
82
+ platform: NodeJS.Platform = process.platform,
83
+ env: Record<string, string | undefined> = process.env,
84
+ execPath: string = process.execPath,
85
+ ): string | null {
86
+ const hit = (p: string): string | null => (p && existsSync(p) ? p : null);
87
+
88
+ if (platform === "darwin") {
89
+ // …/Privateer.app/Contents/MacOS/Privateer → …/Privateer.app
90
+ const marker = "/Privateer.app/";
91
+ const at = execPath.indexOf(marker);
92
+ if (at >= 0) {
93
+ const bundle = hit(execPath.slice(0, at + marker.length - 1));
94
+ if (bundle) return bundle;
95
+ }
96
+ return hit("/Applications/Privateer.app") ?? hit(join(homedir(), "Applications", "Privateer.app"));
97
+ }
98
+
99
+ if (platform === "win32") {
100
+ if (/^privateer\.exe$/i.test(basename(execPath))) {
101
+ const self = hit(execPath);
102
+ if (self) return self;
103
+ }
104
+ const local = env.LOCALAPPDATA;
105
+ const files = env.ProgramFiles;
106
+ return (
107
+ (local ? hit(join(local, "Programs", "Privateer", "Privateer.exe")) : null) ??
108
+ (files ? hit(join(files, "Privateer", "Privateer.exe")) : null)
109
+ );
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Launch (or focus) the desktop app. Best-effort and never throws — the caller
117
+ * reports the failure as text, the way openBrowser.ts does.
118
+ *
119
+ * ELECTRON_RUN_AS_NODE IS STRIPPED, and that is load-bearing rather than tidy: when
120
+ * this terminal was itself started by the desktop's CLI shim, that variable is set
121
+ * in our environment, and a child inheriting it starts the app binary as a bare Node
122
+ * that exits without ever drawing a window. `open` goes through LaunchServices and
123
+ * wouldn't pass it on anyway; the Windows path spawns the exe directly and would.
124
+ */
125
+ export function openDesktopApp(appPath: string, platform: NodeJS.Platform = process.platform): boolean {
126
+ const { ELECTRON_RUN_AS_NODE: _drop, ...env } = process.env;
127
+ try {
128
+ const child =
129
+ platform === "darwin"
130
+ ? spawn("open", ["-a", appPath], { detached: true, stdio: "ignore", env })
131
+ : spawn(appPath, [], { detached: true, stdio: "ignore", env });
132
+ child.on("error", () => {}); // a spawn failure must not raise on the event loop
133
+ child.unref(); // never hold the CLI's exit open
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
@@ -34,6 +34,7 @@ import { basename } from "node:path";
34
34
  import { managedNames } from "./moatManifest.ts";
35
35
  import type { GateController } from "../ext/permissionGate.ts";
36
36
  import type { SendFileBridge } from "../tools/sendFile.ts";
37
+ import type { CargoSaveBridge } from "../tools/cargo.ts";
37
38
  import type { AttachmentStore } from "../util/attachmentStore.ts";
38
39
 
39
40
  /** A Pi extension factory, as DefaultResourceLoader takes them. */
@@ -61,7 +62,7 @@ export interface MoatOptions {
61
62
  * its module-level bridge and stands them down inside the daemon, so a live spawn's own
62
63
  * pair is what the model gets (see tools/relayFileTools.ts).
63
64
  */
64
- relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
65
+ relayFiles?: { bridge: SendFileBridge & CargoSaveBridge; attachments: AttachmentStore };
65
66
  /**
66
67
  * THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
67
68
  * a path whose result reaches the app's Inbox — a scheduled routine, a submitted
@@ -176,17 +177,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
176
177
  if (!caps) throw new Error(`buildMoat: unknown kind "${opts.kind}"`);
177
178
 
178
179
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
179
- const { makePiPrivacyExtension } = await import("pi-privacy");
180
180
  const { makeAccountProvider } = await import("../providers/account.ts");
181
181
  const { webEnabled, mediaEnabled } = await import("./hosted.ts");
182
- const { sharedPrivacyOptions } = await import("./privacyPolicy.ts");
182
+ const { privacyExtension } = await import("./privacyPolicy.ts");
183
183
 
184
184
  const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
185
185
 
186
186
  // pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
187
187
  // extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
188
188
  // bugs that came of configuring it in two.
189
- factories.push(makePiPrivacyExtension(sharedPrivacyOptions()));
189
+ factories.push(privacyExtension());
190
190
  factories.push(makeAccountProvider()); // must follow pi-privacy — see header
191
191
 
192
192
  if (opts.relayFiles) {
@@ -11,6 +11,7 @@
11
11
  { "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
12
12
  { "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
13
13
  { "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
14
+ { "name": "privateer-desktop", "entry": "extensions/privateer-desktop.ts", "note": "/desktop — open the Privateer desktop app" },
14
15
  { "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
15
16
  { "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
16
17
  { "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
@@ -0,0 +1,117 @@
1
+ // The PII allowlist the OPERATOR controls — "that string is not personal data, stop
2
+ // masking it."
3
+ //
4
+ // WHY THIS EXISTS. pi-privacy's detection is pattern-based and honest about it: it fires
5
+ // on anything email-SHAPED or address-SHAPED. It refuses the shapes that are impossible
6
+ // for a type (see its precision guards), but plenty of real-world strings are genuinely
7
+ // ambiguous — a bare `8.0.0.0` is a version quad AND a valid address, an internal
8
+ // hostname list is a list of addresses, a fixture mailbox is an address nobody reads. For
9
+ // those the only correct answer comes from the person, and until now Privateer gave them
10
+ // nowhere to put it: sharedPrivacyOptions() hand-built pi-privacy's options and never
11
+ // called its config loader, so `PI_PRIVACY_PII_ALLOW` and pi-privacy.config.json were
12
+ // silently ignored in every Privateer session. The only lever was "Send + remember for
13
+ // session", which is all-or-nothing and forgotten at exit.
14
+ //
15
+ // This matters more since the gate learned to answer itself: under no quarter the
16
+ // question is swallowed and the payload is auto-redacted, so a false positive is no
17
+ // longer a prompt you dismiss — it is a silent rewrite the model then reads.
18
+ //
19
+ // WHERE IT LIVES. `privacy.piiAllow` in ~/.privateer/config.json (the same file the
20
+ // harbor, channels and webhooks read). Entry forms are pi-privacy's — `me@acme.com`
21
+ // (exact, `*` globs), `@acme.com` (that domain and its subdomains), `10.0.0.0/8` (an
22
+ // IPv4 block), or any exact/globbed value.
23
+ //
24
+ // LIVE, NOT LATCHED. entries() is handed to pi-privacy as a function, so `/privacy allow
25
+ // …` applies on the next turn instead of the next launch. It is called once per matched
26
+ // value during a scan, so it must stay cheap: the JSON is re-parsed only when the file
27
+ // changes (stamp = mtime + size), which leaves a stat per call and nothing else. An edit
28
+ // made in an editor is picked up on the next scan, exactly like one made through the
29
+ // command — there is no "restart to apply" step to explain.
30
+
31
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
32
+ import { configPath } from "./paths.ts";
33
+
34
+ let cached: string[] = [];
35
+ let cachedStamp = "";
36
+
37
+ function parse(raw: unknown): string[] {
38
+ const list = (raw as any)?.privacy?.piiAllow;
39
+ if (!Array.isArray(list)) return [];
40
+ return list.filter((e): e is string => typeof e === "string" && e.trim() !== "").map((e) => e.trim());
41
+ }
42
+
43
+ /**
44
+ * The operator's allowlist, live. Missing file, unreadable file and malformed JSON all
45
+ * mean "no entries" — a config typo must not take the PII gate down with it, and the
46
+ * gate erring toward MORE detection is the safe direction.
47
+ */
48
+ export function piiAllowEntries(): readonly string[] {
49
+ try {
50
+ const st = statSync(configPath());
51
+ const stamp = `${st.mtimeMs}:${st.size}`;
52
+ if (stamp === cachedStamp) return cached;
53
+ cachedStamp = stamp;
54
+ cached = parse(JSON.parse(readFileSync(configPath(), "utf8")));
55
+ } catch {
56
+ cachedStamp = "";
57
+ cached = [];
58
+ }
59
+ return cached;
60
+ }
61
+
62
+ /** Drop the cache — after our own write, so the stamp is never mistaken for unchanged. */
63
+ function invalidate(): void {
64
+ cachedStamp = "";
65
+ }
66
+
67
+ // A bare `*` allows every value of every type: that is `piiPolicy: off` wearing a
68
+ // different hat, and pi-privacy refuses it at compile time anyway. Refusing it HERE means
69
+ // the person who typed it is told, rather than watching an entry land in their config and
70
+ // do nothing.
71
+ function invalidReason(entry: string): string | undefined {
72
+ if (!entry) return "an empty entry matches nothing";
73
+ if (/^\*+$/.test(entry)) return "a bare `*` would allowlist everything — turn the gate off explicitly if that's what you mean";
74
+ return undefined;
75
+ }
76
+
77
+ export interface AllowEdit {
78
+ ok: boolean;
79
+ /** Why it was refused, or what already stood. Always safe to show the user. */
80
+ message: string;
81
+ entries: readonly string[];
82
+ }
83
+
84
+ function write(entries: string[]): void {
85
+ let cfg: Record<string, unknown> = {};
86
+ try {
87
+ cfg = JSON.parse(readFileSync(configPath(), "utf8"));
88
+ } catch {
89
+ /* new or unreadable — a fresh object, so one bad file doesn't lose the edit */
90
+ }
91
+ const privacy = { ...((cfg.privacy as Record<string, unknown>) ?? {}), piiAllow: entries };
92
+ writeFileSync(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
+ invalidate();
94
+ }
95
+
96
+ /** Add one entry. Idempotent — adding what's already there is reported, not duplicated. */
97
+ export function addPiiAllow(raw: string): AllowEdit {
98
+ const entry = raw.trim();
99
+ const bad = invalidReason(entry);
100
+ if (bad) return { ok: false, message: bad, entries: piiAllowEntries() };
101
+ const entries = [...piiAllowEntries()];
102
+ if (entries.includes(entry)) return { ok: true, message: `${entry} is already allowlisted`, entries };
103
+ entries.push(entry);
104
+ write(entries);
105
+ return { ok: true, message: `${entry} is no longer treated as PII in this and future sessions`, entries };
106
+ }
107
+
108
+ /** Remove one entry, exactly as written. */
109
+ export function removePiiAllow(raw: string): AllowEdit {
110
+ const entry = raw.trim();
111
+ const entries = [...piiAllowEntries()];
112
+ const i = entries.indexOf(entry);
113
+ if (i < 0) return { ok: false, message: `${entry || "(empty)"} is not in the allowlist`, entries };
114
+ entries.splice(i, 1);
115
+ write(entries);
116
+ return { ok: true, message: `${entry} is gated again`, entries };
117
+ }