privateer-agent 0.12.13 → 0.12.14
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/bin/privateer-subagent.mjs +10 -2
- package/extensions/privateer-gate.ts +7 -0
- package/extensions/privateer-media.ts +17 -4
- package/extensions/privateer-privacy.ts +13 -33
- package/extensions/privateer-tools.ts +8 -3
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
- package/src/acp/run.ts +29 -2
- package/src/channels/run.ts +9 -0
- package/src/cli/chat.ts +47 -0
- package/src/config/moat.ts +6 -26
- package/src/config/moatManifest.ts +5 -0
- package/src/config/privacyPolicy.ts +97 -0
- package/src/engine/errors.ts +97 -1
- package/src/ext/permissionGate.ts +6 -0
- package/src/harbor/index.ts +65 -11
- package/src/outbox/cloudOutbox.ts +72 -4
- package/src/permissions/childSpend.ts +105 -0
- package/src/permissions/classify.ts +61 -3
- package/src/permissions/modeGate.ts +39 -0
- package/src/providers/account.ts +8 -1
- package/src/providers/phala/measurements.ts +170 -0
- package/src/providers/phala/pin.ts +98 -0
- package/src/providers/phalaSeal.ts +131 -9
- package/src/providers/sealedShim.ts +6 -1
- package/src/remote/liveTaskSession.ts +8 -1
- package/src/remote/relayClient.ts +21 -0
- package/src/remote/remoteBridge.ts +9 -0
- package/src/routines/store.ts +11 -0
- package/src/tools/media.ts +516 -11
- package/src/tools/routineResult.ts +146 -0
- package/src/tools/videoCompose.ts +825 -7
|
@@ -54,6 +54,16 @@ export interface ModeGateDeps {
|
|
|
54
54
|
// the remote branch, even the dangerous-command denylist): the operator has
|
|
55
55
|
// explicitly opted the whole session out of the moat. Off unless the flag is set.
|
|
56
56
|
getSkipAllPermissions?: () => boolean;
|
|
57
|
+
// Spend the operator authorized IN ADVANCE, by tool name, at a moment when there WAS
|
|
58
|
+
// a human to ask: the media tools a routine names when it is saved (naming them is
|
|
59
|
+
// itself the decision — they are deliberately absent from the default allow-list, and
|
|
60
|
+
// saving a routine that grants egress is an alwaysAsk prompt of its own), handed to
|
|
61
|
+
// the run that fires hours later with nobody watching.
|
|
62
|
+
//
|
|
63
|
+
// Consulted ONLY to lift `alwaysAsk`, and only under the guards in ModeGate.request.
|
|
64
|
+
// Absent ⇒ nothing is pre-authorized, which is the posture every interactive session
|
|
65
|
+
// keeps: a terminal always asks its human, however cheap the call.
|
|
66
|
+
isSpendPreauthorized?: (req: PermissionRequest) => boolean;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -96,6 +106,35 @@ export class ModeGate implements PermissionGate {
|
|
|
96
106
|
|
|
97
107
|
if (auto !== "ask") return auto;
|
|
98
108
|
|
|
109
|
+
// Pre-authorized spend. An unattended run reaches here with no one to ask, so an
|
|
110
|
+
// `alwaysAsk` tool — every billing media tool — was denied outright: the harbor
|
|
111
|
+
// let a routine NAME generate_video and then blocked every call it made, which is
|
|
112
|
+
// not a safe default so much as a capability that silently didn't exist.
|
|
113
|
+
//
|
|
114
|
+
// This lifts that one veto, and only that one. Four guards, all load-bearing:
|
|
115
|
+
//
|
|
116
|
+
// • the controller must vouch for THIS tool by name (the harbor passes the media
|
|
117
|
+
// tools this run's own allow-list names — see harbor/index.ts);
|
|
118
|
+
// • `alwaysAsk` must be the ONLY reason we're asking. Re-deciding with the flag
|
|
119
|
+
// cleared is how that is checked, so a pre-authorized tool in `plan` mode is
|
|
120
|
+
// still denied and one at the default mode still prompts — pre-authorization
|
|
121
|
+
// never grants what the mode wouldn't;
|
|
122
|
+
// • never when the call leaves the working directory or touches a protected file.
|
|
123
|
+
// bypass mode allows both outright, so this cannot lean on the re-decide above:
|
|
124
|
+
// "you may generate video" must not become "you may upload ~/.ssh/id_rsa as a
|
|
125
|
+
// reference image", which is exactly the shape classify.ts flags;
|
|
126
|
+
// • never on a remote-driven turn — that branch returned above. A driven turn has
|
|
127
|
+
// a human holding the phone, and they get the prompt.
|
|
128
|
+
if (
|
|
129
|
+
req.alwaysAsk &&
|
|
130
|
+
!req.outside &&
|
|
131
|
+
!req.protected &&
|
|
132
|
+
this.deps.isSpendPreauthorized?.(req) === true &&
|
|
133
|
+
decideAuto({ ...req, alwaysAsk: false }, this.deps.getMode(), this.deps.allowlist, denylist) === "allow"
|
|
134
|
+
) {
|
|
135
|
+
return "allow";
|
|
136
|
+
}
|
|
137
|
+
|
|
99
138
|
// A dangerous command (or an always-ask destructive action) can be approved
|
|
100
139
|
// once, but is never remembered: adding it to the allowlist or relaxing the
|
|
101
140
|
// mode would let a later variant slip through.
|
package/src/providers/account.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
ensureSealedShim,
|
|
37
37
|
attestSealed,
|
|
38
38
|
} from "./sealedShim.ts";
|
|
39
|
+
import type { PhalaEnclaveIdentity } from "./phalaSeal.ts";
|
|
39
40
|
|
|
40
41
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
41
42
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
@@ -502,6 +503,10 @@ export interface AccountPosture {
|
|
|
502
503
|
tier: PrivacyTier;
|
|
503
504
|
teePosture?: "green" | "yellow" | "red";
|
|
504
505
|
error?: string;
|
|
506
|
+
// Phala sealed path only: what the verified quote says about the enclave that
|
|
507
|
+
// answered. Evidence about WHICH image it was, not part of the verdict — the tier
|
|
508
|
+
// above is decided by the crypto binding + quote alone. See phalaSeal.ts.
|
|
509
|
+
enclaveIdentity?: PhalaEnclaveIdentity;
|
|
505
510
|
}
|
|
506
511
|
|
|
507
512
|
// Posture for an account-channel model. For NEAR models the attestation is fetched
|
|
@@ -526,7 +531,9 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
526
531
|
const sealedProvider = sealedEnabled() ? sealedProviderFor(modelId) : null;
|
|
527
532
|
if (sealedProvider) {
|
|
528
533
|
const att = await attestSealed(sealedProvider);
|
|
529
|
-
return att.ok
|
|
534
|
+
return att.ok
|
|
535
|
+
? { tier: "tee-verified", enclaveIdentity: att.enclaveIdentity }
|
|
536
|
+
: { tier: "tee-unverified", error: att.error };
|
|
530
537
|
}
|
|
531
538
|
// Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
|
|
532
539
|
// explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// TDX measurement verification for the Phala ACI report — the layer above the quote
|
|
2
|
+
// signature check in ../phalaSeal.ts.
|
|
3
|
+
//
|
|
4
|
+
// The quote proves genuine Intel silicon and binds the E2EE key we seal to. It does
|
|
5
|
+
// NOT say which image answered. These are the checks that start to close that gap,
|
|
6
|
+
// and they split into two kinds that must never be confused:
|
|
7
|
+
//
|
|
8
|
+
// SELF-CONSISTENCY (gates, verified here from the report alone)
|
|
9
|
+
// - the event log replays to the RTMRs the hardware signed
|
|
10
|
+
// - sha256(app_compose) equals the compose-hash the log claims
|
|
11
|
+
// Both are checkable with no outside knowledge, so a failure means the report is
|
|
12
|
+
// malformed or doctored and we refuse it.
|
|
13
|
+
//
|
|
14
|
+
// IDENTITY (evidence, NOT a gate)
|
|
15
|
+
// - is this the same image we saw last time?
|
|
16
|
+
// Phala publishes no registry of known-good measurements; expected MRTD/RTMR0-2
|
|
17
|
+
// are computed with dstack-mr from the reproducible dstack OS build. Until we do
|
|
18
|
+
// that, first contact is trust-on-first-use: we can detect that the image CHANGED,
|
|
19
|
+
// never that it is the RIGHT one. Reporting drift as a hard failure would be a
|
|
20
|
+
// false alarm on every legitimate upgrade; reporting first-sight as "verified"
|
|
21
|
+
// would be the overclaim. So it surfaces as its own state and moves no verdict.
|
|
22
|
+
//
|
|
23
|
+
// Verified against the live gateway 2026-08-17: all four RTMRs replay and the
|
|
24
|
+
// compose-hash matches.
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
// One entry of the dstack event log. `imr` selects the register it extends; `digest`
|
|
29
|
+
// is what actually gets hashed in. `event`/`event_payload` are the human-readable
|
|
30
|
+
// name and value (app-id, compose-hash, os-image-hash, …).
|
|
31
|
+
export interface TdxEvent {
|
|
32
|
+
imr: number;
|
|
33
|
+
digest: string;
|
|
34
|
+
event?: string;
|
|
35
|
+
event_payload?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The app-layer values the IMR3 log names. Every one is a string we can pin.
|
|
39
|
+
export interface PhalaAppIdentity {
|
|
40
|
+
appId?: string;
|
|
41
|
+
composeHash?: string;
|
|
42
|
+
osImageHash?: string;
|
|
43
|
+
instanceId?: string;
|
|
44
|
+
mrKms?: string;
|
|
45
|
+
keyProvider?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ReplayedRtmrs {
|
|
49
|
+
rtMr0: string;
|
|
50
|
+
rtMr1: string;
|
|
51
|
+
rtMr2: string;
|
|
52
|
+
rtMr3: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RTMR_KEYS = ["rtMr0", "rtMr1", "rtMr2", "rtMr3"] as const;
|
|
56
|
+
|
|
57
|
+
// The event log arrives as a JSON *string* inside evidence (not an array), so it has
|
|
58
|
+
// to be parsed before anything can replay it. Lenient: a shape we don't recognise
|
|
59
|
+
// yields [] and the caller decides — an absent log is a missing check, not a forgery.
|
|
60
|
+
export function parseEventLog(raw: unknown): TdxEvent[] {
|
|
61
|
+
let value: unknown = raw;
|
|
62
|
+
if (typeof raw === "string") {
|
|
63
|
+
try {
|
|
64
|
+
value = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(value)) return [];
|
|
70
|
+
return value.filter(
|
|
71
|
+
(e): e is TdxEvent =>
|
|
72
|
+
!!e && typeof e === "object" && typeof (e as TdxEvent).imr === "number" && typeof (e as TdxEvent).digest === "string",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Replay the hash chain each register accumulates: starting from 48 zero bytes,
|
|
77
|
+
// rtmr = SHA384(rtmr ‖ digest) for every event extending that register, in log order.
|
|
78
|
+
// Order is load-bearing — the same events in a different sequence give a different
|
|
79
|
+
// register, which is exactly what makes the log unforgeable against a signed quote.
|
|
80
|
+
export function replayRtmrs(events: TdxEvent[]): ReplayedRtmrs {
|
|
81
|
+
const out = {} as Record<(typeof RTMR_KEYS)[number], string>;
|
|
82
|
+
RTMR_KEYS.forEach((key, imr) => {
|
|
83
|
+
let acc = Buffer.alloc(48);
|
|
84
|
+
for (const ev of events) {
|
|
85
|
+
if (ev.imr !== imr) continue;
|
|
86
|
+
let digest: Buffer;
|
|
87
|
+
try {
|
|
88
|
+
digest = Buffer.from(ev.digest, "hex");
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
acc = createHash("sha384").update(Buffer.concat([acc, digest])).digest();
|
|
93
|
+
}
|
|
94
|
+
out[key] = acc.toString("hex");
|
|
95
|
+
});
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The named app-layer values out of the IMR3 events.
|
|
100
|
+
export function appIdentityFrom(events: TdxEvent[]): PhalaAppIdentity {
|
|
101
|
+
const byName = new Map<string, string>();
|
|
102
|
+
for (const ev of events) {
|
|
103
|
+
if (ev.imr === 3 && ev.event && typeof ev.event_payload === "string" && ev.event_payload) {
|
|
104
|
+
byName.set(ev.event, ev.event_payload);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
appId: byName.get("app-id"),
|
|
109
|
+
composeHash: byName.get("compose-hash"),
|
|
110
|
+
osImageHash: byName.get("os-image-hash"),
|
|
111
|
+
instanceId: byName.get("instance-id"),
|
|
112
|
+
mrKms: byName.get("mr-kms"),
|
|
113
|
+
keyProvider: byName.get("key-provider"),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// sha256 over the app-compose document exactly as shipped. Hashing a re-serialized
|
|
118
|
+
// object would silently "fix" any difference in key order or whitespace and match a
|
|
119
|
+
// document that isn't the measured one, so a string is hashed verbatim.
|
|
120
|
+
export function computeComposeHash(appCompose: unknown): string | undefined {
|
|
121
|
+
if (typeof appCompose !== "string" || !appCompose) return undefined;
|
|
122
|
+
return createHash("sha256").update(Buffer.from(appCompose, "utf8")).digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ConsistencyCheck {
|
|
126
|
+
name: string;
|
|
127
|
+
ok: boolean;
|
|
128
|
+
detail?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The self-consistency gates. `skipped` (no material to check) is reported as its own
|
|
132
|
+
// outcome rather than silently passing — a check we did not run must never read as a
|
|
133
|
+
// check that succeeded.
|
|
134
|
+
export function checkReportConsistency(args: {
|
|
135
|
+
events: TdxEvent[];
|
|
136
|
+
quoted: ReplayedRtmrs;
|
|
137
|
+
appCompose: unknown;
|
|
138
|
+
identity: PhalaAppIdentity;
|
|
139
|
+
}): { checks: ConsistencyCheck[]; ok: boolean; skipped: string[] } {
|
|
140
|
+
const checks: ConsistencyCheck[] = [];
|
|
141
|
+
const skipped: string[] = [];
|
|
142
|
+
|
|
143
|
+
if (args.events.length === 0) {
|
|
144
|
+
skipped.push("rtmr-replay");
|
|
145
|
+
} else {
|
|
146
|
+
const replayed = replayRtmrs(args.events);
|
|
147
|
+
for (const key of RTMR_KEYS) {
|
|
148
|
+
const ok = replayed[key] === args.quoted[key];
|
|
149
|
+
checks.push({
|
|
150
|
+
name: `rtmr-replay:${key}`,
|
|
151
|
+
ok,
|
|
152
|
+
detail: ok ? undefined : `replayed ${replayed[key].slice(0, 16)}… but the quote signed ${args.quoted[key].slice(0, 16)}…`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const computed = computeComposeHash(args.appCompose);
|
|
158
|
+
if (!computed || !args.identity.composeHash) {
|
|
159
|
+
skipped.push("compose-hash");
|
|
160
|
+
} else {
|
|
161
|
+
const ok = computed === args.identity.composeHash;
|
|
162
|
+
checks.push({
|
|
163
|
+
name: "compose-hash",
|
|
164
|
+
ok,
|
|
165
|
+
detail: ok ? undefined : `sha256(app_compose)=${computed.slice(0, 16)}… but the log attests ${args.identity.composeHash.slice(0, 16)}…`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { checks, ok: checks.every((c) => c.ok), skipped };
|
|
170
|
+
}
|
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/routines/store.ts
CHANGED
|
@@ -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 {
|