privateer-agent 0.12.15 → 0.12.17

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
@@ -21,6 +21,8 @@ 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";
25
+ import { makeChartTools } from "../src/tools/charts.ts";
24
26
  import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
25
27
  import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
26
28
  import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
@@ -474,6 +476,17 @@ export default function privateerControl(pi: any): void {
474
476
  // driving. The daemon no longer loads this file, so there is nothing to shadow.
475
477
  pi.registerTool?.(makeSendFileTool(bridge));
476
478
  pi.registerTool?.(makeSaveAttachmentTool(attachments));
479
+ // save_cargo belongs with them: it is the same bridge, the same "needs a connected
480
+ // app" precondition, and the same failure mode if it were registered anywhere the
481
+ // relay isn't this file's. Unlike the pair it hands the app PLAINTEXT to encrypt —
482
+ // the terminal has no master key, so the round trip is the feature (cargoSave.ts).
483
+ pi.registerTool?.(makeSaveCargoTool(bridge));
484
+ // The chart tools, for the same reasons and one more. Same: this bridge, the same
485
+ // "needs a connected app" precondition, the same plaintext-out/app-encrypts round trip
486
+ // (chartOps.ts). More: they also READ the user's stored content back, so registering
487
+ // them anywhere the relay isn't this file's would mean a session asking for decrypted
488
+ // charts over a relay nobody is driving.
489
+ for (const tool of makeChartTools(bridge)) pi.registerTool?.(tool);
477
490
 
478
491
  // Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
479
492
  // no one can approve, so a "default" gate would fail-closed on every tool and the
@@ -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.15",
3
+ "version": "0.12.17",
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
  });
@@ -34,6 +34,8 @@ 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";
38
+ import type { ChartOpBridge } from "../tools/charts.ts";
37
39
  import type { AttachmentStore } from "../util/attachmentStore.ts";
38
40
 
39
41
  /** A Pi extension factory, as DefaultResourceLoader takes them. */
@@ -61,7 +63,7 @@ export interface MoatOptions {
61
63
  * its module-level bridge and stands them down inside the daemon, so a live spawn's own
62
64
  * pair is what the model gets (see tools/relayFileTools.ts).
63
65
  */
64
- relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
66
+ relayFiles?: { bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge; attachments: AttachmentStore };
65
67
  /**
66
68
  * THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
67
69
  * a path whose result reaches the app's Inbox — a scheduled routine, a submitted
@@ -176,17 +178,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
176
178
  if (!caps) throw new Error(`buildMoat: unknown kind "${opts.kind}"`);
177
179
 
178
180
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
179
- const { makePiPrivacyExtension } = await import("pi-privacy");
180
181
  const { makeAccountProvider } = await import("../providers/account.ts");
181
182
  const { webEnabled, mediaEnabled } = await import("./hosted.ts");
182
- const { sharedPrivacyOptions } = await import("./privacyPolicy.ts");
183
+ const { privacyExtension } = await import("./privacyPolicy.ts");
183
184
 
184
185
  const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
185
186
 
186
187
  // pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
187
188
  // extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
188
189
  // bugs that came of configuring it in two.
189
- factories.push(makePiPrivacyExtension(sharedPrivacyOptions()));
190
+ factories.push(privacyExtension());
190
191
  factories.push(makeAccountProvider()); // must follow pi-privacy — see header
191
192
 
192
193
  if (opts.relayFiles) {
@@ -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
+ }
@@ -29,7 +29,9 @@
29
29
  // buildMoat(), exactly as it does every other Pi-touching import. Extensions load late
30
30
  // and import it statically.
31
31
 
32
+ import { loadConfig, makePiPrivacyExtension } from "pi-privacy";
32
33
  import { noQuarterActive } from "../permissions/noQuarter.ts";
34
+ import { addPiiAllow, piiAllowEntries, removePiiAllow } from "./piiAllow.ts";
33
35
  import { cliPalette, detectScheme } from "../ui/palette.ts";
34
36
  import { accountPosture, privateerChannel } from "../providers/account.ts";
35
37
  import { hasCredentials } from "../auth/privateer.ts";
@@ -38,15 +40,32 @@ import { hasCredentials } from "../auth/privateer.ts";
38
40
  // no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
39
41
  // extension's status line), body in the accent color — distinct from a yellow warning and
40
42
  // from a red error, because this is neither: it's the answer we gave for you.
43
+ // The tail is the point as much as the color is: unattended, nobody was asked, so the
44
+ // only way to disagree with the answer is to know where to say so. `/privacy allow …`
45
+ // is that place, and a notice about a masked filename is exactly when you want to know
46
+ // it exists.
41
47
  function renderPiiAutoRedact(notice: string): string {
42
48
  const p = cliPalette(detectScheme());
43
49
  const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
44
- return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
50
+ return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET} ${p.DIM}· /privacy allow <value> if it shouldn't be masked${p.RESET}`;
45
51
  }
46
52
 
47
53
  /** The pi-privacy options every Privateer session gets, whichever route built it. */
48
54
  export function sharedPrivacyOptions() {
55
+ // pi-privacy's OWN configuration — PI_PRIVACY_* env vars and a pi-privacy.config.json.
56
+ // We used to build the options object by hand and never call this, so every one of
57
+ // those settings was silently ignored in Privateer and nowhere else: a user who set
58
+ // PI_PRIVACY_PII_ALLOW watched it do nothing. Ambient settings go UNDER ours, so the
59
+ // knobs Privateer has a product reason to hold (the tier resolver, the unattended
60
+ // signal, the account badge) are still ours, while the ones that are a matter of taste
61
+ // (piiPolicy, badge sinks, the exfil/downgrade policies) become settable.
62
+ //
63
+ // Safe against a repo you just opened: a project-local pi-privacy.config.json is
64
+ // clamped by pi-privacy itself — it may not weaken a policy below the built-in floor,
65
+ // and may not add allowlist entries at all.
66
+ const ambient = loadConfig();
49
67
  return {
68
+ ...ambient,
50
69
  // The account channel's real posture. pi-privacy ships a `privateer` provider, but it
51
70
  // is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
52
71
  // end-to-end), so from the package alone every privateer/* model floors to
@@ -92,6 +111,64 @@ export function sharedPrivacyOptions() {
92
111
  // unconditionally and each masks its own patterns, so the surviving content is the
93
112
  // same either way. Under "warn" the order WOULD matter, since it decides whether the
94
113
  // prompt is raised on a raw key or one we already masked.
95
- toolResultPolicy: "redact" as const,
114
+ toolResultPolicy: ambient.toolResultPolicy ?? ("redact" as const),
115
+ // The operator's own "that is not personal data" list, read LIVE (see
116
+ // ./piiAllow.ts): `/privacy allow @acme.com` applies on the next turn, not the next
117
+ // launch. Ambient config entries come first — both lists are additive, since an
118
+ // allowlist can only ever remove detection and there is no sense in which one of
119
+ // them should silence the other.
120
+ piiAllow: () => [...(ambient.piiAllow ?? []), ...piiAllowEntries()],
121
+ };
122
+ }
123
+
124
+ // ── /privacy ─────────────────────────────────────────────────────────────────────────
125
+ // The gate's escape hatch, at the point of complaint. Everything else about pi-privacy is
126
+ // glanceable (the badge) or answerable in the moment (the prompt); the allowlist is the
127
+ // only part that needs a place to live, and "edit ~/.privateer/config.json and relaunch"
128
+ // is not a thing anyone does mid-task while the gate masks a filename in front of them.
129
+ //
130
+ // Registered from HERE rather than from the extension file, for the reason this module
131
+ // exists at all: the factory-built sessions and the discovered extension must not drift.
132
+ function registerPrivacyCommand(pi: any): void {
133
+ pi.registerCommand?.("privacy", {
134
+ description: "Values the PII gate must not treat as personal data: /privacy [allow <value> | unallow <value>]",
135
+ handler: (args: string, ctx: any) => {
136
+ const raw = String(args ?? "").trim();
137
+ const [verb, ...rest] = raw.split(/\s+/);
138
+ const value = rest.join(" ").trim();
139
+ const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
140
+
141
+ if (verb === "allow" || verb === "unallow") {
142
+ if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
143
+ const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
144
+ return notify(r.message, r.ok ? "info" : "warning");
145
+ }
146
+ if (verb) return notify(`unknown option "${verb}" — usage: /privacy [allow <value> | unallow <value>]`, "warning");
147
+
148
+ const mine = piiAllowEntries();
149
+ notify(
150
+ [
151
+ mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
152
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
153
+ "Add one with /privacy allow <value> — an address (me@acme.com), a domain (@acme.com),",
154
+ "an IPv4 block (10.0.0.0/8), or any exact/globbed value.",
155
+ ].join("\n"),
156
+ "info",
157
+ );
158
+ },
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The privacy half of the moat as ONE factory: pi-privacy configured the Privateer way,
164
+ * plus the `/privacy` command that maintains its allowlist. Both routes into pi-privacy
165
+ * (src/config/moat.ts and extensions/privateer-privacy.ts) use this, so neither can end
166
+ * up with the gate but not its escape hatch.
167
+ */
168
+ export function privacyExtension() {
169
+ const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
170
+ return function privateerPrivacyCore(pi: any): void {
171
+ privacy(pi);
172
+ registerPrivacyCommand(pi);
96
173
  };
97
174
  }
@@ -270,6 +270,101 @@ export function classifyToolCall(
270
270
  };
271
271
  }
272
272
 
273
+ // save_cargo (src/tools/cargo.ts) — hand an artifact to the connected app, which
274
+ // encrypts it and stores it in the user's Cargo.
275
+ //
276
+ // Left to the unknown-tool branch this is bash-kind: a "Run save_cargo" prompt over a
277
+ // JSON blob, and an outright DENY in plan/readonly. Denying it there is the wrong call
278
+ // twice over — it writes nothing on this machine, and "show me this on my phone" is a
279
+ // reasonable thing to ask for while planning.
280
+ //
281
+ // Classified as a WRITE even though no local file changes, because that is what it is
282
+ // from the user's side: a new, persistent thing in their account, against their storage
283
+ // quota. Not alwaysAsk — unlike the generate_* tools it spends no credit, and unlike an
284
+ // ordinary egress the destination is the user's OWN device, encrypted there before it
285
+ // is stored, so there is no third party to leak to and nothing irreversible to stop.
286
+ //
287
+ // `outside` is about the SOURCE. The content is read off disk and shipped off-machine,
288
+ // so `path: "~/Documents/notes.md"` discloses a file from outside the working directory
289
+ // — the same disclosure the media tools flag on their inputs, and the reason `outside`
290
+ // has to be set here: it forces a prompt even under acceptEdits, which would otherwise
291
+ // swallow the call as an ordinary in-scope write.
292
+ // The chart tools (src/tools/charts.ts) — read and write the boards in the user's app.
293
+ //
294
+ // Split by direction, because they are not the same act. list_charts and read_chart are
295
+ // READS, and left to the unknown-tool branch they'd be bash-kind prompts denied outright
296
+ // in plan/readonly — wrong twice over: they touch nothing on this machine, and "look at
297
+ // what's already on my board" is exactly the kind of thing a plan-mode turn wants.
298
+ //
299
+ // read_chart is still worth naming precisely in the prompt rather than folding in with
300
+ // the listing. It returns DECRYPTED content out of the user's account — the only tool
301
+ // here that does — and the detail line says which chart, so an approval is a decision
302
+ // about a specific board rather than a blanket yes to reading their charts.
303
+ //
304
+ // create_chart and edit_chart are WRITES for the same reason save_cargo is: a new,
305
+ // persistent thing in the user's account against their quota. Not alwaysAsk — no credit
306
+ // is spent and the destination is the user's own device, encrypted there before storage,
307
+ // so there is no third party and nothing irreversible. `outside` is deliberately NOT set:
308
+ // unlike save_cargo there is no source file, so there is no out-of-scope disclosure to
309
+ // flag. A delete_node step is the one thing here that destroys something the user made,
310
+ // so it is surfaced in the title rather than buried in the op list.
311
+ if (name === "list_charts") {
312
+ return { tool: toolName, kind: "read", title: "List charts in the Privateer app", detail: "titles and card counts" };
313
+ }
314
+ if (name === "read_chart") {
315
+ const chartId = str(obj.chartId);
316
+ return {
317
+ tool: toolName,
318
+ kind: "read",
319
+ title: "Read a chart from the Privateer app",
320
+ detail: chartId ? `chart ${chartId} — the app decrypts its cards on the device` : "a chart's cards",
321
+ };
322
+ }
323
+ if (name === "create_chart") {
324
+ const nodes = Array.isArray(obj.nodes) ? obj.nodes.length : 0;
325
+ const titleNote = str(obj.title) ? ` "${str(obj.title)}"` : "";
326
+ return {
327
+ tool: toolName,
328
+ kind: "write",
329
+ title: "Create a chart in the Privateer app",
330
+ detail: `${nodes} card${nodes === 1 ? "" : "s"}${titleNote} → the app encrypts them and stores them in Charts`,
331
+ };
332
+ }
333
+ if (name === "edit_chart") {
334
+ const ops = Array.isArray(obj.ops) ? (obj.ops as Array<Record<string, unknown>>) : [];
335
+ const deletes = ops.filter((o) => o?.edit === "delete_node").length;
336
+ const chartId = str(obj.chartId);
337
+ const kinds = [...new Set(ops.map((o) => str(o?.edit)).filter(Boolean))].join(", ");
338
+ return {
339
+ tool: toolName,
340
+ kind: "write",
341
+ title: deletes ? "Edit a chart in the Privateer app (deletes cards)" : "Edit a chart in the Privateer app",
342
+ detail:
343
+ `${ops.length} step${ops.length === 1 ? "" : "s"}${kinds ? ` (${kinds})` : ""}` +
344
+ `${chartId ? ` on chart ${chartId}` : ""}` +
345
+ `${deletes ? ` — ${deletes} card${deletes === 1 ? "" : "s"} deleted` : ""}`,
346
+ };
347
+ }
348
+
349
+ if (name === "save_cargo") {
350
+ const src = str(obj.path);
351
+ if (!src) return unknownTarget(toolName, "write");
352
+ const abs = resolveInCwd(scope.cwd, src);
353
+ const outside = isOutsideScope(scope, abs);
354
+ const protectedSrc = isProtectedPath(abs);
355
+ const kindNote = str(obj.kind) ? ` as ${str(obj.kind)}` : "";
356
+ const titleNote = str(obj.title) ? ` "${str(obj.title)}"` : "";
357
+ return {
358
+ tool: toolName,
359
+ kind: "write",
360
+ title: protectedSrc ? "Save a protected file to Cargo in the app" : "Save to Cargo in the Privateer app",
361
+ detail: `${outside || protectedSrc ? abs : src}${kindNote}${titleNote} → the app encrypts it and stores it in Cargo`,
362
+ protected: protectedSrc,
363
+ outside,
364
+ path: abs,
365
+ };
366
+ }
367
+
273
368
  // Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
274
369
  //
275
370
  // Left to the unknown-tool branch at the bottom these classify as bash-kind, which
@@ -5,38 +5,46 @@
5
5
  //
6
6
  // Two ways in, one state:
7
7
  // 1. `privateer --no-quarter` at launch → PRIVATEER_NO_QUARTER=1 (see
8
- // bin/privateer-launch.mjs), which seeds `active` below.
8
+ // bin/privateer-launch.mjs), which is what the reader below sees.
9
9
  // 2. shift+tab in a live session → toggleNoQuarter(). This is the "step away from
10
10
  // the keyboard" switch: flip it on and the agent runs to completion instead of
11
11
  // stopping on the next approval prompt.
12
12
  //
13
- // Toggling MIRRORS the env var, because that's how the state reaches subagents: a
14
- // pi-subagents child is a `pi` subprocess that inherits this process's env and reads
15
- // PRIVATEER_NO_QUARTER in its own gate. Children spawned after a toggle therefore
16
- // match the parent; ones already running keep the posture they started with.
13
+ // THE ENV VAR IS THE STORE, not a mirror of a module-level flag — and that is
14
+ // load-bearing for two separate reasons:
15
+ //
16
+ // ACROSS PROCESSES: a pi-subagents child is a `pi` subprocess that inherits this
17
+ // process's env and reads PRIVATEER_NO_QUARTER in its own gate. Children spawned
18
+ // after a toggle therefore match the parent; ones already running keep the posture
19
+ // they started with.
20
+ // • ACROSS EXTENSIONS IN THIS PROCESS: Pi loads each extension with its OWN jiti
21
+ // instance and `moduleCache: false` (pi-coding-agent dist/core/extensions/loader.ts,
22
+ // loadExtensionModule), so every extension that imports this file gets a SEPARATE
23
+ // copy of it. A module-level `let` would be per-extension state: shift+tab in
24
+ // extensions/privateer-gate.ts flipped the gate's copy while the copy inside
25
+ // extensions/privateer-privacy.ts stayed false, so a no-quarter session still
26
+ // stopped the turn with pi-privacy's "PII detected — send as-is or redact?" prompt.
27
+ // process.env is the one thing all those copies share, so the state lives there and
28
+ // nowhere else: every reader, in every extension, sees every toggle.
17
29
  //
18
30
  // IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
19
31
  // including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
20
32
 
21
33
  const ENV = "PRIVATEER_NO_QUARTER";
22
34
 
23
- // Seeded from the launch flag so `--no-quarter` and the toggle share one state.
24
- let active = process.env[ENV] === "1";
25
-
26
- /** True while the gate is fully lowered for this session. */
35
+ /** True while the gate is fully lowered for this session. Read live, never cached. */
27
36
  export function noQuarterActive(): boolean {
28
- return active;
37
+ return process.env[ENV] === "1";
29
38
  }
30
39
 
31
- /** Set the state (and mirror it to the env for future subagent children). Returns the new state. */
40
+ /** Set the state in the env, so every copy of this module and every child agrees. Returns the new state. */
32
41
  export function setNoQuarter(on: boolean): boolean {
33
- active = on;
34
42
  if (on) process.env[ENV] = "1";
35
43
  else delete process.env[ENV];
36
- return active;
44
+ return on;
37
45
  }
38
46
 
39
47
  /** Flip the state. Returns the new state. */
40
48
  export function toggleNoQuarter(): boolean {
41
- return setNoQuarter(!active);
49
+ return setNoQuarter(!noQuarterActive());
42
50
  }
@@ -63,6 +63,12 @@ const DEFAULT_MODELS = [
63
63
  // account catalog automatically; these seeds just make them resolve at launch).
64
64
  "moonshotai/kimi-k3",
65
65
  "z-ai/glm-5.2",
66
+ // Both verified 2026-08-19 against /endpoints/zdr and GET /api/models: ZDR-covered
67
+ // and servable. Same reason as the line above — the live catalog already carries
68
+ // them, this only closes the first-launch window where a saved default that isn't
69
+ // yet in the cache would fall through to a BYO provider.
70
+ "x-ai/grok-4.6",
71
+ "openai/gpt-5.6-luna",
66
72
  ];
67
73
 
68
74
  function seedModel(id: string) {