privateer-agent 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/privateer-launch.mjs +11 -1
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +23 -1
- package/package.json +2 -1
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +6 -1
- package/src/config/whatsNew.json +10 -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 +143 -58
- 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/liveTaskSession.ts +18 -2
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).
|
|
@@ -545,6 +549,8 @@ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
|
|
|
545
549
|
Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
|
|
546
550
|
every approval arrives as a `session/request_permission` call it answers. The wire format,
|
|
547
551
|
option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
|
|
552
|
+
Handing it data your program shouldn't see? Set `"private": true` in the `acp` block; see
|
|
553
|
+
[Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
|
|
548
554
|
|
|
549
555
|
### One-shot runs (`-p`) and spending
|
|
550
556
|
|
|
@@ -566,6 +572,36 @@ call. `media_capabilities` shows the same prices. A call that can't be priced is
|
|
|
566
572
|
under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
|
|
567
573
|
open or running in the background.
|
|
568
574
|
|
|
575
|
+
## Hand off sensitive work to Privateer
|
|
576
|
+
|
|
577
|
+
Your coding agent's context goes to its provider. When a task touches files that
|
|
578
|
+
shouldn't (patient records, a customer export, payroll, a database dump), the agent can
|
|
579
|
+
hand that part to Privateer instead of reading the files itself:
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
privateer -p --private --model privateer/tinfoil/gemma4-31b \
|
|
583
|
+
"Read customers/export.csv. Reply with the number of customers per country. No row-level data."
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
The calling agent passes file *names*. Privateer reads the files on this machine, reasons
|
|
587
|
+
over them on a verified-private model, and prints only the answer. `--private` is what
|
|
588
|
+
makes that a guarantee rather than a hope:
|
|
589
|
+
|
|
590
|
+
- **No file reaches an unverified model.** Before every tool call the model must be
|
|
591
|
+
`tee-verified` (an enclave quote Privateer checked itself) or `local`. A ZDR route, a
|
|
592
|
+
standard provider, or a failed attestation refuses the tool.
|
|
593
|
+
- **Nothing leaves through a tool.** Only on-machine tools run. Web, MCP, media, uploads
|
|
594
|
+
and network shell commands (`curl`, `ssh`, `git push`, …) are refused.
|
|
595
|
+
- **Nothing lifts it.** It sits above permission modes and approvals, `--no-quarter`
|
|
596
|
+
included, and subagents inherit it.
|
|
597
|
+
- **The caller sees only the answer.** `-p` prints the reply. `--private --mode json`,
|
|
598
|
+
which would stream the file contents, is refused.
|
|
599
|
+
|
|
600
|
+
The reply itself is the one channel left, so ask for aggregates, yes/no answers, or a
|
|
601
|
+
summary, and have Privateer write detail to a file. Setup for Claude Code and other agents,
|
|
602
|
+
the ACP route for your own programs, prompt patterns, and the honest limits are in
|
|
603
|
+
[`docs/private-handoff.md`](docs/private-handoff.md).
|
|
604
|
+
|
|
569
605
|
## Connectors — MCP
|
|
570
606
|
|
|
571
607
|
Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
@@ -737,7 +773,10 @@ agent is stuck),
|
|
|
737
773
|
`privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
|
|
738
774
|
[`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
|
|
739
775
|
`privateer -p … [--allow-spend …] [--approve-in-app]` (see
|
|
740
|
-
[one-shot runs](#one-shot-runs--p-and-spending)), `privateer --
|
|
776
|
+
[one-shot runs](#one-shot-runs--p-and-spending)), `privateer --private` (tools run only on a
|
|
777
|
+
verified-private model and never reach the network; see
|
|
778
|
+
[Hand off sensitive work](#hand-off-sensitive-work-to-privateer)), `privateer --no-quarter`,
|
|
779
|
+
`privateer --version`.
|
|
741
780
|
An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
|
|
742
781
|
|
|
743
782
|
## 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.
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
|
-
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
|
|
32
|
+
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
|
|
33
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
34
34
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
35
35
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
@@ -248,6 +248,16 @@ delete process.env[APPROVE_IN_APP_ENV];
|
|
|
248
248
|
if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
|
|
249
249
|
if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
|
|
250
250
|
}
|
|
251
|
+
// --private: NOT cleared from the inherited env first, unlike the two above — it can only
|
|
252
|
+
// tighten a run, so an orchestrator exporting it for every child is the point.
|
|
253
|
+
if (extractPrivateFlag(args)) process.env[PRIVATE_ENV] = "1";
|
|
254
|
+
if (/^(1|true|yes)$/i.test((process.env[PRIVATE_ENV] ?? "").trim())) {
|
|
255
|
+
const problem = privateProblem(args);
|
|
256
|
+
if (problem) {
|
|
257
|
+
process.stderr.write(`privateer: ${problem}\n`);
|
|
258
|
+
process.exit(2);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
251
261
|
|
|
252
262
|
const sub = args[0];
|
|
253
263
|
|
|
@@ -281,17 +281,21 @@ function contextLine(p: Palette): string {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// ── "What's New" — a tiny in-banner changelog ────────────────────────────────
|
|
284
|
-
// A hand-curated highlights list (newest first)
|
|
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,7 @@ import { updatePostureBadge } from "./privateer-posture.ts";
|
|
|
40
40
|
import { childSpendAllows } from "../src/permissions/childSpend.ts";
|
|
41
41
|
import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
|
|
42
42
|
import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
|
|
43
|
+
import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
|
|
43
44
|
import type { PermissionRequest } from "../src/permissions/gate.ts";
|
|
44
45
|
import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
|
|
45
46
|
import { describeErrorText } from "../src/engine/errors.ts";
|
|
@@ -694,7 +695,9 @@ export default function privateerControl(pi: any): void {
|
|
|
694
695
|
// prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
|
|
695
696
|
// lifts the gate anyway.
|
|
696
697
|
function uncoveredBilledTools(): string[] {
|
|
697
|
-
|
|
698
|
+
// Private mode refuses every billed tool outright (they send text off the machine),
|
|
699
|
+
// so "add --allow-spend" would be advice that cannot work.
|
|
700
|
+
if (noQuarterActive() || privateModeFromEnv()) return [];
|
|
698
701
|
const active: string[] = (() => {
|
|
699
702
|
try {
|
|
700
703
|
return pi.getActiveTools?.() ?? [];
|
|
@@ -750,6 +753,25 @@ export default function privateerControl(pi: any): void {
|
|
|
750
753
|
});
|
|
751
754
|
pi.on("session_shutdown", () => appApprover?.close());
|
|
752
755
|
|
|
756
|
+
// Private mode (src/permissions/privateMode.ts), told to the model before it plans:
|
|
757
|
+
// otherwise it reaches for web_search, gets refused, and narrates the refusal instead
|
|
758
|
+
// of doing the local work. And the reply is the one channel the gate can't close, so
|
|
759
|
+
// the model is asked to keep raw records out of it — a request, not a control.
|
|
760
|
+
pi.on("before_agent_start", (event: any) => {
|
|
761
|
+
if (!privateModeFromEnv()) return;
|
|
762
|
+
return {
|
|
763
|
+
systemPrompt:
|
|
764
|
+
`${event.systemPrompt}\n\n# Private mode\n` +
|
|
765
|
+
"This run handles data that must not leave this machine except through a verified-private model. " +
|
|
766
|
+
"Only local tools work: read, grep, find, ls, write, edit, and shell commands that don't use the network. " +
|
|
767
|
+
"Web, MCP, media and upload tools, and network commands, are refused. Every tool is refused if the model " +
|
|
768
|
+
"can't be verified as private; if that happens, stop and say so.\n" +
|
|
769
|
+
"Your reply leaves private mode: whoever asked will see it. Answer exactly what was asked. Don't quote raw " +
|
|
770
|
+
"records, names, emails, identifiers or other personal data from the files unless the task explicitly asks " +
|
|
771
|
+
"for them. When asked for detail, write it to a file and reply with the path.",
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
|
|
753
775
|
// Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
|
|
754
776
|
// "Connection error." — and exits. Follow it with what that means and what to do,
|
|
755
777
|
// on stderr so stdout stays the answer (or the JSON stream).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"dev": "node bin/privateer-launch.mjs",
|
|
65
65
|
"dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
|
|
66
66
|
"typecheck": "tsc --noEmit",
|
|
67
|
+
"check:whats-new": "node scripts/check-whats-new.mjs",
|
|
67
68
|
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
68
69
|
},
|
|
69
70
|
"engines": {
|
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
|
package/src/channels/run.ts
CHANGED
|
@@ -332,7 +332,12 @@ async function main() {
|
|
|
332
332
|
// in providers/account.ts hangs off `before_agent_start`, which pi's prompt()
|
|
333
333
|
// never reaches — it throws on its own `hasConfiguredAuth` precheck first.
|
|
334
334
|
if (model?.provider === "privateer") await ensureAccountArmed(undefined);
|
|
335
|
-
|
|
335
|
+
// A chat message is never a command: with expansion on, Pi runs a leading
|
|
336
|
+
// `/name` as an extension command, so any chat member could send `/privacy off`
|
|
337
|
+
// or `/privacy allow *` (persisted to config.json). Same rule as ACP.
|
|
338
|
+
await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () =>
|
|
339
|
+
session.prompt(text, { expandPromptTemplates: false }),
|
|
340
|
+
);
|
|
336
341
|
} catch (e) {
|
|
337
342
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
338
343
|
} finally {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
+
"reviewedFor": "0.14.0",
|
|
4
|
+
"items": [
|
|
5
|
+
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
|
|
6
|
+
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
|
|
7
|
+
{ "text": "Voice follows the window you're in", "since": "0.13.0" },
|
|
8
|
+
{ "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
@@ -18,6 +18,7 @@ import { classifyToolCall } from "../permissions/classify.ts";
|
|
|
18
18
|
import { redactText } from "../util/redact.ts";
|
|
19
19
|
import { coauthorGitCommits } from "../util/coauthor.ts";
|
|
20
20
|
import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
|
|
21
|
+
import { privateModeFromEnv, privateRefusal, type ModelRef } from "../permissions/privateMode.ts";
|
|
21
22
|
|
|
22
23
|
// The per-tool_call hook context we read (structural subset of Pi's ctx — kept
|
|
23
24
|
// local so the gate stays import-order-safe and unit-testable).
|
|
@@ -25,6 +26,8 @@ export interface ToolCallCtx {
|
|
|
25
26
|
signal?: AbortSignal;
|
|
26
27
|
hasUI?: boolean;
|
|
27
28
|
mode?: "tui" | "rpc" | "json" | "print" | string;
|
|
29
|
+
// The session's current model — what private mode checks before any tool runs.
|
|
30
|
+
model?: ModelRef;
|
|
28
31
|
// Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
|
|
29
32
|
// select(title, options: string[]) → chosen string | undefined; confirm(title, message).
|
|
30
33
|
ui?: {
|
|
@@ -43,6 +46,10 @@ export interface GateController {
|
|
|
43
46
|
denylist?: string[];
|
|
44
47
|
cwd: string;
|
|
45
48
|
confineToCwd?: boolean;
|
|
49
|
+
// Unattended confinement: deny outside-cwd and protected access in every mode, and
|
|
50
|
+
// treat a read of a credential store as protected even inside cwd. See
|
|
51
|
+
// ModeGate.hardConfine and ScopeOptions.guardSecretReads.
|
|
52
|
+
hardConfine?: boolean;
|
|
46
53
|
getRemote?(): boolean;
|
|
47
54
|
// The controller raised the flag: total bypass for remote turns — see
|
|
48
55
|
// ModeGate.getNoQuarter.
|
|
@@ -54,6 +61,11 @@ export interface GateController {
|
|
|
54
61
|
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
55
62
|
// action with no prompt.
|
|
56
63
|
getSkipAllPermissions?(): boolean;
|
|
64
|
+
// Private mode (permissions/privateMode.ts): no tool runs unless the session's model is
|
|
65
|
+
// verified-private, and only tools that stay on this machine run at all. ORed with
|
|
66
|
+
// PRIVATEER_PRIVATE, which any controller honours, so a controller can switch it on but
|
|
67
|
+
// never off.
|
|
68
|
+
getPrivate?(): boolean;
|
|
57
69
|
// Billing tools the operator authorized before this run started, so an unattended
|
|
58
70
|
// session can spend what it was told it may spend instead of denying every media
|
|
59
71
|
// call for want of a human. Lifts `alwaysAsk` and nothing else — see
|
|
@@ -138,10 +150,18 @@ export async function decideToolCall(
|
|
|
138
150
|
};
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
// Private mode sits above every mode, allowlist and approval: nothing a person or a
|
|
154
|
+
// host answers can show a file to an unverified model or send it off the machine.
|
|
155
|
+
if (ctrl.getPrivate?.() || privateModeFromEnv()) {
|
|
156
|
+
const refusal = await privateRefusal(toolName, input, ctx.model);
|
|
157
|
+
if (refusal) return { block: true, reason: refusal };
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
const req = classifyToolCall(toolName, input, {
|
|
142
161
|
cwd: ctrl.cwd,
|
|
143
162
|
confineToCwd: ctrl.confineToCwd,
|
|
144
163
|
allowedOutsideRoots: ctrl.allowedOutsideRoots,
|
|
164
|
+
guardSecretReads: ctrl.hardConfine,
|
|
145
165
|
});
|
|
146
166
|
if (!req) return undefined; // no gate needed — read-only/in-scope/meta
|
|
147
167
|
|
|
@@ -181,6 +201,7 @@ export async function decideToolCall(
|
|
|
181
201
|
getNoQuarter: ctrl.getNoQuarter,
|
|
182
202
|
getAutoApprove: ctrl.getAutoApprove,
|
|
183
203
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
204
|
+
hardConfine: ctrl.hardConfine,
|
|
184
205
|
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
185
206
|
? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
|
|
186
207
|
: undefined,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// One account inference session for the whole harbor, shared by every run in flight.
|
|
2
|
+
//
|
|
3
|
+
// The account credential is a single slot per process — `_account` in auth/privateer.ts,
|
|
4
|
+
// one row per pid in accountSessions.ts, one `privateer` entry in auth.json — but the
|
|
5
|
+
// harbor runs several sessions at once: overlapping ticks, a submitted task, a workflow
|
|
6
|
+
// step and a live spawn can all be mid-turn together. Each used to mint its own session
|
|
7
|
+
// on start and revoke "the" session on finish, so the first run to finish revoked
|
|
8
|
+
// whichever session was minted LAST (another run's, mid-turn → 401) and its own leaked
|
|
9
|
+
// server-side until the TTL, with its registry row overwritten so no later launch could
|
|
10
|
+
// reclaim it.
|
|
11
|
+
//
|
|
12
|
+
// A lease fits what the slot can actually hold: the first run to need the account mints
|
|
13
|
+
// it, later runs share it, and the last one out revokes it. Pure — the mint and revoke
|
|
14
|
+
// are injected — so the ordering is testable without a server.
|
|
15
|
+
|
|
16
|
+
export interface AccountLeaseDeps {
|
|
17
|
+
/** Mint the account session and arm it (persist + remember). Throws on failure. */
|
|
18
|
+
mint: () => Promise<void>;
|
|
19
|
+
/** Revoke the session and drop its persisted copy. Best-effort; must not throw. */
|
|
20
|
+
revoke: () => Promise<void>;
|
|
21
|
+
log?: (msg: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccountLease {
|
|
25
|
+
/** Take a reference. Resolves true when the account session is armed. ALWAYS pair with release(). */
|
|
26
|
+
acquire(): Promise<boolean>;
|
|
27
|
+
/** Drop a reference; the last one out revokes the session. */
|
|
28
|
+
release(): Promise<void>;
|
|
29
|
+
/** References held right now (tests, status). */
|
|
30
|
+
readonly refs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAccountLease(deps: AccountLeaseDeps): AccountLease {
|
|
34
|
+
let refs = 0;
|
|
35
|
+
// The session the current holders share: resolves true once armed, false if the mint
|
|
36
|
+
// failed. Undefined when nobody holds one.
|
|
37
|
+
let current: Promise<boolean> | undefined;
|
|
38
|
+
// The previous session's revoke. A mint waits on it, so a run that starts as the last
|
|
39
|
+
// one ends never has its fresh credential dropped by the old one's teardown.
|
|
40
|
+
let teardown: Promise<void> = Promise.resolve();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get refs() {
|
|
44
|
+
return refs;
|
|
45
|
+
},
|
|
46
|
+
async acquire() {
|
|
47
|
+
refs++;
|
|
48
|
+
if (!current) {
|
|
49
|
+
current = teardown.then(deps.mint).then(
|
|
50
|
+
() => true,
|
|
51
|
+
(e) => {
|
|
52
|
+
deps.log?.(` account channel unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
53
|
+
return false;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const mine = current;
|
|
58
|
+
const ok = await mine;
|
|
59
|
+
// A failed mint is not cached: the next run should try again rather than inherit it.
|
|
60
|
+
if (!ok && current === mine) current = undefined;
|
|
61
|
+
return ok;
|
|
62
|
+
},
|
|
63
|
+
async release() {
|
|
64
|
+
if (refs === 0) return;
|
|
65
|
+
refs--;
|
|
66
|
+
if (refs > 0) return;
|
|
67
|
+
const held = current;
|
|
68
|
+
current = undefined;
|
|
69
|
+
if (!held) return;
|
|
70
|
+
teardown = teardown.then(async () => {
|
|
71
|
+
if (await held) await deps.revoke().catch(() => {});
|
|
72
|
+
});
|
|
73
|
+
await teardown;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Tear a headless session all the way down. dispose() alone stops the agent but never
|
|
2
|
+
// emits session_shutdown — that is AgentSessionRuntime's job, and we build sessions
|
|
3
|
+
// without one — so every extension that cleans up on it (pi-mcp-adapter's connections,
|
|
4
|
+
// OAuth runtime and lifecycle state among them) leaked once per run, for the life of a
|
|
5
|
+
// resident process. Reason "new", not "quit": to our own extensions "quit" means the
|
|
6
|
+
// PROCESS is exiting (privateer-brand revokes the machine's sessions on it), and the
|
|
7
|
+
// harbor process lives on. Best-effort: teardown must never fail a finished run.
|
|
8
|
+
export async function closeSession(session: any): Promise<void> {
|
|
9
|
+
try {
|
|
10
|
+
const runner = session?.extensionRunner;
|
|
11
|
+
if (runner?.hasHandlers?.("session_shutdown")) await runner.emit({ type: "session_shutdown", reason: "new" });
|
|
12
|
+
} catch {
|
|
13
|
+
/* a handler threw — dispose anyway */
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
session?.dispose?.();
|
|
17
|
+
} catch {
|
|
18
|
+
/* already disposed */
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOu
|
|
|
64
64
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
65
65
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
66
66
|
import { serializeBuild } from "./buildLock.ts";
|
|
67
|
+
import { createAccountLease } from "./accountLease.ts";
|
|
68
|
+
import { closeSession } from "./closeSession.ts";
|
|
67
69
|
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
68
70
|
import { relayExposureAllowed } from "../config/relayExposure.ts";
|
|
69
71
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
@@ -134,6 +136,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
|
134
136
|
// endpoint or an expired OAuth token from stalling a scheduled run.
|
|
135
137
|
const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
|
|
136
138
|
const WARM_POLL_MS = 250;
|
|
139
|
+
// Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
|
|
140
|
+
// step). Nothing else bounds a provider stream that stops sending without closing, and a
|
|
141
|
+
// run stuck there holds its routine's `running` slot until the harbor restarts — the
|
|
142
|
+
// routine silently never fires again. Live spawns have their own cap (liveTaskSession).
|
|
143
|
+
const RUN_TIMEOUT_MS = Number(process.env.PRIVATEER_RUN_TIMEOUT_MS) || 30 * 60_000;
|
|
137
144
|
|
|
138
145
|
// Distinguishes concurrent runs in the child-spend registry. A counter rather than a
|
|
139
146
|
// timestamp: two runs starting in the same millisecond would share a key, and the second
|
|
@@ -177,6 +184,12 @@ function log(msg: string): void {
|
|
|
177
184
|
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
// Identity of a queued outbox item, for removing the ones a flush delivered from a
|
|
188
|
+
// queue that may have grown while it was posting.
|
|
189
|
+
function pendingKey(p: PendingCloud): string {
|
|
190
|
+
return JSON.stringify([p.routine, p.at, p.kind ?? "routine", p.status, p.content]);
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
// Connector notes are things the RUN could not do — a selected connector that isn't
|
|
181
194
|
// configured, or one that wouldn't hand over a tool list. They ride the result
|
|
182
195
|
// because the alternative is the failure mode RoutineIssues exists to prevent: an
|
|
@@ -271,6 +284,25 @@ export class Harbor {
|
|
|
271
284
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
272
285
|
// terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
|
|
273
286
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
287
|
+
// The one account inference session every concurrent run shares — see accountLease.ts.
|
|
288
|
+
private readonly account = createAccountLease({
|
|
289
|
+
mint: async () => {
|
|
290
|
+
const creds = await acquireAccountCredential();
|
|
291
|
+
await persistAccountCredential(creds);
|
|
292
|
+
rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
|
|
293
|
+
},
|
|
294
|
+
revoke: async () => {
|
|
295
|
+
try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
|
|
296
|
+
// Ownership-checked: an interactive terminal on this machine shares auth.json,
|
|
297
|
+
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
298
|
+
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
299
|
+
},
|
|
300
|
+
log,
|
|
301
|
+
});
|
|
302
|
+
// The cloud-outbox flush in progress, if any. Every tick starts one; without this two
|
|
303
|
+
// could post the same items (duplicates in the Inbox) and the slower one's save would
|
|
304
|
+
// overwrite what the faster one left.
|
|
305
|
+
private flushing?: Promise<void>;
|
|
274
306
|
|
|
275
307
|
// App-facing routine management (list/save/delete/pause/run) over the harbor's
|
|
276
308
|
// relay. Run-now is injected here since only the harbor can actually fire one;
|
|
@@ -633,22 +665,33 @@ export class Harbor {
|
|
|
633
665
|
return sealToOutbox(name, at, status, content, kind, media, source);
|
|
634
666
|
}
|
|
635
667
|
|
|
636
|
-
private
|
|
668
|
+
private flushPendingCloud(): Promise<void> {
|
|
669
|
+
this.flushing ??= this.doFlushPendingCloud()
|
|
670
|
+
.catch((e) => log(`outbox flush failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
671
|
+
.finally(() => {
|
|
672
|
+
this.flushing = undefined;
|
|
673
|
+
});
|
|
674
|
+
return this.flushing;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private async doFlushPendingCloud(): Promise<void> {
|
|
637
678
|
if (!hasCredentials()) return;
|
|
638
679
|
const queue = loadPendingCloud();
|
|
639
680
|
if (queue.length === 0) return;
|
|
640
|
-
const
|
|
681
|
+
const sent = new Set<string>();
|
|
641
682
|
for (const p of queue) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
)
|
|
646
|
-
remaining.push(p);
|
|
647
|
-
}
|
|
648
|
-
if (remaining.length !== queue.length) {
|
|
649
|
-
savePendingCloud(remaining);
|
|
650
|
-
log(`flushed ${queue.length - remaining.length} pending cloud outbox item(s); ${remaining.length} remaining`);
|
|
683
|
+
// Stop at the first failure: order is preserved, and whatever broke this one
|
|
684
|
+
// (offline, no outbox key yet) will break the rest too.
|
|
685
|
+
if (!(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))) break;
|
|
686
|
+
sent.add(pendingKey(p));
|
|
651
687
|
}
|
|
688
|
+
if (sent.size === 0) return;
|
|
689
|
+
// Re-read rather than save what we loaded: a run that finished while we were
|
|
690
|
+
// posting queued its result in the meantime, and overwriting with our snapshot
|
|
691
|
+
// would delete it.
|
|
692
|
+
const remaining = loadPendingCloud().filter((p) => !sent.has(pendingKey(p)));
|
|
693
|
+
savePendingCloud(remaining);
|
|
694
|
+
log(`flushed ${sent.size} pending cloud outbox item(s); ${remaining.length} remaining`);
|
|
652
695
|
}
|
|
653
696
|
|
|
654
697
|
private primeSchedule(): void {
|
|
@@ -778,32 +821,56 @@ export class Harbor {
|
|
|
778
821
|
const toInbox = routine.delivery.includes("cloud");
|
|
779
822
|
const media = toInbox ? new ResultMedia() : undefined;
|
|
780
823
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
pushCloud: this.pushCloud,
|
|
794
|
-
webhooks: config.webhooks,
|
|
795
|
-
redact: (text) => redactText(text, collectSecrets(config.providers)),
|
|
796
|
-
});
|
|
797
|
-
log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
|
|
824
|
+
let status: "ok" | "error" = "error";
|
|
825
|
+
let error: string | undefined;
|
|
826
|
+
let delivered: string[] = [];
|
|
827
|
+
try {
|
|
828
|
+
const run = await this.runSession({
|
|
829
|
+
prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
|
|
830
|
+
cwd: routine.cwd,
|
|
831
|
+
model: modelSpec,
|
|
832
|
+
tools: routine.tools ?? [],
|
|
833
|
+
media,
|
|
834
|
+
});
|
|
835
|
+
({ status, error } = run);
|
|
798
836
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
837
|
+
const content = formatResult(routine, run.out, status, error, run.notes);
|
|
838
|
+
try {
|
|
839
|
+
const report = await deliver(routine, content, status, {
|
|
840
|
+
media: media?.list() ?? [],
|
|
841
|
+
pushRelay: this.pushRelay,
|
|
842
|
+
pushCloud: this.pushCloud,
|
|
843
|
+
webhooks: config.webhooks,
|
|
844
|
+
redact: (text) => redactText(text, collectSecrets(config.providers)),
|
|
845
|
+
});
|
|
846
|
+
delivered = report.delivered;
|
|
847
|
+
} catch (e) {
|
|
848
|
+
// A full disk, an unwritable output dir. The run happened and was paid for; say
|
|
849
|
+
// so in the log and on the routine rather than throwing past the schedule
|
|
850
|
+
// advance below — that throw is what used to re-fire (and re-bill) the routine
|
|
851
|
+
// on every restart.
|
|
852
|
+
error = `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
853
|
+
status = "error";
|
|
854
|
+
}
|
|
855
|
+
log(` "${routine.name}" ${status}; delivered via ${delivered.join(", ") || "(none)"}${error && delivered.length === 0 ? ` (${error})` : ""}`);
|
|
856
|
+
} finally {
|
|
857
|
+
// Always advance the schedule and free the slot, whatever threw above. Without the
|
|
858
|
+
// finally, one exception left the routine marked running forever (it never fired
|
|
859
|
+
// again) — or, as an unhandled rejection, crashed the harbor before nextRun moved,
|
|
860
|
+
// so the restart fired it again straight away.
|
|
861
|
+
try {
|
|
862
|
+
this.persistRun(routine.id, {
|
|
863
|
+
lastRun: new Date().toISOString(),
|
|
864
|
+
lastStatus: status,
|
|
865
|
+
lastError: error,
|
|
866
|
+
...advanceAfterRun(routine),
|
|
867
|
+
});
|
|
868
|
+
} catch (e) {
|
|
869
|
+
log(` couldn't record the run of "${routine.name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
870
|
+
}
|
|
871
|
+
this.running.delete(routine.id);
|
|
872
|
+
}
|
|
873
|
+
return { ok: status === "ok", message: delivered.join(", ") || undefined };
|
|
807
874
|
}
|
|
808
875
|
|
|
809
876
|
// Build the Pi services for one unattended run: the auto-approve gate, the account
|
|
@@ -831,6 +898,11 @@ export class Harbor {
|
|
|
831
898
|
allowedOutsideRoots: [],
|
|
832
899
|
cwd,
|
|
833
900
|
confineToCwd: true,
|
|
901
|
+
// Bypass mode lets anything through that the allow-list registered — including
|
|
902
|
+
// reads outside cwd and of protected files, which bypass allows outright. A run
|
|
903
|
+
// nobody is watching, that may well have fetched a hostile web page, must not
|
|
904
|
+
// read ~/.ssh or our own auth.json and put it in the next URL: refuse both.
|
|
905
|
+
hardConfine: true,
|
|
834
906
|
async localAsk() {
|
|
835
907
|
return "deny";
|
|
836
908
|
},
|
|
@@ -954,7 +1026,7 @@ export class Harbor {
|
|
|
954
1026
|
await new Promise((r) => setTimeout(r, WARM_POLL_MS));
|
|
955
1027
|
}
|
|
956
1028
|
} finally {
|
|
957
|
-
session
|
|
1029
|
+
await closeSession(session);
|
|
958
1030
|
}
|
|
959
1031
|
} catch (e) {
|
|
960
1032
|
log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -977,7 +1049,8 @@ export class Harbor {
|
|
|
977
1049
|
let out = "";
|
|
978
1050
|
let status: "ok" | "error" = "ok";
|
|
979
1051
|
let error: string | undefined;
|
|
980
|
-
let
|
|
1052
|
+
let leasedAccount = false;
|
|
1053
|
+
let session: any;
|
|
981
1054
|
const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
|
|
982
1055
|
// attach_to_result rides with the staging area rather than the allow-list: it is
|
|
983
1056
|
// how the answer is DELIVERED, not a capability the run gains. A routine that
|
|
@@ -997,14 +1070,10 @@ export class Harbor {
|
|
|
997
1070
|
|
|
998
1071
|
const { provider, modelId } = parseSpec(spec.model);
|
|
999
1072
|
if (provider === "privateer") {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
spawnedAccount = true;
|
|
1005
|
-
} catch (e) {
|
|
1006
|
-
log(` account channel unavailable: ${(e as Error).message}`);
|
|
1007
|
-
}
|
|
1073
|
+
// Shared with every other run in flight — see accountLease.ts. A failed mint is
|
|
1074
|
+
// logged by the lease; the run goes ahead and fails on auth with that context.
|
|
1075
|
+
leasedAccount = true;
|
|
1076
|
+
await this.account.acquire();
|
|
1008
1077
|
}
|
|
1009
1078
|
|
|
1010
1079
|
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
@@ -1012,12 +1081,12 @@ export class Harbor {
|
|
|
1012
1081
|
status = "error";
|
|
1013
1082
|
error = `model ${provider}/${modelId} not found`;
|
|
1014
1083
|
} else {
|
|
1015
|
-
|
|
1084
|
+
({ session } = await createAgentSessionFromServices({
|
|
1016
1085
|
services,
|
|
1017
1086
|
sessionManager: SessionManager.inMemory(spec.cwd),
|
|
1018
1087
|
model,
|
|
1019
1088
|
tools: allowedTools,
|
|
1020
|
-
} as any);
|
|
1089
|
+
} as any));
|
|
1021
1090
|
const adapter = createEngineEventAdapter();
|
|
1022
1091
|
session.subscribe((ev: any) => {
|
|
1023
1092
|
for (const ee of adapter.toEngineEvents(ev)) {
|
|
@@ -1028,21 +1097,30 @@ export class Harbor {
|
|
|
1028
1097
|
}
|
|
1029
1098
|
}
|
|
1030
1099
|
});
|
|
1031
|
-
|
|
1100
|
+
let timedOut = false;
|
|
1101
|
+
const deadline = setTimeout(() => {
|
|
1102
|
+
timedOut = true;
|
|
1103
|
+
void Promise.resolve(session.abort?.()).catch(() => {});
|
|
1104
|
+
}, RUN_TIMEOUT_MS);
|
|
1105
|
+
try {
|
|
1106
|
+
await session.prompt(spec.prompt);
|
|
1107
|
+
} finally {
|
|
1108
|
+
clearTimeout(deadline);
|
|
1109
|
+
}
|
|
1110
|
+
if (timedOut) {
|
|
1111
|
+
status = "error";
|
|
1112
|
+
error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
|
|
1113
|
+
}
|
|
1032
1114
|
}
|
|
1033
1115
|
} catch (err) {
|
|
1034
1116
|
status = "error";
|
|
1035
1117
|
error = err instanceof Error ? err.message : String(err);
|
|
1036
1118
|
} finally {
|
|
1037
|
-
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
// Ownership-checked: an interactive terminal on this machine shares auth.json,
|
|
1043
|
-
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
1044
|
-
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
1045
|
-
}
|
|
1119
|
+
if (session) await closeSession(session);
|
|
1120
|
+
// Drop this run's reference to the shared account session; the last run out
|
|
1121
|
+
// revokes it (and its persisted copy). The harbor's own child API session —
|
|
1122
|
+
// relay/outbox — is separate and stays alive until shutdown.
|
|
1123
|
+
if (leasedAccount) await this.account.release();
|
|
1046
1124
|
// Drop this run's child grant. Unconditional and last: a grant left behind would
|
|
1047
1125
|
// authorize the NEXT run's subagents for tools that run never named.
|
|
1048
1126
|
releaseChildSpend();
|
|
@@ -1108,6 +1186,7 @@ export class Harbor {
|
|
|
1108
1186
|
defaultModel: loadHarborConfig().defaultModel,
|
|
1109
1187
|
parseSpec,
|
|
1110
1188
|
log,
|
|
1189
|
+
account: this.account,
|
|
1111
1190
|
onClosed: (id) => this.liveTasks.delete(id),
|
|
1112
1191
|
// A live spawn's feed lives only in the attached app; when the session ends
|
|
1113
1192
|
// (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
|
|
@@ -1401,6 +1480,12 @@ export function runHarbor(): void {
|
|
|
1401
1480
|
};
|
|
1402
1481
|
process.on("SIGINT", shutdown);
|
|
1403
1482
|
process.on("SIGTERM", shutdown);
|
|
1483
|
+
// A resident service: one stray rejection from a fire-and-forget promise (a relay
|
|
1484
|
+
// callback, a run started from IPC) must not take every routine down with it — and,
|
|
1485
|
+
// under launchd/systemd, restart into the same state and do it again. Log it instead.
|
|
1486
|
+
process.on("unhandledRejection", (reason) => {
|
|
1487
|
+
log(`unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
|
|
1488
|
+
});
|
|
1404
1489
|
harbor.start().catch(async (err) => {
|
|
1405
1490
|
if (err instanceof HarborAlreadyRunningError) {
|
|
1406
1491
|
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
@@ -2,7 +2,7 @@ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:pat
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { isProtectedPath } from "./protected.ts";
|
|
5
|
+
import { isProtectedPath, isCredentialStorePath } from "./protected.ts";
|
|
6
6
|
import type { PermissionRequest } from "./gate.ts";
|
|
7
7
|
|
|
8
8
|
// NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
|
|
@@ -19,6 +19,11 @@ export interface ScopeOptions {
|
|
|
19
19
|
cwd: string;
|
|
20
20
|
confineToCwd?: boolean; // default true
|
|
21
21
|
allowedOutsideRoots?: string[];
|
|
22
|
+
// Gate a READ of a protected file or credential store (isCredentialStorePath) even
|
|
23
|
+
// inside cwd, flagged `protected`. Off by default — an interactive session reading
|
|
24
|
+
// its own project's .env is normal. The harbor's unattended runs turn it on and pair
|
|
25
|
+
// it with GateController.hardConfine, which refuses anything flagged.
|
|
26
|
+
guardSecretReads?: boolean;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
// Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
|
|
@@ -619,13 +624,16 @@ export function classifyToolCall(
|
|
|
619
624
|
const p = firstPath(obj);
|
|
620
625
|
if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
|
|
621
626
|
const abs = resolveInCwd(scope.cwd, p);
|
|
622
|
-
|
|
627
|
+
const outside = isOutsideScope(scope, abs);
|
|
628
|
+
const secret = scope.guardSecretReads === true && (isProtectedPath(abs) || isCredentialStorePath(abs));
|
|
629
|
+
if (!outside && !secret) return null;
|
|
623
630
|
return {
|
|
624
631
|
tool: toolName,
|
|
625
632
|
kind: "read",
|
|
626
|
-
title: "Read outside working directory",
|
|
633
|
+
title: secret ? "Read a protected file" : "Read outside working directory",
|
|
627
634
|
detail: abs,
|
|
628
|
-
outside
|
|
635
|
+
outside,
|
|
636
|
+
...(secret ? { protected: true } : {}),
|
|
629
637
|
path: abs,
|
|
630
638
|
};
|
|
631
639
|
}
|
|
@@ -37,6 +37,15 @@ export function looksLikeSecretExfil(command: string): boolean {
|
|
|
37
37
|
return SECRET_FILE.test(command) && NETWORK_SINK.test(command);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Does this command look like it can move bytes off the machine? Private mode refuses
|
|
41
|
+
// these outright (permissions/privateMode.ts). Same heuristic as the exfil check, plus
|
|
42
|
+
// the git and package-manager verbs that upload: a regex over the raw text, not a shell
|
|
43
|
+
// parser, so it is a net, not a proof.
|
|
44
|
+
const UPLOAD_VERB = /\bgit\s+push\b|\b(npm|pnpm|yarn)\s+publish\b/i;
|
|
45
|
+
export function reachesNetwork(command: string): boolean {
|
|
46
|
+
return NETWORK_SINK.test(command) || UPLOAD_VERB.test(command);
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export function matchesDenylist(command: string, patterns: string[]): boolean {
|
|
41
50
|
return patterns.some((src) => {
|
|
42
51
|
if (!src.trim()) return false;
|
|
@@ -69,6 +69,12 @@ export interface ModeGateDeps {
|
|
|
69
69
|
// budget RECORDS what it allows — asking it about a call the mode would refuse anyway
|
|
70
70
|
// would spend budget on nothing.
|
|
71
71
|
isSpendPreauthorized?: (req: PermissionRequest) => boolean | Promise<boolean>;
|
|
72
|
+
// Refuse every request that leaves cwd or touches a protected file, whatever the mode.
|
|
73
|
+
// For the harbor's unattended runs, which run in `bypass` (their safety is the tool
|
|
74
|
+
// allow-list) — and bypass allows outside/protected outright (mode.ts), which left
|
|
75
|
+
// their `confineToCwd` meaning nothing. Sits below no-quarter only: an operator who
|
|
76
|
+
// launched with --no-quarter asked for no gate at all.
|
|
77
|
+
hardConfine?: boolean;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -85,6 +91,8 @@ export class ModeGate implements PermissionGate {
|
|
|
85
91
|
// first check so nothing below can force an "ask" back on.
|
|
86
92
|
if (this.deps.getSkipAllPermissions?.()) return "allow";
|
|
87
93
|
|
|
94
|
+
if (this.deps.hardConfine && (req.outside || req.protected)) return "deny";
|
|
95
|
+
|
|
88
96
|
const denylist = this.deps.denylist ?? [];
|
|
89
97
|
const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
|
|
90
98
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Private mode — the contract another agent relies on when it hands Privateer data it
|
|
2
|
+
// must not see itself.
|
|
3
|
+
//
|
|
4
|
+
// The handoff: an agent running on a model that is NOT private (a hosted frontier model,
|
|
5
|
+
// say) has a task that touches sensitive files. It keeps them out of its own context by
|
|
6
|
+
// asking Privateer to do the reading: `privateer -p --private "…"`, or an ACP session
|
|
7
|
+
// with `acp.private`. That only works if Privateer refuses to put those files in front of
|
|
8
|
+
// a model that isn't private either. A config that quietly fell back to a BYO-key model,
|
|
9
|
+
// a sealed shim that never came up, an attestation that came back yellow: each would
|
|
10
|
+
// send the very data the caller was keeping out of its own provider to someone else's.
|
|
11
|
+
//
|
|
12
|
+
// So private mode is enforced where the data enters the model: at the TOOL CALL. Before
|
|
13
|
+
// any tool runs, the session's current model must resolve to a verified-private tier —
|
|
14
|
+
// `tee-verified` (an enclave quote we checked) or `local` (on this machine). Anything
|
|
15
|
+
// else, including a check that throws, blocks the tool. The caller's own prompt still
|
|
16
|
+
// reaches the model, by design: it is the caller's text, and the caller already holds it.
|
|
17
|
+
// What never reaches an unverified model is anything Privateer READ on the caller's
|
|
18
|
+
// behalf.
|
|
19
|
+
//
|
|
20
|
+
// The second half is egress. A private model does not make a tool call private: a
|
|
21
|
+
// web_fetch carrying a file's contents leaves the machine no matter where inference ran.
|
|
22
|
+
// Private mode therefore runs on an ALLOWLIST of tools that stay on this machine, and
|
|
23
|
+
// refuses shell commands that reach the network, before any mode or approval is
|
|
24
|
+
// consulted — so no-quarter, `acp.posture: "auto"` and an approval cannot lift it.
|
|
25
|
+
//
|
|
26
|
+
// Turned on by `--private` (the launcher sets PRIVATEER_PRIVATE=1) or `acp.private`.
|
|
27
|
+
// The env var is honoured from the ambient environment on purpose: unlike the spend
|
|
28
|
+
// grants it can only ever make a run STRICTER, so an orchestrator may export it once
|
|
29
|
+
// for every Privateer it spawns. Subagent children inherit it with the rest of the env.
|
|
30
|
+
//
|
|
31
|
+
// IMPORT-SAFETY: the gate imports this module statically, and the gate must stay
|
|
32
|
+
// import-order-safe, so the Pi-touching posture code is imported dynamically below.
|
|
33
|
+
|
|
34
|
+
import type { PrivacyTier } from "pi-privacy";
|
|
35
|
+
import { reachesNetwork } from "./danger.ts";
|
|
36
|
+
|
|
37
|
+
export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
|
|
38
|
+
|
|
39
|
+
/** True when the environment asks for private mode. */
|
|
40
|
+
export function privateModeFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
const v = (env[PRIVATE_ENV] ?? "").trim().toLowerCase();
|
|
42
|
+
return v === "1" || v === "true" || v === "yes";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tiers a sensitive file may be shown to. Nothing weaker, and nothing asserted. */
|
|
46
|
+
export const PRIVATE_TIERS: ReadonlySet<PrivacyTier> = new Set<PrivacyTier>(["tee-verified", "local"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tools that keep their effects on this machine. Everything else (web, crypto lookups,
|
|
50
|
+
* media generation, MCP, app/library uploads, routines, screen control) is refused in
|
|
51
|
+
* private mode, because each one can carry what was read to a third party. `bash` and
|
|
52
|
+
* `terminal` are here but also pass through reachesNetwork().
|
|
53
|
+
*/
|
|
54
|
+
export const PRIVATE_TOOLS: ReadonlySet<string> = new Set([
|
|
55
|
+
"read",
|
|
56
|
+
"grep",
|
|
57
|
+
"find",
|
|
58
|
+
"ls",
|
|
59
|
+
"write",
|
|
60
|
+
"edit",
|
|
61
|
+
"bash",
|
|
62
|
+
"terminal",
|
|
63
|
+
// Children inherit PRIVATEER_PRIVATE and run this same check against their own model.
|
|
64
|
+
"subagent",
|
|
65
|
+
"contact_supervisor",
|
|
66
|
+
"intercom",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export interface ModelRef {
|
|
70
|
+
provider?: string;
|
|
71
|
+
id?: string;
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TTL_MS = 60_000;
|
|
76
|
+
const tierCache = new Map<string, { tier: PrivacyTier; at: number }>();
|
|
77
|
+
|
|
78
|
+
/** Test seam: swap how a model's tier is resolved. */
|
|
79
|
+
let resolveTierImpl: (m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef) => Promise<PrivacyTier> = defaultResolveTier;
|
|
80
|
+
export function setTierResolverForTests(fn: typeof resolveTierImpl | undefined): void {
|
|
81
|
+
resolveTierImpl = fn ?? defaultResolveTier;
|
|
82
|
+
tierCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultResolveTier(m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef): Promise<PrivacyTier> {
|
|
86
|
+
// The account channel's verdict is ours to give (see config/privacyPolicy.ts
|
|
87
|
+
// resolveTier); every other provider is pi-privacy's, which attests TEE providers live
|
|
88
|
+
// and never returns tee-verified from a static claim.
|
|
89
|
+
if (m.provider === "privateer") {
|
|
90
|
+
const { accountPosture } = await import("../providers/account.ts");
|
|
91
|
+
return (await accountPosture(m.id)).tier;
|
|
92
|
+
}
|
|
93
|
+
const { verifyModelPosture } = await import("pi-privacy");
|
|
94
|
+
const apiKey = m.provider === "nearai" ? process.env.NEARAI_API_KEY : undefined;
|
|
95
|
+
return (await verifyModelPosture(m.provider, m.id, { baseUrl: m.baseUrl, apiKey })).tier;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The model's privacy tier, from a short cache. A failure is not a tier — it throws. */
|
|
99
|
+
export async function modelTier(model: ModelRef): Promise<PrivacyTier> {
|
|
100
|
+
if (!model.provider || !model.id) throw new Error("no model selected");
|
|
101
|
+
const key = `${model.provider}/${model.id}@${model.baseUrl ?? ""}`;
|
|
102
|
+
const hit = tierCache.get(key);
|
|
103
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.tier;
|
|
104
|
+
const tier = await resolveTierImpl({ ...model, provider: model.provider, id: model.id });
|
|
105
|
+
tierCache.set(key, { tier, at: Date.now() });
|
|
106
|
+
return tier;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const label = (m: ModelRef | undefined) => (m?.provider && m?.id ? `${m.provider}/${m.id}` : "no model");
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Why this tool call may not run in private mode, or undefined when it may. Never
|
|
113
|
+
* throws: any failure to establish privacy is itself the refusal.
|
|
114
|
+
*/
|
|
115
|
+
export async function privateRefusal(toolName: string, input: unknown, model: ModelRef | undefined): Promise<string | undefined> {
|
|
116
|
+
if (!PRIVATE_TOOLS.has(toolName)) {
|
|
117
|
+
return `${toolName} is unavailable in private mode: it can carry what this session has read off this machine. Work with local files only.`;
|
|
118
|
+
}
|
|
119
|
+
if (toolName === "bash" || toolName === "terminal") {
|
|
120
|
+
const obj = (input ?? {}) as Record<string, unknown>;
|
|
121
|
+
const command = String(obj.command ?? obj.cmd ?? obj.script ?? "");
|
|
122
|
+
if (reachesNetwork(command)) {
|
|
123
|
+
return `This command reaches the network, which private mode refuses: it could carry what this session has read off this machine. Use local commands only.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let tier: PrivacyTier;
|
|
127
|
+
try {
|
|
128
|
+
tier = await modelTier(model ?? {});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return `Private mode could not verify ${label(model)} (${e instanceof Error ? e.message : String(e)}), so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
131
|
+
}
|
|
132
|
+
if (!PRIVATE_TIERS.has(tier)) {
|
|
133
|
+
return `Private mode: ${label(model)} is "${tier}", not a verified-private model, so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
1
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { agentDir, globalDir } from "../config/paths.ts";
|
|
2
5
|
|
|
3
6
|
// Files we never auto-edit, even under acceptEdits/allowlist: shell rc / git /
|
|
4
7
|
// package-manager / secrets that a coding task should not silently rewrite. A
|
|
@@ -27,3 +30,35 @@ export function isProtectedPath(p: string): boolean {
|
|
|
27
30
|
const name = basename(p);
|
|
28
31
|
return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
|
|
29
32
|
}
|
|
33
|
+
|
|
34
|
+
// Where this machine keeps credentials that are never a coding task's business: the
|
|
35
|
+
// standard per-user secret directories, and the files at the top of our OWN state
|
|
36
|
+
// dirs — credentials.json, agent/auth.json, terminal-key.json, config.json (bot
|
|
37
|
+
// tokens, provider keys), mcp.json, account-trust.json and the rest. Only the TOP
|
|
38
|
+
// level of ~/.privateer and its agent dir: their subdirectories (routines output,
|
|
39
|
+
// projects, sessions) are ordinary data a run may legitimately be pointed at.
|
|
40
|
+
//
|
|
41
|
+
// Consulted only by surfaces that opt in (ScopeOptions.guardSecretReads — the
|
|
42
|
+
// harbor's unattended runs), where a READ of one of these is refused even inside the
|
|
43
|
+
// working directory: an unattended run that fetches a web page is one prompt
|
|
44
|
+
// injection away from reading a token and putting it in the next URL it fetches.
|
|
45
|
+
const SECRET_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".docker", join(".config", "gh"), join(".config", "gcloud")];
|
|
46
|
+
|
|
47
|
+
function canonical(p: string): string {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync(p);
|
|
50
|
+
} catch {
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function within(root: string, abs: string): boolean {
|
|
56
|
+
return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isCredentialStorePath(abs: string): boolean {
|
|
60
|
+
const home = homedir();
|
|
61
|
+
if (SECRET_DIRS.some((d) => within(canonical(join(home, d)), abs))) return true;
|
|
62
|
+
const parent = dirname(abs);
|
|
63
|
+
return parent === canonical(globalDir()) || parent === canonical(agentDir());
|
|
64
|
+
}
|
|
@@ -29,6 +29,8 @@ import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.
|
|
|
29
29
|
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
30
30
|
import { createUIContext } from "../ext/headlessUi.ts";
|
|
31
31
|
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
32
|
+
import type { AccountLease } from "../harbor/accountLease.ts";
|
|
33
|
+
import { closeSession } from "../harbor/closeSession.ts";
|
|
32
34
|
|
|
33
35
|
export interface LiveTaskHandle {
|
|
34
36
|
termId: string;
|
|
@@ -41,6 +43,10 @@ export interface LiveTaskDeps {
|
|
|
41
43
|
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
42
44
|
log: (msg: string) => void;
|
|
43
45
|
onClosed: (termId: string) => void;
|
|
46
|
+
// The harbor's shared account session (harbor/accountLease.ts). The credential slot is
|
|
47
|
+
// one per process, so a spawn minting and revoking its own killed whatever routine was
|
|
48
|
+
// mid-turn beside it. Absent → this session mints and revokes its own (standalone use).
|
|
49
|
+
account?: AccountLease;
|
|
44
50
|
// Deliver the session's closing answer durably (the harbor seals it to the account
|
|
45
51
|
// outbox, so it lands in the app's inbox). A live spawn's feed is otherwise purely
|
|
46
52
|
// ephemeral: close the screen, reap the session, and everything it said is gone —
|
|
@@ -72,6 +78,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
72
78
|
let initialPromptSent = false;
|
|
73
79
|
let stopped = false;
|
|
74
80
|
let spawnedAccount = false;
|
|
81
|
+
let leasedAccount = false;
|
|
75
82
|
|
|
76
83
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
77
84
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -111,10 +118,16 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
111
118
|
if (attachTimer) clearTimeout(attachTimer);
|
|
112
119
|
if (lifeTimer) clearTimeout(lifeTimer);
|
|
113
120
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
121
|
+
if (session) {
|
|
122
|
+
try { await session.abort?.(); } catch { /* nothing in flight */ }
|
|
123
|
+
await closeSession(session); // MCP connections, listeners — see closeSession.ts
|
|
124
|
+
}
|
|
114
125
|
attachments.cleanup(); // drop the scratch dir holding inbound file bytes
|
|
115
126
|
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
116
127
|
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
117
|
-
if (
|
|
128
|
+
if (leasedAccount) {
|
|
129
|
+
await deps.account!.release();
|
|
130
|
+
} else if (spawnedAccount) {
|
|
118
131
|
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
119
132
|
// Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
|
|
120
133
|
// must not delete an interactive terminal's entry (see providers/account.ts).
|
|
@@ -228,7 +241,10 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
228
241
|
});
|
|
229
242
|
|
|
230
243
|
const { provider, modelId } = deps.parseSpec(modelSpec);
|
|
231
|
-
if (provider === "privateer") {
|
|
244
|
+
if (provider === "privateer" && deps.account) {
|
|
245
|
+
leasedAccount = true; // set before the await, so stop() always releases what we took
|
|
246
|
+
await deps.account.acquire();
|
|
247
|
+
} else if (provider === "privateer") {
|
|
232
248
|
try {
|
|
233
249
|
const creds = await spawnAccountCredentials();
|
|
234
250
|
await persistAccountCredential(creds);
|