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.
- package/README.md +7 -0
- package/bin/privateer-subagent.mjs +10 -2
- package/extensions/privateer-desktop.ts +54 -0
- package/extensions/privateer-gate.ts +7 -0
- package/extensions/privateer-hints.ts +33 -2
- 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/desktopApp.ts +138 -0
- package/src/config/moat.ts +6 -26
- package/src/config/moatManifest.json +1 -0
- 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
|
@@ -68,6 +68,62 @@ export interface OutboxMedia {
|
|
|
68
68
|
blobId?: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What the run was ASKED to do, carried beside what it answered.
|
|
73
|
+
*
|
|
74
|
+
* A result on its own is a dead end: the app can show it, read it aloud and file
|
|
75
|
+
* it, but "now book the top one" needs the standing instruction the run was given,
|
|
76
|
+
* and the directory it ran in, or the follow-up starts from a summary with no idea
|
|
77
|
+
* what produced it. That context is the routine — it lives on this machine and the
|
|
78
|
+
* app has never seen it — so it rides inside the SEALED envelope (the server holds
|
|
79
|
+
* ciphertext either way, exactly like `origin`).
|
|
80
|
+
*
|
|
81
|
+
* Every field is optional and clipped: this travels on every result, and a routine
|
|
82
|
+
* prompt can be arbitrarily long. Absent → the app falls back to the result body
|
|
83
|
+
* alone, which is what results from older CLIs carry.
|
|
84
|
+
*/
|
|
85
|
+
export interface OutboxSource {
|
|
86
|
+
/** The saved routine's id, when one produced this. Absent for ad-hoc tasks. */
|
|
87
|
+
routineId?: string;
|
|
88
|
+
/** The instruction the run was given (a routine's `prompt`, a task's spec). */
|
|
89
|
+
prompt?: string;
|
|
90
|
+
/** Where the run executed — a follow-up belongs in the same directory. */
|
|
91
|
+
cwd?: string;
|
|
92
|
+
/** The "provider:model" the run resolved to. */
|
|
93
|
+
model?: string;
|
|
94
|
+
/** The trigger verbatim: a cron expression, or a one-off ISO datetime. */
|
|
95
|
+
schedule?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clip bounds for `source`. The prompt is the only field that can be long. */
|
|
99
|
+
export const MAX_SOURCE_PROMPT = 2_000;
|
|
100
|
+
const SOURCE_FIELD_CAPS: Record<keyof OutboxSource, number> = {
|
|
101
|
+
routineId: 200,
|
|
102
|
+
prompt: MAX_SOURCE_PROMPT,
|
|
103
|
+
cwd: 500,
|
|
104
|
+
model: 200,
|
|
105
|
+
schedule: 100,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Normalize a source for the envelope: trim, clip, drop empties — and return
|
|
110
|
+
* undefined when nothing survives, so a workflow (or an empty-prompt live spawn)
|
|
111
|
+
* doesn't ship an empty object and bump the envelope version for nothing.
|
|
112
|
+
* Exported for the round-trip test.
|
|
113
|
+
*/
|
|
114
|
+
export function packSource(source?: OutboxSource): OutboxSource | undefined {
|
|
115
|
+
if (!source) return undefined;
|
|
116
|
+
const out: OutboxSource = {};
|
|
117
|
+
for (const [key, cap] of Object.entries(SOURCE_FIELD_CAPS) as [keyof OutboxSource, number][]) {
|
|
118
|
+
const raw = source[key];
|
|
119
|
+
if (typeof raw !== "string") continue;
|
|
120
|
+
const value = raw.trim();
|
|
121
|
+
if (!value) continue;
|
|
122
|
+
out[key] = value.length > cap ? value.slice(0, cap) + "…" : value;
|
|
123
|
+
}
|
|
124
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
71
127
|
let outboxPub: Uint8Array | undefined;
|
|
72
128
|
let originCache: { id: string; label: string } | undefined;
|
|
73
129
|
|
|
@@ -216,6 +272,9 @@ function undeliveredNote(names: string[]): string {
|
|
|
216
272
|
* BEFORE the message, so a message that lands always references bytes that exist;
|
|
217
273
|
* if the message then fails, the blobs are dropped again and the caller's queued
|
|
218
274
|
* retry re-uploads from the same paths on disk.
|
|
275
|
+
*
|
|
276
|
+
* `source` is what the run was asked to do (see OutboxSource) — the context a
|
|
277
|
+
* follow-up needs, sealed alongside the answer.
|
|
219
278
|
*/
|
|
220
279
|
export async function postOutbox(
|
|
221
280
|
name: string,
|
|
@@ -224,20 +283,28 @@ export async function postOutbox(
|
|
|
224
283
|
content: string,
|
|
225
284
|
kind: OutboxKind = "routine",
|
|
226
285
|
staged: StagedMedia[] = [],
|
|
286
|
+
source?: OutboxSource,
|
|
227
287
|
): Promise<boolean> {
|
|
228
288
|
const pub = await ensureOutboxPub();
|
|
229
289
|
if (!pub) return false;
|
|
230
290
|
let body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
231
291
|
|
|
292
|
+
// Packed before the media budget is struck, because it spends the same envelope:
|
|
293
|
+
// attachments must not be sized against room the source has already taken.
|
|
294
|
+
const packedSource = packSource(source);
|
|
295
|
+
const sourceCost = packedSource ? JSON.stringify(packedSource).length : 0;
|
|
296
|
+
|
|
232
297
|
const { media, blobIds, undelivered } = staged.length
|
|
233
|
-
? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - 2_000)
|
|
298
|
+
? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - sourceCost - 2_000)
|
|
234
299
|
: { media: [] as OutboxMedia[], blobIds: [] as string[], undelivered: [] as string[] };
|
|
235
300
|
body += undeliveredNote(undelivered);
|
|
236
301
|
|
|
237
|
-
// v2 adds `media`.
|
|
238
|
-
// why the note above names undelivered files
|
|
302
|
+
// v2 adds `media`, v3 `source`. Both are additive: an older app ignores the field
|
|
303
|
+
// and still renders the body, which is why the note above names undelivered files
|
|
304
|
+
// in prose rather than only in metadata, and why a follow-up degrades to "the
|
|
305
|
+
// result text alone" rather than failing when `source` is missing.
|
|
239
306
|
const envelope: Record<string, unknown> = {
|
|
240
|
-
v: media.length > 0 ? 2 : 1,
|
|
307
|
+
v: packedSource ? 3 : media.length > 0 ? 2 : 1,
|
|
241
308
|
kind,
|
|
242
309
|
name,
|
|
243
310
|
status,
|
|
@@ -245,6 +312,7 @@ export async function postOutbox(
|
|
|
245
312
|
content: body,
|
|
246
313
|
origin: machineOrigin(),
|
|
247
314
|
...(media.length > 0 ? { media } : {}),
|
|
315
|
+
...(packedSource ? { source: packedSource } : {}),
|
|
248
316
|
};
|
|
249
317
|
|
|
250
318
|
let sealed = sealJson(pub, envelope);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Handing an unattended run's SPEND AUTHORIZATION down to its subagent children.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. A subagent is a fresh headless `pi` subprocess (pi-subagents, spawned
|
|
4
|
+
// through bin/privateer-subagent.mjs), so it inherits nothing from its parent except the
|
|
5
|
+
// environment and the `-e` moat the wrapper injects. Its gate therefore runs in bypass
|
|
6
|
+
// with a fail-closed asker: ordinary writes go through, and every `alwaysAsk` tool — which
|
|
7
|
+
// is every billing media tool — is denied, because there is no human on the other end of
|
|
8
|
+
// a headless child's stdin. So "one subagent per shot" worked in a terminal, where the
|
|
9
|
+
// child's ask relays up to the parent's TUI, and quietly could not work at all in the one
|
|
10
|
+
// place it matters most: a routine that fires at 3am to build a film.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IS HANDED DOWN, AND WHY THAT IS NOT A HOLE. Only the names of billing tools the
|
|
13
|
+
// operator already authorized for this run, by naming them when they saved the routine —
|
|
14
|
+
// media generation is deliberately absent from the harbor's default allow-list precisely
|
|
15
|
+
// so that granting it is a decision someone made. The child gets no more than its parent
|
|
16
|
+
// has, and the gate still refuses to let a pre-authorization cover a call that leaves the
|
|
17
|
+
// working directory or touches a protected file (see ModeGate.isSpendPreauthorized).
|
|
18
|
+
//
|
|
19
|
+
// HONOURED ONLY IN A CHILD. `PRIVATEER_CHILD_SPEND` sitting in a developer's shell must
|
|
20
|
+
// never turn a TERMINAL into a session that bills without asking, so the child side reads
|
|
21
|
+
// it only when pi-subagents has marked this process as a child. A top-level session always
|
|
22
|
+
// asks its human, however cheap the call.
|
|
23
|
+
//
|
|
24
|
+
// CONCURRENCY, AND WHY THE UNION WOULD BE WRONG. The harbor daemon can have two unattended
|
|
25
|
+
// runs in flight at once (a scheduled routine and an app-submitted task), and children read
|
|
26
|
+
// the environment when they spawn — one process-wide variable, two different grants. Taking
|
|
27
|
+
// the union would let run B's child spend on a tool only run A was granted. So the exported
|
|
28
|
+
// value is the INTERSECTION of every grant currently in flight: exact when one run holds a
|
|
29
|
+
// grant (the overwhelmingly common case), and narrowing — fail-closed, with the gate's
|
|
30
|
+
// ordinary denial message — when two overlap. A child denied that way reports it plainly;
|
|
31
|
+
// a child over-granted would bill silently.
|
|
32
|
+
|
|
33
|
+
import { isSubagentChild } from "../remote/subagentRelay.ts";
|
|
34
|
+
|
|
35
|
+
/** The env var carrying a run's spend grant to its (possibly nested) children. */
|
|
36
|
+
export const CHILD_SPEND_ENV = "PRIVATEER_CHILD_SPEND";
|
|
37
|
+
|
|
38
|
+
// Grants currently in flight, keyed by the run that holds one. Module-level because the
|
|
39
|
+
// value it projects is process-wide (the environment) — one registry per daemon.
|
|
40
|
+
const active = new Map<string, ReadonlySet<string>>();
|
|
41
|
+
|
|
42
|
+
function publish(): void {
|
|
43
|
+
if (active.size === 0) {
|
|
44
|
+
delete process.env[CHILD_SPEND_ENV];
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const sets = [...active.values()];
|
|
48
|
+
const shared = [...sets[0]].filter((tool) => sets.every((s) => s.has(tool)));
|
|
49
|
+
if (shared.length === 0) delete process.env[CHILD_SPEND_ENV];
|
|
50
|
+
else process.env[CHILD_SPEND_ENV] = [...new Set(shared)].sort().join(",");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Advertise `tools` to children spawned while this run is in flight, and return the
|
|
55
|
+
* release. ALWAYS call the release in a `finally`: a grant that outlives its run would
|
|
56
|
+
* authorize the next one's children, which is the whole thing this is careful about.
|
|
57
|
+
* An empty/absent `tools` registers nothing, so a run with no media grant neither widens
|
|
58
|
+
* nor narrows what a concurrent run advertises.
|
|
59
|
+
*/
|
|
60
|
+
export function grantChildSpend(runKey: string, tools: Iterable<string> | undefined): () => void {
|
|
61
|
+
const set = new Set([...(tools ?? [])].filter((t) => typeof t === "string" && t.trim() !== ""));
|
|
62
|
+
if (set.size === 0) return () => {};
|
|
63
|
+
active.set(runKey, set);
|
|
64
|
+
publish();
|
|
65
|
+
return () => {
|
|
66
|
+
active.delete(runKey);
|
|
67
|
+
publish();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Test seam: forget every in-flight grant (and the env var it projects). */
|
|
72
|
+
export function resetChildSpend(): void {
|
|
73
|
+
active.clear();
|
|
74
|
+
delete process.env[CHILD_SPEND_ENV];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The grant this process inherited — empty unless we ARE a subagent child, so a stray
|
|
79
|
+
* env var can never lower a terminal's gate. Parsed defensively: unknown names simply
|
|
80
|
+
* never match a tool, and the gate's own guards bound what a match can authorize.
|
|
81
|
+
*/
|
|
82
|
+
export function inheritedChildSpend(env: NodeJS.ProcessEnv = process.env): ReadonlySet<string> {
|
|
83
|
+
if (!isSubagentChild()) return new Set();
|
|
84
|
+
const raw = env[CHILD_SPEND_ENV];
|
|
85
|
+
if (!raw) return new Set();
|
|
86
|
+
return new Set(
|
|
87
|
+
raw
|
|
88
|
+
.split(",")
|
|
89
|
+
.map((t) => t.trim())
|
|
90
|
+
.filter(Boolean),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Is `tool` pre-authorized for this process? False everywhere but a child that was
|
|
96
|
+
* granted it. Shape matches GateController.isSpendPreauthorized's needs.
|
|
97
|
+
*/
|
|
98
|
+
export function childSpendAllows(tool: string): boolean {
|
|
99
|
+
return inheritedChildSpend().has(tool);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Does this child hold any spend grant at all — i.e. should billing tools exist here? */
|
|
103
|
+
export function childHoldsSpendGrant(): boolean {
|
|
104
|
+
return inheritedChildSpend().size > 0;
|
|
105
|
+
}
|
|
@@ -118,6 +118,14 @@ function str(v: unknown): string {
|
|
|
118
118
|
return typeof v === "string" ? v : v == null ? "" : String(v);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// A path from a list entry that may be a bare string or an object carrying one —
|
|
122
|
+
// video_compose's `tracks` and `images` take both, and the tools open whichever arrives,
|
|
123
|
+
// so the classifier has to resolve whichever arrives. Miss the object form and
|
|
124
|
+
// `tracks: [{path: "~/.ssh/id_rsa"}]` reads a key with `outside` left false.
|
|
125
|
+
function nestedPath(entry: unknown): string {
|
|
126
|
+
return entry && typeof entry === "object" ? str((entry as Record<string, unknown>).path) : str(entry);
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
function firstPath(input: Record<string, unknown>): string {
|
|
122
130
|
return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
|
|
123
131
|
}
|
|
@@ -166,8 +174,10 @@ const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
|
|
|
166
174
|
const MEDIA_TOOLS = new Set([
|
|
167
175
|
"generate_image",
|
|
168
176
|
"generate_video",
|
|
177
|
+
"generate_model",
|
|
169
178
|
"generate_speech",
|
|
170
179
|
"generate_music",
|
|
180
|
+
"generate_sfx",
|
|
171
181
|
"media_capabilities",
|
|
172
182
|
"video_compose",
|
|
173
183
|
]);
|
|
@@ -181,17 +191,30 @@ const MEDIA_TOOLS = new Set([
|
|
|
181
191
|
// remembered, so every generation is a fresh human decision. video_compose is
|
|
182
192
|
// excluded on purpose — it is local ffmpeg, no egress and no spend — and
|
|
183
193
|
// media_capabilities is a read.
|
|
184
|
-
|
|
194
|
+
// Exported because it is the definition of "this call bills, so a human decides": the
|
|
195
|
+
// harbor reads it to work out which of a routine's granted tools its pre-authorization
|
|
196
|
+
// has to cover (harbor/index.ts), and a second hand-written copy of this list there
|
|
197
|
+
// would be one that drifts.
|
|
198
|
+
export const BILLED_MEDIA_TOOLS: ReadonlySet<string> = new Set([
|
|
185
199
|
"generate_image",
|
|
186
200
|
"generate_video",
|
|
201
|
+
"generate_model",
|
|
187
202
|
"generate_speech",
|
|
188
203
|
"generate_music",
|
|
204
|
+
"generate_sfx",
|
|
189
205
|
]);
|
|
190
206
|
const MEDIA_TITLES: Record<string, string> = {
|
|
191
207
|
generate_image: "Generate an image (billed to your Privateer account)",
|
|
192
208
|
generate_video: "Generate a video (billed to your Privateer account)",
|
|
209
|
+
// The dearest of these per call, and the one whose price moves with the
|
|
210
|
+
// options, so the title says so out loud rather than leaving the human to
|
|
211
|
+
// work it out from a JSON blob of flags.
|
|
212
|
+
generate_model: "Generate a 3D model (billed; $0.14-$2.41 a mesh depending on the model)",
|
|
193
213
|
generate_speech: "Generate speech (billed to your Privateer account)",
|
|
194
214
|
generate_music: "Generate music (billed; music prompts have no zero-retention option)",
|
|
215
|
+
// Cheap per call and therefore the one most likely to be called twenty times in a
|
|
216
|
+
// row for a single cut, which is the number the title should let a human weigh.
|
|
217
|
+
generate_sfx: "Generate a sound effect (billed ~$0.02; effect models are non-ZDR)",
|
|
195
218
|
video_compose: "Compose video/audio locally",
|
|
196
219
|
media_capabilities: "Read media capabilities",
|
|
197
220
|
};
|
|
@@ -225,6 +248,28 @@ export function classifyToolCall(
|
|
|
225
248
|
};
|
|
226
249
|
}
|
|
227
250
|
|
|
251
|
+
// Reading a routine back (src/tools/routineResult.ts): its stored instruction and
|
|
252
|
+
// its latest result. A READ, and it must be classified as one — the unknown-tool
|
|
253
|
+
// branch at the bottom would call it bash-kind, which prompts with a JSON blob and
|
|
254
|
+
// denies outright in plan/readonly, the very posture where "what did last night's
|
|
255
|
+
// run find?" is the most reasonable question there is.
|
|
256
|
+
//
|
|
257
|
+
// It still asks in default mode rather than returning null: the files live in
|
|
258
|
+
// ~/.privateer (outside any cwd) and can hold whatever the run collected, so
|
|
259
|
+
// pulling one into a session is the user's call, exactly as reading the file by
|
|
260
|
+
// hand would be. It takes a NAME, not a path, so there is no target to resolve —
|
|
261
|
+
// `outside` is therefore left unset (setting it would force a prompt even under
|
|
262
|
+
// acceptEdits for a read the user just asked for).
|
|
263
|
+
if (name === "read_routine_result") {
|
|
264
|
+
const label = str(obj.name);
|
|
265
|
+
return {
|
|
266
|
+
tool: toolName,
|
|
267
|
+
kind: "read",
|
|
268
|
+
title: "Read a routine's saved result",
|
|
269
|
+
detail: label ? `routine "${label}" (instruction + latest result)` : "list saved routines",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
228
273
|
// Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
|
|
229
274
|
//
|
|
230
275
|
// Left to the unknown-tool branch at the bottom these classify as bash-kind, which
|
|
@@ -243,9 +288,22 @@ export function classifyToolCall(
|
|
|
243
288
|
...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
|
|
244
289
|
str(obj.input),
|
|
245
290
|
str(obj.audio),
|
|
246
|
-
|
|
291
|
+
// `images` is a list of paths for generate_video's reference stills and a list of
|
|
292
|
+
// OBJECTS for video_compose overlay_image's layers — both shapes have to resolve, or
|
|
293
|
+
// the nested one is a path the gate never judges.
|
|
294
|
+
...(Array.isArray(obj.images) ? (obj.images as unknown[]).map(nestedPath) : []),
|
|
247
295
|
str(obj.firstFrame),
|
|
248
296
|
str(obj.lastFrame),
|
|
297
|
+
// video_compose mix_audio carries its paths INSIDE objects — one per placed
|
|
298
|
+
// track — and a nested path is one the gate would never judge: `tracks:
|
|
299
|
+
// [{path: "~/.ssh/id_rsa"}]` would read a key outside scope with `outside`
|
|
300
|
+
// left false. Bare strings are accepted here too because the tool accepts
|
|
301
|
+
// them, and the classifier must see whatever the tool will open.
|
|
302
|
+
...(Array.isArray(obj.tracks) ? (obj.tracks as unknown[]).map(nestedPath) : []),
|
|
303
|
+
// overlay_text renders a font file into the frame, and burn_subtitles reads a whole
|
|
304
|
+
// subtitle file into it, so an out-of-scope or protected one is a read like any other.
|
|
305
|
+
str(obj.fontFile),
|
|
306
|
+
str(obj.subtitles),
|
|
249
307
|
].filter(Boolean);
|
|
250
308
|
// Resolve each input once, then flag the two ways an input is sensitive: it leaves
|
|
251
309
|
// the working directory, or it is a guarded file (.env, keys, credentials, …). The
|
|
@@ -297,7 +355,7 @@ export function classifyToolCall(
|
|
|
297
355
|
detail: `${outputOutside ? absOut : outPath}${inputNote}`,
|
|
298
356
|
protected: isProtectedPath(absOut) || protectedInputs.length > 0,
|
|
299
357
|
outside,
|
|
300
|
-
alwaysAsk:
|
|
358
|
+
alwaysAsk: BILLED_MEDIA_TOOLS.has(name),
|
|
301
359
|
path: absOut,
|
|
302
360
|
};
|
|
303
361
|
}
|
|
@@ -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
|
+
}
|