privateer-agent 0.13.0 → 0.14.1

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).
@@ -475,10 +479,30 @@ privateer harbor uninstall
475
479
  ```
476
480
 
477
481
  Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
478
- root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.)
482
+ root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.) Logs go to
483
+ `~/.privateer/harbor.log` on macOS (kept under 5 MB, one previous copy as `harbor.log.1`) and
484
+ to the journal on Linux: `journalctl --user -u privateer-harbor`.
479
485
 
480
486
  Everything the harbor does still runs through the permission gate. Actions needing approval
481
- surface in the app; routines you approved once run on their own schedule.
487
+ surface in the app; routines you approved once run on their own schedule. Unattended runs
488
+ are confined to their working directory and can't read credential stores (`~/.ssh`,
489
+ `~/.aws`, Privateer's own login and config files), whatever tools they're allowed.
490
+
491
+ **Chat channels** (Telegram, Slack, Discord, WhatsApp) run beside it, as their own service:
492
+
493
+ ```bash
494
+ privateer channels install # login service; re-run after changing channels from the app
495
+ privateer channels status # which platforms are live right now
496
+ privateer channels run # or in the foreground
497
+ ```
498
+
499
+ **On a VPS:** run both as a dedicated, unprivileged user, never root. A routine you allow
500
+ `bash` runs with that user's full rights. Sign in over SSH with `privateer` and `/login` (it
501
+ prints a code to approve in the app). Nothing needs an inbound port, except WhatsApp's
502
+ webhook. The Linux unit sets `NoNewPrivileges`, `UMask=0077` and a restart limit. It skips
503
+ systemd's namespace sandboxing on purpose, because that fails to start on kernels without
504
+ unprivileged user namespaces. In a container with no systemd, use `privateer harbor run`
505
+ under the container's own restart policy.
482
506
 
483
507
  ## Workflows
484
508
 
@@ -545,6 +569,8 @@ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
545
569
  Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
546
570
  every approval arrives as a `session/request_permission` call it answers. The wire format,
547
571
  option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
572
+ Handing it data your program shouldn't see? Set `"private": true` in the `acp` block; see
573
+ [Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
548
574
 
549
575
  ### One-shot runs (`-p`) and spending
550
576
 
@@ -566,6 +592,36 @@ call. `media_capabilities` shows the same prices. A call that can't be priced is
566
592
  under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
567
593
  open or running in the background.
568
594
 
595
+ ## Hand off sensitive work to Privateer
596
+
597
+ Your coding agent's context goes to its provider. When a task touches files that
598
+ shouldn't (patient records, a customer export, payroll, a database dump), the agent can
599
+ hand that part to Privateer instead of reading the files itself:
600
+
601
+ ```bash
602
+ privateer -p --private --model privateer/tinfoil/gemma4-31b \
603
+ "Read customers/export.csv. Reply with the number of customers per country. No row-level data."
604
+ ```
605
+
606
+ The calling agent passes file *names*. Privateer reads the files on this machine, reasons
607
+ over them on a verified-private model, and prints only the answer. `--private` is what
608
+ makes that a guarantee rather than a hope:
609
+
610
+ - **No file reaches an unverified model.** Before every tool call the model must be
611
+ `tee-verified` (an enclave quote Privateer checked itself) or `local`. A ZDR route, a
612
+ standard provider, or a failed attestation refuses the tool.
613
+ - **Nothing leaves through a tool.** Only on-machine tools run. Web, MCP, media, uploads
614
+ and network shell commands (`curl`, `ssh`, `git push`, …) are refused.
615
+ - **Nothing lifts it.** It sits above permission modes and approvals, `--no-quarter`
616
+ included, and subagents inherit it.
617
+ - **The caller sees only the answer.** `-p` prints the reply. `--private --mode json`,
618
+ which would stream the file contents, is refused.
619
+
620
+ The reply itself is the one channel left, so ask for aggregates, yes/no answers, or a
621
+ summary, and have Privateer write detail to a file. Setup for Claude Code and other agents,
622
+ the ACP route for your own programs, prompt patterns, and the honest limits are in
623
+ [`docs/private-handoff.md`](docs/private-handoff.md).
624
+
569
625
  ## Connectors — MCP
570
626
 
571
627
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -737,7 +793,10 @@ agent is stuck),
737
793
  `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
738
794
  [`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
739
795
  `privateer -p … [--allow-spend …] [--approve-in-app]` (see
740
- [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --no-quarter`, `privateer --version`.
796
+ [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --private` (tools run only on a
797
+ verified-private model and never reach the network; see
798
+ [Hand off sensitive work](#hand-off-sensitive-work-to-privateer)), `privateer --no-quarter`,
799
+ `privateer --version`.
741
800
  An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
742
801
 
743
802
  ## 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.
@@ -0,0 +1,9 @@
1
+ // Types for bin/model-args.mjs — plain .mjs because bin/ runs under a bare `node`.
2
+
3
+ export function modelArgs(o: {
4
+ launchArgs: string[];
5
+ envModel?: string;
6
+ savedDefault?: string | null;
7
+ signedIn: boolean;
8
+ computed: string;
9
+ }): string[];
@@ -0,0 +1,43 @@
1
+ // Which `--model` the launcher hands Pi, if any.
2
+ //
3
+ // Plain .mjs for the same reason as headless-flags.mjs: bin/ runs under a bare `node`,
4
+ // and the launcher runs on import, so the rule lives where a test can reach it.
5
+ // See tests/modelArgs.test.ts.
6
+ //
7
+ // Precedence:
8
+ // 1. a --model the user typed (already in the args) → add nothing
9
+ // 2. PRIVATEER_MODEL → the computed model
10
+ // 3. a saved pick in settings.json → usually add nothing and let Pi resolve it, which
11
+ // falls back sanely when a BYO model has vanished from the registry. EXCEPT a
12
+ // `privateer/*` pick on a signed-in machine, which is passed explicitly (below).
13
+ // 4. nothing saved → the computed model
14
+ //
15
+ // Why the exception. Pi resolves a saved default only when that provider HAS AUTH at
16
+ // the moment the session starts (model-resolver.js findInitialModel, step 3), and if not
17
+ // it silently takes the first model that does: with an OPENROUTER_API_KEY around, that
18
+ // is Pi's openrouter default, moonshotai/kimi-k2.6. The account provider's auth is the
19
+ // one `privateer` entry in auth.json, which is machine-global and removed whenever ANY
20
+ // Privateer process exits (it revokes its own account session on the way out). So a
21
+ // launch that happened to follow another process's exit came up on OpenRouter with no
22
+ // message at all, while settings.json still said privateer/near/…. A `-p` run then sent
23
+ // its work to a ZDR provider instead of the enclave the user picked; under --private it
24
+ // refused every tool and said the model was "zdr-policy", which is how this was found.
25
+ //
26
+ // An explicit --model resolves against every REGISTERED model, auth or not
27
+ // (resolveCliModel → getModels()), and the account credential is armed at
28
+ // session_start, before the first prompt. Being signed in is the condition because
29
+ // that is when arming will succeed; signed out, the saved pick couldn't run anyway and
30
+ // Pi's own resolution is no worse.
31
+
32
+ /**
33
+ * @param {{ launchArgs: string[], envModel?: string, savedDefault?: string | null, signedIn: boolean, computed: string }} o
34
+ * @returns {string[]} args to prepend: [] or ["--model", spec]
35
+ */
36
+ export function modelArgs({ launchArgs, envModel, savedDefault, signedIn, computed }) {
37
+ if (launchArgs.includes("--model")) return [];
38
+ if (envModel) return ["--model", computed];
39
+ if (savedDefault) {
40
+ return signedIn && savedDefault.startsWith("privateer/") ? ["--model", savedDefault] : [];
41
+ }
42
+ return ["--model", computed];
43
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the channels runner (Telegram/Slack/Discord/WhatsApp → agent turns).
3
+ // Mirrors bin/privateer-harbor.mjs: load dev keys from the repo .env WITHOUT changing
4
+ // cwd, register tsx so TS resolves regardless of the invocation cwd, then hand off to
5
+ // the channels CLI dispatcher (whose `run` imports ./boot.ts before any Pi code).
6
+ //
7
+ // Invoked two ways: interactively via the launcher (`privateer channels …`), and by the
8
+ // installed launchd/systemd service (`node privateer-channels.mjs run`). This is what
9
+ // makes channels runnable from an npm install; it used to need a source checkout
10
+ // (`npm run channels`, tsx and a .env).
11
+ import { register } from "tsx/esm/api";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { dirname, resolve } from "node:path";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const repo = resolve(here, "..");
17
+
18
+ try {
19
+ process.loadEnvFile(resolve(repo, ".env"));
20
+ } catch {
21
+ /* no .env — rely on the ambient environment / ~/.privateer */
22
+ }
23
+
24
+ // Same reason as the harbor: channel sessions load the moat in code, so subagent
25
+ // children need the moat-injecting wrapper rather than a bare `pi`.
26
+ process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
+
28
+ register();
29
+ const { runChannelsCli } = await import(pathToFileURL(resolve(repo, "src/cli/channelsCli.ts")).href);
30
+ await runChannelsCli(process.argv.slice(2));
@@ -29,8 +29,9 @@ 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
+ import { modelArgs } from "./model-args.mjs";
34
35
  import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
35
36
  import { configureCompileCache } from "./startup-cache.mjs";
36
37
 
@@ -248,6 +249,16 @@ delete process.env[APPROVE_IN_APP_ENV];
248
249
  if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
249
250
  if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
250
251
  }
252
+ // --private: NOT cleared from the inherited env first, unlike the two above — it can only
253
+ // tighten a run, so an orchestrator exporting it for every child is the point.
254
+ if (extractPrivateFlag(args)) process.env[PRIVATE_ENV] = "1";
255
+ if (/^(1|true|yes)$/i.test((process.env[PRIVATE_ENV] ?? "").trim())) {
256
+ const problem = privateProblem(args);
257
+ if (problem) {
258
+ process.stderr.write(`privateer: ${problem}\n`);
259
+ process.exit(2);
260
+ }
261
+ }
251
262
 
252
263
  const sub = args[0];
253
264
 
@@ -545,6 +556,16 @@ else if (sub === "harbor" || sub === "daemon") {
545
556
  runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
546
557
  }
547
558
 
559
+ // --- `privateer channels [run|install|uninstall|status]` -------------------
560
+ // The chat-platform bridges (Telegram/Slack/Discord/WhatsApp). Same shape as the
561
+ // harbor: a resident process, its own launcher, its own optional login service.
562
+ else if (sub === "channels") {
563
+ sweepLegacyShims();
564
+ sweepBashLogs();
565
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
566
+ runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-channels.mjs"), ...args.slice(1)], { forwardSignals: true });
567
+ }
568
+
548
569
  // --- `privateer verify` ----------------------------------------------------
549
570
  // Check the install that is on this disk right now. Every other trust signal we
550
571
  // publish is an install-time one (npm provenance at `npm i`, a checksum inside
@@ -807,14 +828,15 @@ else {
807
828
  // 1. a --model the user typed on the privateer command line (already in `args`)
808
829
  // 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
809
830
  // 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
810
- // falls back sanely if that model has vanished from the registry)
831
+ // falls back sanely if that model has vanished from the registry). Except a
832
+ // signed-in `privateer/*` pick, passed explicitly — see bin/model-args.mjs for
833
+ // the silent OpenRouter fallback that prevents
811
834
  // 4. nothing saved (first run / fresh home) → the computed MODEL above
812
835
  //
813
836
  // A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
814
837
  // so a model picked in the old session boots the new one instead of being stomped by
815
838
  // a first-run --model computed before that pick existed.
816
839
  const modelArgsFor = (launchArgs) => {
817
- const userPassedModel = launchArgs.includes("--model");
818
840
  let savedDefault = null;
819
841
  try {
820
842
  const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
@@ -825,7 +847,7 @@ else {
825
847
  savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
826
848
  }
827
849
  } catch { /* absent/unreadable → no saved pick */ }
828
- return userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
850
+ return modelArgs({ launchArgs, envModel: process.env.PRIVATEER_MODEL, savedDefault, signedIn, computed: MODEL });
829
851
  };
830
852
 
831
853
  // Dev convenience: load provider keys from the repo's .env if present.
@@ -856,7 +878,9 @@ else {
856
878
  // command, or `privateer fresh` from another terminal can swap this agent for a new
857
879
  // process with the old tree reaped. Print/export/version runs have nothing to swap.
858
880
  const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
859
- if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args));
881
+ // forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
882
+ // working (and spending) with nobody waiting for its answer.
883
+ if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
860
884
  else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
861
885
  }
862
886
 
@@ -1037,7 +1061,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
1037
1061
  "Commands:",
1038
1062
  ` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
1039
1063
  ` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
1040
- ` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
1064
+ ` ${cmd} harbor <command> Manage Harbor, the always-on agent behind the app (run/install/uninstall/status)`,
1065
+ ` ${cmd} channels <command> Run chat channels: Telegram, Slack, Discord, WhatsApp (run/install/uninstall/status)`,
1041
1066
  ` ${cmd} verify Check local installation integrity and patch state`,
1042
1067
  ` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
1043
1068
  ` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
@@ -281,17 +281,21 @@ function contextLine(p: Palette): string {
281
281
  }
282
282
 
283
283
  // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
284
- // A hand-curated highlights list (newest first). Not the full changelog — just the two
285
- // or three things a returning user should notice. `cmd`, when present, is rendered in the
286
- // accent color so the actionable bit stands out from the prose. Trim this as it ages.
287
- const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
288
- { text: "Open and switch named terminals —", cmd: "/term" },
289
- { text: "Voice follows the window you're in" },
290
- { text: "Searchable model picker —", cmd: "/models" },
291
- { text: "Start over with a new agent —", cmd: "/fresh" },
292
- ];
284
+ // A hand-curated highlights list (newest first), kept in src/config/whatsNew.json so the
285
+ // release guard (scripts/check-whats-new.mjs) can read it without loading the TUI. Not the
286
+ // full changelog — just the two to four things a returning user should notice. `cmd`, when
287
+ // present, is rendered in the accent color so the actionable bit stands out from the prose.
288
+ const WHATS_NEW: Array<{ text: string; cmd?: string }> = (() => {
289
+ try {
290
+ const list = JSON.parse(readFileSync(new URL("../src/config/whatsNew.json", import.meta.url), "utf8"));
291
+ return Array.isArray(list?.items) ? list.items : [];
292
+ } catch {
293
+ return []; // a banner without the list beats no banner
294
+ }
295
+ })();
293
296
 
294
297
  function whatsNewRows(p: Palette): string[] {
298
+ if (!WHATS_NEW.length) return [];
295
299
  const head = `${p.BOLD}${p.INK}✦ What's new${p.RESET}`;
296
300
  const items = WHATS_NEW.map(
297
301
  ({ text, cmd }) =>
@@ -322,7 +326,8 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
322
326
  const packs = packNotice(p);
323
327
  if (packs) text.push(packs);
324
328
  // A blank spacer, then the What's New block — set off below the identity lines.
325
- text.push("", ...whatsNewRows(p));
329
+ const news = whatsNewRows(p);
330
+ if (news.length) text.push("", ...news);
326
331
 
327
332
  // Zip the mark and the text column by row. Rows past the mark's height get a blank
328
333
  // gutter of the mark's width, so the text stays in one column throughout.
@@ -40,6 +40,8 @@ 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";
44
+ import { REPLY_TIMEOUT_ENV, StallWatchdog, replyTimeoutMs } from "../src/engine/stallWatchdog.ts";
43
45
  import type { PermissionRequest } from "../src/permissions/gate.ts";
44
46
  import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
45
47
  import { describeErrorText } from "../src/engine/errors.ts";
@@ -694,7 +696,9 @@ export default function privateerControl(pi: any): void {
694
696
  // prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
695
697
  // lifts the gate anyway.
696
698
  function uncoveredBilledTools(): string[] {
697
- if (noQuarterActive()) return [];
699
+ // Private mode refuses every billed tool outright (they send text off the machine),
700
+ // so "add --allow-spend" would be advice that cannot work.
701
+ if (noQuarterActive() || privateModeFromEnv()) return [];
698
702
  const active: string[] = (() => {
699
703
  try {
700
704
  return pi.getActiveTools?.() ?? [];
@@ -750,11 +754,63 @@ export default function privateerControl(pi: any): void {
750
754
  });
751
755
  pi.on("session_shutdown", () => appApprover?.close());
752
756
 
757
+ // Private mode (src/permissions/privateMode.ts), told to the model before it plans:
758
+ // otherwise it reaches for web_search, gets refused, and narrates the refusal instead
759
+ // of doing the local work. And the reply is the one channel the gate can't close, so
760
+ // the model is asked to keep raw records out of it — a request, not a control.
761
+ pi.on("before_agent_start", (event: any) => {
762
+ if (!privateModeFromEnv()) return;
763
+ return {
764
+ systemPrompt:
765
+ `${event.systemPrompt}\n\n# Private mode\n` +
766
+ "This run handles data that must not leave this machine except through a verified-private model. " +
767
+ "Only local tools work: read, grep, find, ls, write, edit, and shell commands that don't use the network. " +
768
+ "Web, MCP, media and upload tools, and network commands, are refused. Every tool is refused if the model " +
769
+ "can't be verified as private; if that happens, stop and say so.\n" +
770
+ "Your reply leaves private mode: whoever asked will see it. Answer exactly what was asked. Don't quote raw " +
771
+ "records, names, emails, identifiers or other personal data from the files unless the task explicitly asks " +
772
+ "for them. When asked for detail, write it to a file and reply with the path.",
773
+ };
774
+ });
775
+
776
+ // A headless run must not wait forever on a provider that went silent (see
777
+ // src/engine/stallWatchdog.ts). Armed per request, re-armed by every streamed event,
778
+ // off between requests. On a stall: say so on stderr, abort the turn, exit non-zero.
779
+ let stallCtx: any;
780
+ const stallMs = replyTimeoutMs();
781
+ const watchdog = new StallWatchdog(stallMs, () => {
782
+ const secs = Math.round(stallMs / 1000);
783
+ process.stderr.write(
784
+ `privateer: no reply from ${currentSpec || "the model"} for ${secs}s — stopped. The provider accepted the ` +
785
+ `request and then sent nothing.\n Re-run, or pick another model with --model. ` +
786
+ `${REPLY_TIMEOUT_ENV}=<seconds> changes the limit (0 turns it off).\n`,
787
+ );
788
+ process.exitCode = 1;
789
+ try {
790
+ stallCtx?.abort?.();
791
+ } catch {
792
+ /* the turn is already ending */
793
+ }
794
+ });
795
+ pi.on("before_provider_request", (_e: any, ctx: any) => {
796
+ if (!headlessRun) return;
797
+ stallCtx = ctx;
798
+ watchdog.arm();
799
+ });
800
+ pi.on("message_start", (ev: any) => {
801
+ if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
802
+ });
803
+ pi.on("message_update", () => {
804
+ if (headlessRun) watchdog.arm();
805
+ });
806
+ pi.on("message_end", () => watchdog.disarm());
807
+
753
808
  // Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
754
809
  // "Connection error." — and exits. Follow it with what that means and what to do,
755
810
  // on stderr so stdout stays the answer (or the JSON stream).
756
811
  pi.on("agent_end", (ev: any) => {
757
- if (!headlessRun) return;
812
+ watchdog.disarm();
813
+ if (!headlessRun || watchdog.stalled) return; // a stall already said what happened
758
814
  const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
759
815
  const last = [...msgs].reverse().find((m) => m?.role === "assistant");
760
816
  if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
@@ -21,11 +21,12 @@
21
21
  // /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
22
22
  // Pi never reads that file) — read-modify-write, preserving unrelated keys.
23
23
 
24
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { readFileSync, 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
28
  import { desktopAppPath } from "../src/config/desktopApp.ts";
29
+ import { writeFileAtomic } from "../src/util/atomicWrite.ts";
29
30
 
30
31
  const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
31
32
  const EVERY_MS = 12_000;
@@ -120,7 +121,7 @@ function persistEnabled(enabled: boolean): void {
120
121
  cfg.hints = { ...(cfg.hints ?? {}), enabled };
121
122
  try {
122
123
  mkdirSync(globalDir(), { recursive: true });
123
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
124
+ writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
124
125
  } catch {
125
126
  /* best effort — the toggle still holds for this session */
126
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "dev": "node bin/privateer-launch.mjs",
65
65
  "dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
66
66
  "typecheck": "tsc --noEmit",
67
+ "check:whats-new": "node scripts/check-whats-new.mjs",
67
68
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
68
69
  },
69
70
  "engines": {
package/src/acp/run.ts CHANGED
@@ -21,7 +21,8 @@
21
21
  // "model": "openrouter/openai/gpt-4o-mini", // optional
22
22
  // "tools": ["read","grep","find","ls"], // optional ceiling
23
23
  // "posture": "approve", // readonly | approve | auto
24
- // "cwd": "/path/to/project" // optional; else this process's cwd
24
+ // "cwd": "/path/to/project", // optional; else this process's cwd
25
+ // "private": true // optional; see permissions/privateMode.ts
25
26
  // }
26
27
  // }
27
28
  //
@@ -115,6 +116,10 @@ export async function runAcp(): Promise<void> {
115
116
  : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
116
117
  const posture: Posture = normalizePosture(block.posture) ?? "approve";
117
118
  const baseCwd: string = block.cwd ?? process.cwd();
119
+ // Private mode: `acp.private`, or `privateer acp --private` / PRIVATEER_PRIVATE (read by
120
+ // the gate itself). The handoff surface for an agent that must not see the data.
121
+ const { privateModeFromEnv, modelTier, PRIVATE_TIERS } = await import("../permissions/privateMode.ts");
122
+ const privateMode: boolean = block.private === true || privateModeFromEnv();
118
123
 
119
124
  // The gate. Identical posture semantics to the channels runtime: "readonly" maps to
120
125
  // plan mode (hard-deny writes), "auto" relaxes non-dangerous actions, and every ask
@@ -129,6 +134,7 @@ export async function runAcp(): Promise<void> {
129
134
  confineToCwd: true,
130
135
  getRemote: () => true,
131
136
  getAutoApprove: () => posture === "auto",
137
+ getPrivate: () => privateMode,
132
138
  async localAsk() {
133
139
  return "deny";
134
140
  },
@@ -272,8 +278,22 @@ export async function runAcp(): Promise<void> {
272
278
  const modelCount = listModels()?.available.length ?? 0;
273
279
  log(
274
280
  `ready — model ${defaultModel}, ${modelCount} selectable, ` +
275
- `ceiling [${tools.join(", ")}], posture ${posture}`,
281
+ `ceiling [${tools.join(", ")}], posture ${posture}${privateMode ? ", PRIVATE" : ""}`,
276
282
  );
283
+ // Say up front when private mode will refuse every tool, rather than letting the
284
+ // caller find out from its first denial. The gate re-checks per call regardless.
285
+ if (privateMode) {
286
+ try {
287
+ const tier = await modelTier({ provider: model.provider, id: model.id, baseUrl: model.baseUrl });
288
+ log(
289
+ PRIVATE_TIERS.has(tier)
290
+ ? `private mode: ${defaultModel} is ${tier}`
291
+ : `private mode: ${defaultModel} is ${tier}, NOT verified-private — every tool will be refused. Pick a TEE or local model.`,
292
+ );
293
+ } catch (e) {
294
+ log(`private mode: could not verify ${defaultModel} (${e instanceof Error ? e.message : String(e)}) — tools will be refused until it verifies`);
295
+ }
296
+ }
277
297
 
278
298
  // One Pi session per ACP session. A single subscription routes streamed text into a
279
299
  // mutable holder, which the running turn owns — safe because a session runs at most
@@ -353,7 +373,12 @@ export async function runAcp(): Promise<void> {
353
373
  // its own `hasConfiguredAuth` precheck before it emits `before_agent_start`,
354
374
  // where providers/account.ts installs the equivalent net.
355
375
  await ensureAccountArmed(currentProvider);
356
- await session.prompt(text);
376
+ // Prompt text is the HOST's, so it is never a command: with expansion on, Pi
377
+ // runs a leading `/name` as an extension command, and anyone who can post to
378
+ // the host (a Buzz channel, a calling agent) could send `/privacy off` or
379
+ // `/privacy allow *` — the second persisted to config.json. Text goes to the
380
+ // model as text.
381
+ await session.prompt(text, { expandPromptTemplates: false });
357
382
  } catch (e) {
358
383
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
359
384
  } finally {
@@ -371,7 +396,7 @@ export async function runAcp(): Promise<void> {
371
396
 
372
397
  let agent: InstanceType<typeof PrivateerAcpAgent> | undefined;
373
398
  new AgentSideConnection((conn) => {
374
- agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log });
399
+ agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log, private: privateMode });
375
400
  return agent;
376
401
  }, stream);
377
402
 
package/src/acp/server.ts CHANGED
Binary file