privateer-agent 0.12.13 → 0.12.15

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.
Files changed (37) hide show
  1. package/README.md +7 -0
  2. package/bin/privateer-subagent.mjs +10 -2
  3. package/extensions/privateer-desktop.ts +54 -0
  4. package/extensions/privateer-gate.ts +7 -0
  5. package/extensions/privateer-hints.ts +33 -2
  6. package/extensions/privateer-media.ts +17 -4
  7. package/extensions/privateer-privacy.ts +13 -33
  8. package/extensions/privateer-tools.ts +8 -3
  9. package/package.json +1 -1
  10. package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
  11. package/src/acp/run.ts +29 -2
  12. package/src/channels/run.ts +9 -0
  13. package/src/cli/chat.ts +47 -0
  14. package/src/config/desktopApp.ts +138 -0
  15. package/src/config/moat.ts +6 -26
  16. package/src/config/moatManifest.json +1 -0
  17. package/src/config/moatManifest.ts +5 -0
  18. package/src/config/privacyPolicy.ts +97 -0
  19. package/src/engine/errors.ts +97 -1
  20. package/src/ext/permissionGate.ts +6 -0
  21. package/src/harbor/index.ts +65 -11
  22. package/src/outbox/cloudOutbox.ts +72 -4
  23. package/src/permissions/childSpend.ts +105 -0
  24. package/src/permissions/classify.ts +61 -3
  25. package/src/permissions/modeGate.ts +39 -0
  26. package/src/providers/account.ts +8 -1
  27. package/src/providers/phala/measurements.ts +170 -0
  28. package/src/providers/phala/pin.ts +98 -0
  29. package/src/providers/phalaSeal.ts +131 -9
  30. package/src/providers/sealedShim.ts +6 -1
  31. package/src/remote/liveTaskSession.ts +8 -1
  32. package/src/remote/relayClient.ts +21 -0
  33. package/src/remote/remoteBridge.ts +9 -0
  34. package/src/routines/store.ts +11 -0
  35. package/src/tools/media.ts +516 -11
  36. package/src/tools/routineResult.ts +146 -0
  37. package/src/tools/videoCompose.ts +825 -7
@@ -0,0 +1,98 @@
1
+ // Trust-on-first-use pinning for the Phala enclave's identity.
2
+ //
3
+ // Why TOFU and not a real pin: Phala publishes no registry of known-good measurements.
4
+ // The expected MRTD/RTMR0-2 are *computed* with dstack-mr from the reproducible dstack
5
+ // OS build, and the gateway's compose-hash from its published source — neither of which
6
+ // we do yet. So the honest guarantee is narrow and worth stating plainly: we can tell
7
+ // you the image CHANGED, never that it was right to begin with.
8
+ //
9
+ // That shapes the semantics deliberately:
10
+ // - first sight records and reports `first-seen` — never "verified"
11
+ // - a later mismatch reports `changed` with the exact fields that moved
12
+ // - `changed` is NOT a failure. Phala upgrades the gateway legitimately, and hard-
13
+ // failing on every upgrade would train the user to ignore the one that matters.
14
+ // The verdict stays where the cryptography is; this only ever adds a visible state.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { globalDir } from "../../config/paths.ts";
19
+
20
+ // The fields worth pinning: the image identity (MRTD + boot registers, os-image-hash)
21
+ // and the app identity (app-id, compose-hash, key-management root). Deliberately NOT
22
+ // instance-id — it changes on every restart of the same image, so pinning it would
23
+ // cry wolf constantly and bury a real change.
24
+ export interface PhalaPin {
25
+ mrTd?: string;
26
+ rtMr0?: string;
27
+ rtMr1?: string;
28
+ rtMr2?: string;
29
+ appId?: string;
30
+ composeHash?: string;
31
+ osImageHash?: string;
32
+ mrKms?: string;
33
+ }
34
+
35
+ export type PinState = "first-seen" | "unchanged" | "changed";
36
+
37
+ export interface PinResult {
38
+ state: PinState;
39
+ // Field-level drift, e.g. "composeHash: 73fa4608… → 91bd0c2a…". Empty unless changed.
40
+ changed: string[];
41
+ firstSeenAt?: string;
42
+ }
43
+
44
+ const PIN_VERSION = 1;
45
+
46
+ function pinPath(): string {
47
+ return join(globalDir(), "phala-enclave-pin.json");
48
+ }
49
+
50
+ function readPin(): { v?: number; firstSeenAt?: string; pin?: PhalaPin } | null {
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(pinPath(), "utf8")) as { v?: number; firstSeenAt?: string; pin?: PhalaPin };
53
+ if (parsed?.v !== PIN_VERSION || !parsed.pin || typeof parsed.pin !== "object") return null;
54
+ return parsed;
55
+ } catch {
56
+ return null; // absent, unreadable, or garbage — treated as first sight
57
+ }
58
+ }
59
+
60
+ function writePin(pin: PhalaPin, firstSeenAt: string): void {
61
+ try {
62
+ mkdirSync(globalDir(), { recursive: true });
63
+ writeFileSync(pinPath(), JSON.stringify({ v: PIN_VERSION, firstSeenAt, pin }, null, 2) + "\n", "utf8");
64
+ } catch {
65
+ // Unwritable home: every run then reports first-seen. Degrading to "no memory" is
66
+ // right — the alternative is inventing an "unchanged" we cannot substantiate.
67
+ }
68
+ }
69
+
70
+ // Compare against the stored pin, recording it on first sight.
71
+ //
72
+ // `now` is injected rather than read from the clock so tests are deterministic; the
73
+ // timestamp is display-only (it never participates in the comparison).
74
+ export function checkPin(current: PhalaPin, now: () => string = () => new Date().toISOString()): PinResult {
75
+ const stored = readPin();
76
+ if (!stored?.pin) {
77
+ const firstSeenAt = now();
78
+ writePin(current, firstSeenAt);
79
+ return { state: "first-seen", changed: [], firstSeenAt };
80
+ }
81
+
82
+ const changed: string[] = [];
83
+ for (const key of Object.keys(current) as (keyof PhalaPin)[]) {
84
+ const was = stored.pin[key];
85
+ const is = current[key];
86
+ // Only compare fields present on BOTH sides. A field the gateway stopped sending
87
+ // is an absence, not a substitution, and calling it drift would be a false alarm.
88
+ if (was && is && was !== is) changed.push(`${key}: ${was.slice(0, 12)}… → ${is.slice(0, 12)}…`);
89
+ }
90
+
91
+ if (changed.length === 0) return { state: "unchanged", changed: [], firstSeenAt: stored.firstSeenAt };
92
+
93
+ // Re-pin to the new identity so the change is reported ONCE. Leaving the old pin in
94
+ // place would repeat the same warning every turn until it became wallpaper — and the
95
+ // user has already been shown exactly what moved.
96
+ writePin(current, stored.firstSeenAt ?? now());
97
+ return { state: "changed", changed, firstSeenAt: stored.firstSeenAt };
98
+ }
@@ -34,6 +34,13 @@ import {
34
34
  // refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
35
35
  // the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
36
36
  import { verifyAciReportBinding } from "./phala/reportBinding.ts";
37
+ import {
38
+ parseEventLog,
39
+ appIdentityFrom,
40
+ checkReportConsistency,
41
+ type PhalaAppIdentity,
42
+ } from "./phala/measurements.ts";
43
+ import { checkPin, type PinResult } from "./phala/pin.ts";
37
44
  import { serverBaseUrl } from "../auth/privateer.ts";
38
45
 
39
46
  const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
@@ -56,6 +63,51 @@ function acceptableTcb(): Set<string> {
56
63
  return new Set(list);
57
64
  }
58
65
 
66
+ // The launch measurements carried by a verified TDX quote. MRTD measures the initial
67
+ // TD build (the dstack OS image); RTMR0-2 accumulate firmware/kernel/config and RTMR3
68
+ // carries the app-level extensions (compose hash, app id).
69
+ //
70
+ // READING these is not CHECKING them. Nothing here compares a measurement against a
71
+ // known-good value, so they are evidence to display and pin later — never a verdict.
72
+ // The quote's signature is verified before we get here, so the bytes are authentic:
73
+ // what is unproven is that this particular image is the one we intend to be talking to.
74
+ export interface PhalaMeasurements {
75
+ mrTd: string;
76
+ rtMr0: string;
77
+ rtMr1: string;
78
+ rtMr2: string;
79
+ rtMr3: string;
80
+ }
81
+
82
+ // Pull the measurements off a verified quote report (TD1.0/1.5 layouts), or undefined
83
+ // when the report carries no TD measurement block (e.g. an SGX quote).
84
+ export function extractQuoteMeasurements(report: Report): PhalaMeasurements | undefined {
85
+ const td = report.asTd10?.() ?? report.asTd15?.()?.base;
86
+ const fields = [td?.mrTd, td?.rtMr0, td?.rtMr1, td?.rtMr2, td?.rtMr3];
87
+ // All five or none. A partial set still reads as evidence while silently omitting
88
+ // the register that would have contradicted it — RTMR3 (the app layer) most of all.
89
+ if (fields.some((f) => !f?.length)) return undefined;
90
+ const [mrTd, rtMr0, rtMr1, rtMr2, rtMr3] = fields.map((f) => toHex(new Uint8Array(f as Uint8Array)));
91
+ return { mrTd, rtMr0, rtMr1, rtMr2, rtMr3 };
92
+ }
93
+
94
+ // Everything we can say about the enclave that answered, once its quote verified.
95
+ // `measurements` and `identity` are what the hardware signed; `pin` is our own memory
96
+ // of it; the provenance and downstream fields are the enclave's self-declarations,
97
+ // carried for display precisely because they are NOT proven by the quote.
98
+ export interface PhalaEnclaveIdentity {
99
+ measurements: PhalaMeasurements;
100
+ identity: PhalaAppIdentity;
101
+ pin: PinResult;
102
+ // Self-consistency checks we could not run (absent event log / app_compose). Named
103
+ // so "we didn't check" can never be read as "we checked and it passed".
104
+ skippedChecks: string[];
105
+ repoUrl?: string;
106
+ repoCommit?: string;
107
+ downstreamDomain?: string;
108
+ downstreamSpkiSha256?: string;
109
+ }
110
+
59
111
  // The 64-byte report_data from a verified TDX quote report (TD1.0/1.5 layouts).
60
112
  function extractQuoteReportData(report: Report): Uint8Array {
61
113
  const td10 = report.asTd10?.();
@@ -70,6 +122,9 @@ function extractQuoteReportData(report: Report): Uint8Array {
70
122
  interface VerifiedAttestation {
71
123
  report: AttestationReport;
72
124
  verification: ReportVerification;
125
+ // Absent when the hardware quote was skipped (requireQuote off): with no verified
126
+ // quote there is nothing about the enclave we are entitled to show.
127
+ enclave?: PhalaEnclaveIdentity;
73
128
  }
74
129
 
75
130
  // Attest once, cache the verified report; drop the memo on failure so a later call
@@ -103,17 +158,24 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
103
158
  const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
104
159
  throw new Error(`phala attestation binding failed: ${failed}`);
105
160
  }
106
- await verifyHardwareQuote(report);
107
- return { report, verification };
161
+ const enclave = await verifyHardwareQuote(report);
162
+ return { report, verification, enclave };
108
163
  }
109
164
 
110
- async function verifyHardwareQuote(report: AttestationReport): Promise<void> {
111
- if (!requireQuote()) return;
165
+ async function verifyHardwareQuote(report: AttestationReport): Promise<PhalaEnclaveIdentity | undefined> {
166
+ if (!requireQuote()) return undefined;
112
167
 
113
168
  const attestation = report.attestation as unknown as {
114
169
  tee_type?: string;
115
170
  report_data?: string;
116
- evidence?: { quote?: string; quote_report_data?: string };
171
+ source_provenance?: { repo_url?: string; repo_commit?: string };
172
+ evidence?: {
173
+ quote?: string;
174
+ quote_report_data?: string;
175
+ event_log?: unknown;
176
+ app_compose?: unknown;
177
+ downstream_tls_binding?: { domain?: string; spki_sha256?: string };
178
+ };
117
179
  };
118
180
  const teeType = String(attestation?.tee_type || "");
119
181
  if (teeType !== "tdx") throw new Error(`phala: unsupported/absent tee_type "${teeType}" (only tdx is wired)`);
@@ -141,14 +203,74 @@ async function verifyHardwareQuote(report: AttestationReport): Promise<void> {
141
203
  if (typeof declared === "string" && declared && toHex(quoteReportData) !== declared.toLowerCase()) {
142
204
  throw new Error("phala: evidence.quote_report_data does not match the verified quote");
143
205
  }
206
+
207
+ // 4) The quote is authentic, so its measurement registers are trustworthy bytes.
208
+ const measurements = extractQuoteMeasurements(verified.report);
209
+ if (!measurements) return undefined; // non-TD quote: nothing further to check
210
+
211
+ // 5) SELF-CONSISTENCY GATES. The event log must replay to the registers the hardware
212
+ // signed, and the shipped app_compose must hash to the compose-hash the log
213
+ // attests. Both are checkable from the report alone, so a failure means the report
214
+ // is doctored or malformed — refuse it rather than showing a green shield over it.
215
+ const events = parseEventLog(attestation.evidence?.event_log);
216
+ const identity = appIdentityFrom(events);
217
+ const consistency = checkReportConsistency({
218
+ events,
219
+ quoted: measurements,
220
+ appCompose: attestation.evidence?.app_compose,
221
+ identity,
222
+ });
223
+ if (!consistency.ok) {
224
+ const failed = consistency.checks.filter((c) => !c.ok).map((c) => `${c.name} (${c.detail})`).join("; ");
225
+ throw new Error(`phala: attestation self-consistency failed: ${failed}`);
226
+ }
227
+
228
+ // 6) IDENTITY, as evidence only. TOFU against the stored pin — this can say the image
229
+ // CHANGED, never that it is the right one (no published registry to check against;
230
+ // see phala/pin.ts). It moves no verdict and never throws.
231
+ const pin = checkPin({
232
+ mrTd: measurements.mrTd,
233
+ rtMr0: measurements.rtMr0,
234
+ rtMr1: measurements.rtMr1,
235
+ rtMr2: measurements.rtMr2,
236
+ appId: identity.appId,
237
+ composeHash: identity.composeHash,
238
+ osImageHash: identity.osImageHash,
239
+ mrKms: identity.mrKms,
240
+ });
241
+
242
+ const provenance = attestation.source_provenance;
243
+ const downstream = attestation.evidence?.downstream_tls_binding;
244
+ return {
245
+ measurements,
246
+ identity,
247
+ pin,
248
+ skippedChecks: consistency.skipped,
249
+ // Named so a human can go read the source; self-declared by the enclave and NOT
250
+ // proven by the quote (image_digest/image_provenance come back null), so it is a
251
+ // pointer to audit, never evidence that this binary came from that commit.
252
+ repoUrl: provenance?.repo_url,
253
+ repoCommit: provenance?.repo_commit,
254
+ // The attested enclave forwards to this downstream host over TLS. Surfaced because
255
+ // it marks where our attested boundary ENDS — that host is a separate trust domain
256
+ // we do not attest.
257
+ downstreamDomain: downstream?.domain,
258
+ downstreamSpkiSha256: downstream?.spki_sha256,
259
+ };
144
260
  }
145
261
 
146
262
  // Posture signal: does the attested keyset verify (crypto binding + hardware quote)?
147
- // A green result is a quote WE checked, bound to the E2EE key we seal to.
148
- export async function attestPhala(): Promise<{ ok: boolean; error?: string }> {
263
+ // A green result is a quote WE checked, bound to the E2EE key we seal to. The
264
+ // measurements ride along as evidence of WHICH image answered — unpinned, so they
265
+ // inform the display without moving the verdict.
266
+ export async function attestPhala(): Promise<{
267
+ ok: boolean;
268
+ error?: string;
269
+ enclaveIdentity?: PhalaEnclaveIdentity;
270
+ }> {
149
271
  try {
150
- await attest();
151
- return { ok: true };
272
+ const { enclave } = await attest();
273
+ return { ok: true, enclaveIdentity: enclave };
152
274
  } catch (e) {
153
275
  return { ok: false, error: (e as Error).message };
154
276
  }
@@ -31,7 +31,7 @@ import http from "node:http";
31
31
  import { Readable } from "node:stream";
32
32
  import { SecureClient } from "tinfoil";
33
33
  import { serverBaseUrl } from "../auth/privateer.ts";
34
- import { attestPhala, phalaSealedFetch } from "./phalaSeal.ts";
34
+ import { attestPhala, phalaSealedFetch, type PhalaEnclaveIdentity } from "./phalaSeal.ts";
35
35
  import { iterateSSE } from "./phala/sse.ts";
36
36
 
37
37
  // Providers whose enclave supports application-layer body encryption + client-verified
@@ -109,6 +109,11 @@ export interface SealedAttestation {
109
109
  ok: boolean;
110
110
  enclave?: string;
111
111
  error?: string;
112
+ // Phala only: what the verified quote says about the enclave that answered (see
113
+ // phalaSeal.ts). Distinct from `enclave` above, which is Tinfoil's enclave URL —
114
+ // Tinfoil's SecureClient verifies its enclave internally and exposes no equivalent
115
+ // identity, so a green Tinfoil result carries none of this. Absence is not a failure.
116
+ enclaveIdentity?: PhalaEnclaveIdentity;
112
117
  }
113
118
 
114
119
  // Drive the provider's client-side attestation (the SAME client/keyset the shim
@@ -20,7 +20,7 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { moatResourceOptions } from "../config/moat.ts";
23
- import { rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
23
+ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential, ensureAccountArmed } from "../providers/account.ts";
24
24
  // Pi-free static graph by design — see the header of piAuthStore.ts.
25
25
  import { modelRegistryOf } from "../providers/piAuthStore.ts";
26
26
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
@@ -130,6 +130,13 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
130
130
  lastAnswer = ""; // keep the CLOSING answer, not the whole session
131
131
  turnErrored = false;
132
132
  try {
133
+ // auth.json's `privateer` entry is machine-global and single-slot, so another
134
+ // terminal arming over ours and then exiting deletes the credential this session
135
+ // is running on — every later turn would fail with "This terminal isn't signed in
136
+ // to Privateer" while the session we hold stays valid. Re-arm here rather than in
137
+ // providers/account.ts's `before_agent_start` net, which pi's prompt() throws
138
+ // past on its own `hasConfiguredAuth` precheck.
139
+ if (modelSpec.startsWith("privateer/")) await ensureAccountArmed(undefined);
133
140
  // Fold any files the app sent since the last prompt into a reference note, so the
134
141
  // model knows they exist and can save_attachment them to disk.
135
142
  const atts = sinceLastPrompt;
@@ -19,6 +19,7 @@ import WebSocket from "ws";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { homedir } from "node:os";
21
21
  import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
22
+ import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
22
23
  import type { EngineEvent } from "../engine/events.ts";
23
24
  import type { PermissionRequest } from "../permissions/gate.ts";
24
25
 
@@ -1089,6 +1090,21 @@ export class RelayClient {
1089
1090
  // each add/remove. `busy` drives a progress indicator; `needsRestart` tells the app
1090
1091
  // the change only takes effect on the next terminal launch. NON-PII: package
1091
1092
  // sources + scope only. Installed list bounded like sendCommands.
1093
+ //
1094
+ // `builtIn` + `managed` describe the MOAT, which is deliberately absent from
1095
+ // `installed` (it never enters settings "packages"). Without them the app's Browse
1096
+ // tab — an unfiltered npm `keywords:pi-package` search — offers Add on packages we
1097
+ // already ship: pi-mcp-adapter, pi-subagents and the rpiv packs are on its first
1098
+ // page. Every one of those round-trips to extensionsControl.add() only to come back
1099
+ // refused ("managed by Privateer"), and until it does the row reads as *not*
1100
+ // installed, which is the opposite of the truth. So:
1101
+ // • `builtIn` — what we ship, in load order, for a read-only section.
1102
+ // • `managed` — every name add()/remove() will refuse (builtIn + retired + the
1103
+ // scoped aliases), so the app can drop the Add affordance instead of offering
1104
+ // a button that always fails.
1105
+ // Stamped here rather than passed by the caller: it is a constant of the build, and
1106
+ // six call sites that each had to remember it is the drift moatManifest.json exists
1107
+ // to make unrepresentable. An older app ignores both fields.
1092
1108
  sendExtensions(payload: {
1093
1109
  installed: { source: string; scope: string; filtered?: boolean; installed?: boolean }[];
1094
1110
  busy?: boolean;
@@ -1103,6 +1119,11 @@ export class RelayClient {
1103
1119
  filtered: !!e.filtered,
1104
1120
  installed: !!e.installed,
1105
1121
  })),
1122
+ builtIn: MOAT_SHIMS.slice(0, 100).map((s) => ({
1123
+ name: safe(s.name, 120),
1124
+ note: s.note ? safe(s.note, 200) : undefined,
1125
+ })),
1126
+ managed: reservedNames().slice(0, 200).map((n) => safe(n, 120)),
1106
1127
  busy: !!payload.busy,
1107
1128
  message: payload.message ? safe(payload.message, 500) : undefined,
1108
1129
  needsRestart: !!payload.needsRestart,
@@ -42,8 +42,17 @@ export interface RelayLike {
42
42
  }
43
43
 
44
44
  // The installed-extensions snapshot relayed to the app's extensions manager.
45
+ //
46
+ // `managed`/`builtIn` describe the moat, which `installed` never contains (it is loaded
47
+ // as launch shims, not settings "packages"). Callers do NOT set them: each transport
48
+ // stamps them on the way out — RelayClient.sendExtensions from the shipping manifest,
49
+ // IpcRelay from reservedNames() — because a constant of the build passed by hand at six
50
+ // call sites is the drift moatManifest.json exists to make unrepresentable. Declared
51
+ // here only so a transport can type what it adds.
45
52
  export interface ExtensionsPayload {
46
53
  installed: { source: string; scope: string; filtered?: boolean; installed?: boolean }[];
54
+ builtIn?: { name: string; note?: string }[];
55
+ managed?: string[];
47
56
  busy?: boolean;
48
57
  message?: string;
49
58
  needsRestart?: boolean;
@@ -230,6 +230,17 @@ export interface PendingCloud {
230
230
  // instead. Copying megabytes into this queue would be the same content twice on
231
231
  // the one box that already has it.
232
232
  media?: StagedMedia[];
233
+ // What the run was asked to do (outbox/cloudOutbox.ts OutboxSource), so a flush
234
+ // hours later still seals the context a follow-up needs. Structurally typed rather
235
+ // than imported: this module is the queue's shape, and cloudOutbox already imports
236
+ // from here (importing back would make the cycle).
237
+ source?: {
238
+ routineId?: string;
239
+ prompt?: string;
240
+ cwd?: string;
241
+ model?: string;
242
+ schedule?: string;
243
+ };
233
244
  }
234
245
 
235
246
  function pendingCloudPath(): string {