privateer-agent 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/privateer-launch.mjs +3 -1
- package/package.json +2 -1
- package/src/cli/chat.ts +9 -7
- package/src/permissions/classify.ts +9 -0
- package/src/providers/account.ts +53 -15
- package/src/providers/phala/aci-verifier/VENDORED.md +16 -0
- package/src/providers/phala/reportBinding.ts +193 -0
- package/src/providers/phalaSeal.ts +7 -3
- package/src/providers/sealedShim.ts +35 -6
- package/src/remote/extensionsControl.ts +2 -0
- package/src/remote/relayClient.ts +8 -7
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
|
|
|
73
73
|
| `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
|
|
74
74
|
| `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
|
|
75
75
|
| `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
|
|
76
|
+
| `rpiv-ask-user-question` | `ask_user_question` — a structured questionnaire the agent puts to you instead of guessing |
|
|
76
77
|
| `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
|
|
77
78
|
|
|
78
79
|
They're ordinary Pi extensions — inspect them, replace them, or build your own alongside.
|
|
@@ -509,6 +510,9 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
509
510
|
[Workflows](#workflows).
|
|
510
511
|
- **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
|
|
511
512
|
(self-hosted SearXNG for fully private search).
|
|
513
|
+
- **Ask user question** (`rpiv-ask-user-question`) — when a request is underspecified the agent
|
|
514
|
+
raises a structured questionnaire (typed options, multi-select, markdown previews, or type your
|
|
515
|
+
own answer) instead of guessing. Ungated by design — it only asks you something.
|
|
512
516
|
|
|
513
517
|
## Command reference
|
|
514
518
|
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -162,7 +162,8 @@ else {
|
|
|
162
162
|
"privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
|
|
163
163
|
"privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
|
|
164
164
|
"privateer-connect",
|
|
165
|
-
"pi-privacy", "pi-web-access", "rpiv-web-tools", "
|
|
165
|
+
"pi-privacy", "pi-web-access", "rpiv-web-tools", "rpiv-ask-user-question",
|
|
166
|
+
"pi-mcp-adapter", "pi-hypa", "pi-subagents",
|
|
166
167
|
];
|
|
167
168
|
for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
168
169
|
|
|
@@ -189,6 +190,7 @@ else {
|
|
|
189
190
|
shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
|
|
190
191
|
shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
|
|
191
192
|
shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
|
|
193
|
+
shim("rpiv-ask-user-question", dep("@juicesharp/rpiv-ask-user-question", "index.ts")); // ask_user_question
|
|
192
194
|
shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
|
|
193
195
|
shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
|
|
194
196
|
shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"@earendil-works/pi-coding-agent": "0.80.3",
|
|
64
64
|
"@earendil-works/pi-tui": "^0.80.3",
|
|
65
65
|
"@hypabolic/pi-hypa": "^0.1.6",
|
|
66
|
+
"@juicesharp/rpiv-ask-user-question": "^2.2.0",
|
|
66
67
|
"@juicesharp/rpiv-web-tools": "^1.20.0",
|
|
67
68
|
"@noble/ciphers": "^2.1.1",
|
|
68
69
|
"@noble/curves": "^1.9.7",
|
package/src/cli/chat.ts
CHANGED
|
@@ -157,14 +157,16 @@ async function main() {
|
|
|
157
157
|
console.log(`\n${DIM}⟿ [app] ${text}${RESET}`);
|
|
158
158
|
void (async () => { if (!(await runCommand(text, true))) await runTurn(text, true, false); })();
|
|
159
159
|
},
|
|
160
|
-
// On (re)attach, resync the transcript, push live context (model +
|
|
161
|
-
// the app's banner shows what this terminal runs, AND advertise the
|
|
162
|
-
// commands so the composer can autocomplete them (incl. extension
|
|
163
|
-
// model catalog isn't pushed — /model relays it on demand as a
|
|
164
|
-
//
|
|
160
|
+
// On (re)attach, resync the transcript, push live context (model + cwd +
|
|
161
|
+
// version) so the app's banner shows what this terminal runs, AND advertise the
|
|
162
|
+
// available commands so the composer can autocomplete them (incl. extension
|
|
163
|
+
// commands). The model catalog isn't pushed — /model relays it on demand as a
|
|
164
|
+
// selection prompt. `cwd` is home-collapsed on the way out (see sendContext), so
|
|
165
|
+
// the driver learns which folder they're driving without the OS username
|
|
166
|
+
// crossing the relay.
|
|
165
167
|
onControllerAttached: () => {
|
|
166
168
|
relay?.sendSnapshot([]);
|
|
167
|
-
relay?.sendContext({ model: currentSpec, version: agentVersion() });
|
|
169
|
+
relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() });
|
|
168
170
|
relay?.sendCommands(availableCommands());
|
|
169
171
|
},
|
|
170
172
|
onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
|
|
@@ -602,7 +604,7 @@ async function main() {
|
|
|
602
604
|
currentSpec = sp;
|
|
603
605
|
const m = `model → ${sp}`;
|
|
604
606
|
console.log(`${DIM}${m}${RESET}`);
|
|
605
|
-
relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows the switch
|
|
607
|
+
relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() }); // banner follows the switch
|
|
606
608
|
if (remote) relay?.sendNotice(m);
|
|
607
609
|
} catch (e) {
|
|
608
610
|
const m = `Couldn't switch model: ${(e as Error).message}`;
|
|
@@ -141,8 +141,17 @@ function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequ
|
|
|
141
141
|
// machine: no gate regardless of arguments. Tunable — the conservative default for
|
|
142
142
|
// anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
|
|
143
143
|
// builtin tool catalog as it's enumerated in Phase 5.
|
|
144
|
+
// `ask_user_question` (rpiv-ask-user-question, shimmed by the launcher) is here on
|
|
145
|
+
// purpose: it is a QUESTION PUT TO THE USER — it renders a dialog and returns what the
|
|
146
|
+
// human picked. It touches nothing, sends nothing, and the human is already in the loop
|
|
147
|
+
// by construction. Gating it would fall through to the unknown-tool branch below, which
|
|
148
|
+
// classifies as bash-kind: a pointless "Run ask_user_question" prompt in default mode,
|
|
149
|
+
// and an outright DENY in plan/readonly — the very posture where a model most needs to
|
|
150
|
+
// ask instead of guess. Headless surfaces need no guard either: the tool self-checks
|
|
151
|
+
// ctx.hasUI and returns an error result when there's no one to ask.
|
|
144
152
|
const NON_GATED = new Set([
|
|
145
153
|
"todo", "todowrite", "todo_write", "todoread", "think", "plan_note",
|
|
154
|
+
"ask_user_question",
|
|
146
155
|
]);
|
|
147
156
|
|
|
148
157
|
// Read-ish builtins: gated ONLY when the target resolves outside scope.
|
package/src/providers/account.ts
CHANGED
|
@@ -114,10 +114,37 @@ export function loadCachedCatalogIds(): string[] {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// Whether the account channel can actually SERVE a catalog model right now.
|
|
118
|
+
//
|
|
119
|
+
// `phala/*` is sealed-only: it runs through the sealed blind relay and nowhere else.
|
|
120
|
+
// The server's cleartext `/api/agent/v1` has no Phala route and rejects the id
|
|
121
|
+
// outright (verified live 2026-07-31: 400 "phala/… is not a valid model ID"), because
|
|
122
|
+
// Phala models are the Sealed tier by design — the server is not meant to be able to
|
|
123
|
+
// read them. But `/api/models` advertises them to every client regardless of whether
|
|
124
|
+
// that client can reach the sealed path, so they were pickable and then failed on the
|
|
125
|
+
// first prompt. Offering a model we know cannot answer is worse than a shorter list.
|
|
126
|
+
//
|
|
127
|
+
// The condition is the SHIM, not the flag. Sealed mode being enabled only means we
|
|
128
|
+
// intend to seal; `phala/*` is unservable until the loopback shim is actually
|
|
129
|
+
// listening, because that is what its per-model baseUrl points at (see modelEntry).
|
|
130
|
+
// With the flag now defaulting on, "enabled but the shim failed to bind" is a state a
|
|
131
|
+
// user can really land in, and it must not re-offer models that would 400.
|
|
132
|
+
//
|
|
133
|
+
// `tinfoil/*` is deliberately NOT filtered: the cleartext path serves it fine (sealed
|
|
134
|
+
// mode only upgrades the badge from unconfirmed to verified), so it stays either way.
|
|
135
|
+
export function isServableAccountModel(id: string): boolean {
|
|
136
|
+
if (!id.startsWith("phala/")) return true;
|
|
137
|
+
return sealedEnabled() && sealedShimBase() !== null;
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
// The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
|
|
118
141
|
// default has to be index 0 both because it must always resolve and because Pi clones the
|
|
119
142
|
// provider's first/default model when it synthesizes a custom model id
|
|
120
143
|
// (model-resolver.js buildFallbackModel).
|
|
144
|
+
//
|
|
145
|
+
// Returns the server's list as cached, unfiltered — accountProviderConfig decides what
|
|
146
|
+
// is servable at each registration, so a model dropped now (shim not up yet) can be
|
|
147
|
+
// re-offered by a later re-registration without the cache being rewritten.
|
|
121
148
|
export function seedCatalogIds(): string[] {
|
|
122
149
|
const ids = [...DEFAULT_MODELS];
|
|
123
150
|
const seen = new Set(ids);
|
|
@@ -202,7 +229,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
202
229
|
.map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
|
|
203
230
|
.filter((x): x is AccountModelInfo => !!x);
|
|
204
231
|
// Cache only a real LIVE listing — never the fallback, which would freeze the six
|
|
205
|
-
// seed ids on disk and read back as though it were the catalog.
|
|
232
|
+
// seed ids on disk and read back as though it were the catalog. Both the cache and
|
|
233
|
+
// the returned list are the server's UNFILTERED offer; servability is decided at
|
|
234
|
+
// registration (accountProviderConfig), which re-evaluates it every time.
|
|
206
235
|
if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
|
|
207
236
|
infos = parsed.length ? parsed : fallback();
|
|
208
237
|
}
|
|
@@ -402,14 +431,15 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
402
431
|
const att = await attestSealed(sealedProvider);
|
|
403
432
|
return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
|
|
404
433
|
}
|
|
405
|
-
// Honest labelling for the non-NEAR enclaves
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// client-side over the
|
|
434
|
+
// Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
|
|
435
|
+
// explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
|
|
436
|
+
// and Phala publish real attestations, but the server proxies the inference in
|
|
437
|
+
// cleartext, so from here we cannot bind a quote to the connection actually carrying
|
|
438
|
+
// our tokens — only the account's word that it did. That's `tee-unverified` (yellow
|
|
439
|
+
// "confidential compute, unconfirmed"), never the green tee-verified we reserve for a
|
|
440
|
+
// quote we checked ourselves. Re-enable sealed mode for the verified shield, or set
|
|
441
|
+
// TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests client-side over the
|
|
442
|
+
// TLS binding).
|
|
413
443
|
if (!modelId.startsWith("near/")) {
|
|
414
444
|
return { tier: "tee-unverified" };
|
|
415
445
|
}
|
|
@@ -429,11 +459,13 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
429
459
|
}
|
|
430
460
|
}
|
|
431
461
|
|
|
432
|
-
// A model entry, with a per-model baseUrl override once the
|
|
433
|
-
// `tinfoil/*` then route through the loopback shim (which seals to the
|
|
434
|
-
// instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
|
|
435
|
-
// baseUrl. Until the shim is up (or
|
|
436
|
-
// the cleartext path
|
|
462
|
+
// A model entry, with a per-model baseUrl override once the sealed shim is listening:
|
|
463
|
+
// `tinfoil/*` and `phala/*` then route through the loopback shim (which seals to the
|
|
464
|
+
// blind relay) instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
|
|
465
|
+
// provider baseUrl. Until the shim is up (or with sealed mode disabled) `tinfoil/*` falls
|
|
466
|
+
// back to the cleartext path and the badge stays honestly `tee-unverified` (see
|
|
467
|
+
// accountPosture); `phala/*` has no cleartext path at all and is not registered in that
|
|
468
|
+
// state (see isServableAccountModel).
|
|
437
469
|
function modelEntry(id: string) {
|
|
438
470
|
const base = seedModel(id);
|
|
439
471
|
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
@@ -448,7 +480,13 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
|
448
480
|
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
449
481
|
api: "openai-completions",
|
|
450
482
|
oauth: privateerOAuthProvider,
|
|
451
|
-
|
|
483
|
+
// Filter HERE rather than at the catalog, so callers keep passing the server's
|
|
484
|
+
// full list and every registration re-evaluates servability against the CURRENT
|
|
485
|
+
// shim state. That is what lets the post-shim re-registration in makeAccountProvider
|
|
486
|
+
// put `phala/*` back: had the ids been filtered upstream, the sealed-only models
|
|
487
|
+
// would have been dropped from `lastIds` before the shim ever finished starting and
|
|
488
|
+
// nothing would have brought them back.
|
|
489
|
+
models: ids.filter(isServableAccountModel).map(modelEntry),
|
|
452
490
|
};
|
|
453
491
|
}
|
|
454
492
|
|
|
@@ -21,3 +21,19 @@ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto
|
|
|
21
21
|
(Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
|
|
22
22
|
which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
|
|
23
23
|
re-apply only the `.js`-extension strip.
|
|
24
|
+
|
|
25
|
+
## What lives OUTSIDE this directory (and why)
|
|
26
|
+
`../reportBinding.ts` — `verifyAciReportBinding`, the algorithm dispatch for §10.1
|
|
27
|
+
checks 2–6. Callers use it instead of importing `verifyReportBinding` from here.
|
|
28
|
+
|
|
29
|
+
Upstream's verifier is Web-Crypto-only, so it throws `UnsupportedAlgorithmError` on
|
|
30
|
+
an `ecdsa-secp256k1` keyset endorsement — which §4.3 explicitly permits alongside
|
|
31
|
+
ed25519, and which the deployed `inference.phala.com` gateway actually uses. Rather
|
|
32
|
+
than patch this tree (and re-patch it on every re-pull), the dispatch sits outside:
|
|
33
|
+
ed25519 delegates here verbatim, secp256k1 takes a parallel path over `@noble/curves`,
|
|
34
|
+
and any other algorithm still throws. Nothing here changed, so the re-pull recipe
|
|
35
|
+
above stays exactly the `.js`-extension strip.
|
|
36
|
+
|
|
37
|
+
If upstream ever adds secp256k1 (or a check 7) to `report.ts`, collapse
|
|
38
|
+
`reportBinding.ts` back to a straight re-export — `tests/phalaReportBinding.test.ts`
|
|
39
|
+
pins the behaviour either way.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Algorithm dispatch for the ACI report-binding checks (§10.1 checks 2–6).
|
|
2
|
+
//
|
|
3
|
+
// The ACI spec (§4.3) allows the keyset endorsement to be signed with EITHER
|
|
4
|
+
// `ed25519` OR `ecdsa-secp256k1` — the former because "every primitive in it is
|
|
5
|
+
// available in the Web Crypto API", the latter for "clients in the EVM/dstack
|
|
6
|
+
// ecosystem". Upstream's reference TS verifier implements only the Web Crypto half:
|
|
7
|
+
// `verifySignature` throws `UnsupportedAlgorithmError` on secp256k1
|
|
8
|
+
// (aci-verifier/crypto.ts), and `verifyReportBinding` propagates that.
|
|
9
|
+
//
|
|
10
|
+
// The deployed gateway (inference.phala.com) signs with `ecdsa-secp256k1`, so the
|
|
11
|
+
// vendored verifier can never attest it — a limit of upstream's CLIENT, not of the
|
|
12
|
+
// spec or the gateway. Verified live 2026-07-31: attestation fetched, endorsement
|
|
13
|
+
// rejected with UnsupportedAlgorithmError.
|
|
14
|
+
//
|
|
15
|
+
// This module owns the dispatch so `aci-verifier/` stays byte-for-byte upstream
|
|
16
|
+
// (see its VENDORED.md — only the `.js`-extension strip diverges, and re-pulls stay
|
|
17
|
+
// mechanical):
|
|
18
|
+
// ed25519 → delegate to the vendored verifyReportBinding, verbatim
|
|
19
|
+
// ecdsa-secp256k1 → the same checks 2–6, with check 5 done over @noble/curves
|
|
20
|
+
// anything else → still throws (never a silent pass)
|
|
21
|
+
//
|
|
22
|
+
// The secp256k1 path deliberately mirrors report.ts check-for-check, in the same
|
|
23
|
+
// order and with the same check names, so a caller cannot tell which path ran.
|
|
24
|
+
|
|
25
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
26
|
+
import {
|
|
27
|
+
verifyReportBinding,
|
|
28
|
+
computeWorkloadId,
|
|
29
|
+
computeKeysetDigest,
|
|
30
|
+
computeReportData,
|
|
31
|
+
keysetEndorsementPayload,
|
|
32
|
+
sha256,
|
|
33
|
+
fromHex,
|
|
34
|
+
UnsupportedAlgorithmError,
|
|
35
|
+
type AttestationReport,
|
|
36
|
+
type Check,
|
|
37
|
+
type ReportVerification,
|
|
38
|
+
type ReportBindingOptions,
|
|
39
|
+
} from "./aci-verifier/index.ts";
|
|
40
|
+
|
|
41
|
+
const ED25519 = "ed25519";
|
|
42
|
+
const SECP256K1 = "ecdsa-secp256k1";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Verify a report's cryptographic bindings for `nonce` (§10.1 checks 2–6),
|
|
46
|
+
* dispatching on the algorithm the attested identity key declares. Drop-in
|
|
47
|
+
* replacement for the vendored `verifyReportBinding`: same arguments, same result
|
|
48
|
+
* shape, same "a failed check is `ok: false`, never thrown" contract.
|
|
49
|
+
*
|
|
50
|
+
* Like upstream, this is the crypto-binding half only — compose it with a hardware
|
|
51
|
+
* quote verifier (phalaSeal.ts `verifyHardwareQuote`) for Level 2.
|
|
52
|
+
*/
|
|
53
|
+
export async function verifyAciReportBinding(
|
|
54
|
+
report: AttestationReport,
|
|
55
|
+
nonce: string | null | undefined,
|
|
56
|
+
options: ReportBindingOptions = {},
|
|
57
|
+
): Promise<ReportVerification> {
|
|
58
|
+
const algo = report.attestation.workload_keyset.workload_identity.public_key.algo;
|
|
59
|
+
if (algo === ED25519) return verifyReportBinding(report, nonce, options);
|
|
60
|
+
if (algo !== SECP256K1) {
|
|
61
|
+
// Same fail-closed posture as upstream: an algorithm we cannot check is a
|
|
62
|
+
// refusal, not a pass.
|
|
63
|
+
throw new UnsupportedAlgorithmError(algo, "keyset endorsement (§4.3)");
|
|
64
|
+
}
|
|
65
|
+
return verifySecp256k1ReportBinding(report, nonce, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function verifySecp256k1ReportBinding(
|
|
69
|
+
report: AttestationReport,
|
|
70
|
+
nonce: string | null | undefined,
|
|
71
|
+
options: ReportBindingOptions,
|
|
72
|
+
): Promise<ReportVerification> {
|
|
73
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
74
|
+
const checks: Check[] = [];
|
|
75
|
+
|
|
76
|
+
const keyset = report.attestation.workload_keyset;
|
|
77
|
+
const identityKey = keyset.workload_identity.public_key;
|
|
78
|
+
|
|
79
|
+
// Check 2: workload_id == digest of the identity public key in the report's keyset.
|
|
80
|
+
const workloadId = await computeWorkloadId(identityKey);
|
|
81
|
+
pushEqual(checks, "workload_id", report.workload_id, workloadId);
|
|
82
|
+
|
|
83
|
+
// Check 3: workload_keyset_digest == digest of the report's keyset.
|
|
84
|
+
const workloadKeysetDigest = await computeKeysetDigest(keyset);
|
|
85
|
+
pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, workloadKeysetDigest);
|
|
86
|
+
|
|
87
|
+
// Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
|
|
88
|
+
// The hardware-evidence-binds-report_data half is verifyHardwareQuote's job.
|
|
89
|
+
const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
|
|
90
|
+
pushEqual(checks, "report_data", report.attestation.report_data, expectedReportData);
|
|
91
|
+
|
|
92
|
+
// Check 5: keyset endorsement verifies under the identity key, algo matching.
|
|
93
|
+
const endorsement = report.attestation.keyset_endorsement;
|
|
94
|
+
if (endorsement.algo !== identityKey.algo) {
|
|
95
|
+
checks.push({
|
|
96
|
+
name: "keyset_endorsement",
|
|
97
|
+
ok: false,
|
|
98
|
+
detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
const ok = await verifySecp256k1(
|
|
102
|
+
identityKey.public_key,
|
|
103
|
+
endorsement.value,
|
|
104
|
+
keysetEndorsementPayload(workloadKeysetDigest),
|
|
105
|
+
);
|
|
106
|
+
checks.push({
|
|
107
|
+
name: "keyset_endorsement",
|
|
108
|
+
ok,
|
|
109
|
+
...(ok ? {} : { detail: "endorsement signature failed under identity key" }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check 6: freshness. Nonce binding is check 4; here bound the epoch and, when
|
|
114
|
+
// the profile trusts it, the declared validity window.
|
|
115
|
+
const notAfter = keyset.keyset_epoch.not_after;
|
|
116
|
+
const epochOk = now < notAfter;
|
|
117
|
+
checks.push({
|
|
118
|
+
name: "keyset_epoch.not_after",
|
|
119
|
+
ok: epochOk,
|
|
120
|
+
...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
|
|
121
|
+
});
|
|
122
|
+
if (options.trustPlatformClock) {
|
|
123
|
+
const freshness = report.attestation.freshness;
|
|
124
|
+
const fetchedAt = freshness?.fetched_at;
|
|
125
|
+
const staleAfter = freshness?.stale_after;
|
|
126
|
+
const windowOk =
|
|
127
|
+
typeof fetchedAt === "number" &&
|
|
128
|
+
typeof staleAfter === "number" &&
|
|
129
|
+
fetchedAt <= now &&
|
|
130
|
+
now < staleAfter;
|
|
131
|
+
checks.push({
|
|
132
|
+
name: "freshness_window",
|
|
133
|
+
ok: windowOk,
|
|
134
|
+
...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §4.3 secp256k1 endorsement: a 64-byte `r || s` signature over
|
|
143
|
+
* `sha256(payload bytes)`. Returns false on malformed input rather than throwing —
|
|
144
|
+
* a bad signature is a failed check, not an exception.
|
|
145
|
+
*
|
|
146
|
+
* NOT the §8.5 *receipt* shape, which is a 65-byte recoverable `r || s || v` and
|
|
147
|
+
* where the spec says 64-byte signatures MUST be rejected. Different shapes; easy
|
|
148
|
+
* to conflate if this ever grows a receipt path.
|
|
149
|
+
*/
|
|
150
|
+
async function verifySecp256k1(
|
|
151
|
+
publicKeyHex: string,
|
|
152
|
+
signatureHex: string,
|
|
153
|
+
payload: Uint8Array,
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
const sig = fromHex(signatureHex);
|
|
157
|
+
if (sig.length !== 64) return false; // r||s only; DER / recoverable forms are not §4.3
|
|
158
|
+
const msgHash = await sha256(payload);
|
|
159
|
+
return secp256k1.verify(sig, msgHash, publicKey(publicKeyHex), {
|
|
160
|
+
prehash: false, // we hand it the sha256 digest, per §4.3
|
|
161
|
+
// Accept high-s as well as low-s. ECDSA malleability is meaningless for a
|
|
162
|
+
// signature over a FIXED payload — an attacker who can flip s already has a
|
|
163
|
+
// valid endorsement and still cannot sign a different keyset digest. Leaving
|
|
164
|
+
// the default on would reject ~half of otherwise-valid endorsements from any
|
|
165
|
+
// signer that doesn't normalize, as an intermittent attestation failure.
|
|
166
|
+
lowS: false,
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Identity key bytes. §7.1 pins secp256k1 public keys as 65-byte uncompressed SEC1
|
|
175
|
+
* and requires that "the 64-byte uncompressed form without the `0x04` prefix MUST be
|
|
176
|
+
* accepted and treated as the same key" — so restore the prefix when it's absent.
|
|
177
|
+
* The live gateway sends the 65-byte form; do NOT prefix that one again.
|
|
178
|
+
*/
|
|
179
|
+
function publicKey(hex: string): Uint8Array {
|
|
180
|
+
const raw = fromHex(hex);
|
|
181
|
+
if (raw.length === 64) {
|
|
182
|
+
const sec1 = new Uint8Array(65);
|
|
183
|
+
sec1[0] = 0x04;
|
|
184
|
+
sec1.set(raw, 1);
|
|
185
|
+
return sec1;
|
|
186
|
+
}
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
|
|
191
|
+
const ok = actual === expected;
|
|
192
|
+
checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
|
|
193
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// no polyfills, unlike the RN app.
|
|
14
14
|
//
|
|
15
15
|
// Two-layer attestation, fail-secure:
|
|
16
|
-
// (1)
|
|
16
|
+
// (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
|
|
17
17
|
// report_data == statement(nonce), endorsement sig). Self-attesting alone.
|
|
18
18
|
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
19
|
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import type { Report } from "@phala/dcap-qvl";
|
|
24
24
|
import {
|
|
25
|
-
verifyReportBinding,
|
|
26
25
|
openE2eeChannel,
|
|
27
26
|
toHex,
|
|
28
27
|
fromHex,
|
|
@@ -30,6 +29,11 @@ import {
|
|
|
30
29
|
type ReportVerification,
|
|
31
30
|
type E2eeChannel,
|
|
32
31
|
} from "./phala/aci-verifier/index.ts";
|
|
32
|
+
// Not the vendored verifyReportBinding directly: the deployed gateway signs its
|
|
33
|
+
// keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
|
|
34
|
+
// refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
|
|
35
|
+
// the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
|
|
36
|
+
import { verifyAciReportBinding } from "./phala/reportBinding.ts";
|
|
33
37
|
import { serverBaseUrl } from "../auth/privateer.ts";
|
|
34
38
|
|
|
35
39
|
const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
|
|
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
|
94
98
|
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
95
99
|
const report = (await res.json()) as AttestationReport;
|
|
96
100
|
|
|
97
|
-
const verification = await
|
|
101
|
+
const verification = await verifyAciReportBinding(report, nonce);
|
|
98
102
|
if (!verification.ok) {
|
|
99
103
|
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
100
104
|
throw new Error(`phala attestation binding failed: ${failed}`);
|
|
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
|
|
|
40
40
|
// (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
|
|
41
41
|
export type SealedProvider = "tinfoil" | "phala";
|
|
42
42
|
|
|
43
|
-
// Sealed mode is
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
43
|
+
// Sealed mode is ON by default as of 2026-07-31, when the live checklist in
|
|
44
|
+
// docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
|
|
45
|
+
// both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
|
|
46
|
+
// TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
|
|
47
|
+
// refused rather than silently greened, and the server bills the turn.
|
|
48
|
+
//
|
|
49
|
+
// What it buys: the prompt is sealed to the enclave the client itself attested, so the
|
|
50
|
+
// badge is a quote WE checked rather than the account's word — green instead of
|
|
51
|
+
// "Trusted Execution (unconfirmed)".
|
|
52
|
+
//
|
|
53
|
+
// PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
|
|
54
|
+
// the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
|
|
55
|
+
// sealed-only and simply disappears from the catalog (see isServableAccountModel).
|
|
47
56
|
export function sealedEnabled(): boolean {
|
|
48
57
|
const v = process.env.PRIVATEER_SEALED;
|
|
49
|
-
return v === "
|
|
58
|
+
return !(v === "0" || v === "false");
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
// The sealed provider a model id routes through, or null if it isn't a sealed
|
|
@@ -151,7 +160,12 @@ export function buildForward(
|
|
|
151
160
|
// Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
|
|
152
161
|
}
|
|
153
162
|
const headers: Record<string, string> = {
|
|
154
|
-
|
|
163
|
+
// MUST carry the charset. EHBP seals the body but headers travel in cleartext
|
|
164
|
+
// (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
|
|
165
|
+
// Content-Type is what the enclave's router actually validates — and it rejects a
|
|
166
|
+
// bare `application/json` with "Unsupported Media Type: Only 'application/json' is
|
|
167
|
+
// allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
|
|
168
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
155
169
|
"X-Sealed-Model": sealedModel,
|
|
156
170
|
};
|
|
157
171
|
if (authHeader) headers.Authorization = authHeader;
|
|
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
|
165
179
|
|
|
166
180
|
let shimBase: string | null = null;
|
|
167
181
|
let shimStarting: Promise<string> | null = null;
|
|
182
|
+
let shimServer: http.Server | null = null;
|
|
168
183
|
|
|
169
184
|
// The shim's base URL once listening, else null. account.ts reads this to decide
|
|
170
185
|
// whether a sealed model can point its baseUrl at the shim yet.
|
|
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
|
|
|
179
194
|
return shimStarting;
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
// Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
|
|
198
|
+
// The listener is `unref`'d and never blocks exit, so this is not needed for
|
|
199
|
+
// shutdown — it exists so a caller that stops sealing (or a test) can drop the
|
|
200
|
+
// socket deterministically rather than leaving a port open for the process lifetime.
|
|
201
|
+
export function stopSealedShim(): Promise<void> {
|
|
202
|
+
const server = shimServer;
|
|
203
|
+
shimServer = null;
|
|
204
|
+
shimBase = null;
|
|
205
|
+
shimStarting = null;
|
|
206
|
+
if (!server) return Promise.resolve();
|
|
207
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
208
|
+
}
|
|
209
|
+
|
|
182
210
|
function startShim(): Promise<string> {
|
|
183
211
|
return new Promise((resolve, reject) => {
|
|
184
212
|
const server = http.createServer((req, res) => {
|
|
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
|
|
|
192
220
|
server.listen(0, "127.0.0.1", () => {
|
|
193
221
|
const addr = server.address();
|
|
194
222
|
if (addr && typeof addr === "object") {
|
|
223
|
+
shimServer = server;
|
|
195
224
|
shimBase = `http://127.0.0.1:${addr.port}`;
|
|
196
225
|
resolve(shimBase);
|
|
197
226
|
} else {
|
|
@@ -996,13 +996,14 @@ export class RelayClient {
|
|
|
996
996
|
// stance (the server/controller learns as little as possible about the machine).
|
|
997
997
|
// Empty/absent fields are omitted so the app renders less rather than blank.
|
|
998
998
|
//
|
|
999
|
-
// `cwd` is the one scoped exception,
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
// the
|
|
999
|
+
// `cwd` is the one scoped exception, sent by BOTH session kinds: it is where
|
|
1000
|
+
// everything the agent reads, writes and `@`-mentions lives, so a driver who can't
|
|
1001
|
+
// see it is guessing at the blast radius of every prompt they send. (It used to be
|
|
1002
|
+
// harbor-spawned sessions only — on the theory that a human sits in an interactive
|
|
1003
|
+
// terminal and already knows the folder. They don't when they're driving it from a
|
|
1004
|
+
// phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
|
|
1005
|
+
// on the way out, so the banner reads like the CLI's own and the OS username still
|
|
1006
|
+
// never crosses the relay.
|
|
1006
1007
|
sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
|
|
1007
1008
|
const frame: Record<string, unknown> = { type: "context" };
|
|
1008
1009
|
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|