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 +62 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/model-args.d.mts +9 -0
- package/bin/model-args.mjs +43 -0
- package/bin/privateer-channels.mjs +30 -0
- package/bin/privateer-launch.mjs +31 -6
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +58 -2
- package/extensions/privateer-hints.ts +3 -2
- package/package.json +2 -1
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +30 -6
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +10 -0
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/ext/permissionGate.ts +21 -0
- package/src/harbor/accountLease.ts +76 -0
- package/src/harbor/closeSession.ts +20 -0
- package/src/harbor/index.ts +147 -58
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/permissions/classify.ts +12 -4
- package/src/permissions/danger.ts +9 -0
- package/src/permissions/modeGate.ts +8 -0
- package/src/permissions/privateMode.ts +136 -0
- package/src/permissions/protected.ts +36 -1
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/liveTaskSession.ts +18 -2
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/util/atomicWrite.ts +46 -0
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 --
|
|
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
|
package/bin/headless-flags.d.mts
CHANGED
|
@@ -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;
|
package/bin/headless-flags.mjs
CHANGED
|
@@ -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,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));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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)
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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
|