ccp-core 0.5.0 → 0.5.1
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 +47 -0
- package/dist/grants.d.ts +6 -0
- package/dist/grants.js +3 -3
- package/dist/identity.d.ts +150 -0
- package/dist/identity.js +495 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime.d.ts +155 -4
- package/dist/runtime.js +436 -17
- package/package.json +1 -1
package/dist/runtime.js
CHANGED
|
@@ -4,7 +4,10 @@ import { signingBytes } from "./signing.js";
|
|
|
4
4
|
import { keyIdFromPublic, verify } from "./crypto.js";
|
|
5
5
|
import { decide } from "./decision.js";
|
|
6
6
|
import { validate as validateSchema } from "./schema.js";
|
|
7
|
-
import { AUTHORIZED, CONFIRMATION_REQUIRED } from "./errors.js";
|
|
7
|
+
import { AUTHORIZED, CONFIRMATION_REQUIRED, CcpError } from "./errors.js";
|
|
8
|
+
import { createGrant } from "./grants.js";
|
|
9
|
+
import { verifyManifest } from "./consumer.js";
|
|
10
|
+
import { IdentityError, admit, admittedGrant, verifyIdentityDocument, } from "./identity.js";
|
|
8
11
|
import { MemoryAuditLog, MemoryGrantStore, MemoryLimitStore, MemoryManifestStore, MemoryPendingStore, MemoryReplayStore, } from "./stores.js";
|
|
9
12
|
import { utcnow } from "./time.js";
|
|
10
13
|
export { utcnow };
|
|
@@ -20,21 +23,169 @@ export class SchemaRegistry {
|
|
|
20
23
|
return id === undefined ? undefined : this._schemas.get(id);
|
|
21
24
|
}
|
|
22
25
|
}
|
|
26
|
+
/** The only hosts that may be registered without an explicit profile. */
|
|
27
|
+
export const CORE_HOSTS = new Set([
|
|
28
|
+
"store.read",
|
|
29
|
+
"files.read",
|
|
30
|
+
"files.search",
|
|
31
|
+
"notify",
|
|
32
|
+
"confirm",
|
|
33
|
+
"audit.append",
|
|
34
|
+
]);
|
|
35
|
+
const HOST_CLASSES = new Set([
|
|
36
|
+
"read",
|
|
37
|
+
"write",
|
|
38
|
+
"disclose",
|
|
39
|
+
"control",
|
|
40
|
+
"compute",
|
|
41
|
+
]);
|
|
42
|
+
const HOST_RISKS = new Set([
|
|
43
|
+
"read",
|
|
44
|
+
"write",
|
|
45
|
+
"disclose",
|
|
46
|
+
"irreversible",
|
|
47
|
+
]);
|
|
48
|
+
const HOST_PLACEMENTS = new Set([
|
|
49
|
+
"local",
|
|
50
|
+
"remote",
|
|
51
|
+
"delegated",
|
|
52
|
+
]);
|
|
53
|
+
const IDEMPOTENCY = new Set([
|
|
54
|
+
"pure",
|
|
55
|
+
"caller_key",
|
|
56
|
+
"atomic",
|
|
57
|
+
"none",
|
|
58
|
+
]);
|
|
59
|
+
const CLASS_DEFAULTS = {
|
|
60
|
+
read: { risk: "read", minConfirmation: "none", idempotency: "pure" },
|
|
61
|
+
disclose: { risk: "disclose", minConfirmation: "per_action", idempotency: "pure" },
|
|
62
|
+
write: { risk: "write", minConfirmation: "per_action", idempotency: "caller_key" },
|
|
63
|
+
control: { risk: "write", minConfirmation: "per_action", idempotency: "none" },
|
|
64
|
+
compute: { risk: "read", minConfirmation: "per_action", idempotency: "pure" },
|
|
65
|
+
};
|
|
66
|
+
export class HostProfileError extends Error {
|
|
67
|
+
code = "invalid_request";
|
|
68
|
+
constructor(message) {
|
|
69
|
+
super(`invalid_request: ${message}`);
|
|
70
|
+
this.name = "HostProfileError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate and normalize a host profile (§26.4).
|
|
75
|
+
*
|
|
76
|
+
* Extension hosts (anything outside the core six) must declare a class; the
|
|
77
|
+
* rest of the contract follows from it unless explicitly overridden. A
|
|
78
|
+
* `write`, `control`, or `compute` host can never suppress confirmation, and
|
|
79
|
+
* an `irreversible` risk cannot either.
|
|
80
|
+
*/
|
|
81
|
+
export function validateHostProfile(name, profile = {}) {
|
|
82
|
+
if (typeof name !== "string" || name === "") {
|
|
83
|
+
throw new HostProfileError("host name is required");
|
|
84
|
+
}
|
|
85
|
+
const declared = profile.class;
|
|
86
|
+
if (declared === undefined && !CORE_HOSTS.has(name)) {
|
|
87
|
+
throw new HostProfileError(`extension host ${name} requires a class`);
|
|
88
|
+
}
|
|
89
|
+
if (declared !== undefined && !HOST_CLASSES.has(declared)) {
|
|
90
|
+
throw new HostProfileError(`unknown host class: ${declared}`);
|
|
91
|
+
}
|
|
92
|
+
const hostClass = (declared ?? "read");
|
|
93
|
+
const defaults = CLASS_DEFAULTS[hostClass];
|
|
94
|
+
const risk = (profile.risk ?? defaults.risk);
|
|
95
|
+
if (!HOST_RISKS.has(risk)) {
|
|
96
|
+
throw new HostProfileError(`unknown host risk: ${risk}`);
|
|
97
|
+
}
|
|
98
|
+
const minConfirmation = (profile.minConfirmation ??
|
|
99
|
+
defaults.minConfirmation);
|
|
100
|
+
if (minConfirmation !== "none" && minConfirmation !== "per_action") {
|
|
101
|
+
throw new HostProfileError(`unknown confirmation floor: ${minConfirmation}`);
|
|
102
|
+
}
|
|
103
|
+
if (minConfirmation === "none") {
|
|
104
|
+
if (hostClass !== "read" && hostClass !== "disclose") {
|
|
105
|
+
throw new HostProfileError(`a ${hostClass} host cannot suppress confirmation`);
|
|
106
|
+
}
|
|
107
|
+
if (risk === "irreversible") {
|
|
108
|
+
throw new HostProfileError("an irreversible host cannot suppress confirmation");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const idempotency = (profile.idempotency ?? defaults.idempotency);
|
|
112
|
+
if (!IDEMPOTENCY.has(idempotency)) {
|
|
113
|
+
throw new HostProfileError(`unknown idempotency: ${idempotency}`);
|
|
114
|
+
}
|
|
115
|
+
const network = profile.network === true;
|
|
116
|
+
if (profile.network !== undefined && typeof profile.network !== "boolean") {
|
|
117
|
+
throw new HostProfileError("network must be a boolean");
|
|
118
|
+
}
|
|
119
|
+
const placement = profile.placement ?? ["local"];
|
|
120
|
+
if (!Array.isArray(placement) || placement.length === 0) {
|
|
121
|
+
throw new HostProfileError("placement must be a non-empty array");
|
|
122
|
+
}
|
|
123
|
+
const seenPlacement = new Set();
|
|
124
|
+
for (const entry of placement) {
|
|
125
|
+
if (typeof entry !== "string" || !HOST_PLACEMENTS.has(entry)) {
|
|
126
|
+
throw new HostProfileError(`unknown placement: ${entry}`);
|
|
127
|
+
}
|
|
128
|
+
if (seenPlacement.has(entry)) {
|
|
129
|
+
throw new HostProfileError(`duplicate placement: ${entry}`);
|
|
130
|
+
}
|
|
131
|
+
seenPlacement.add(entry);
|
|
132
|
+
}
|
|
133
|
+
const fields = profile.fields ?? [];
|
|
134
|
+
if (!Array.isArray(fields)) {
|
|
135
|
+
throw new HostProfileError("fields must be an array");
|
|
136
|
+
}
|
|
137
|
+
const seenFields = new Set();
|
|
138
|
+
for (const field of fields) {
|
|
139
|
+
if (typeof field !== "string" || field === "") {
|
|
140
|
+
throw new HostProfileError("fields must be non-empty strings");
|
|
141
|
+
}
|
|
142
|
+
if (seenFields.has(field)) {
|
|
143
|
+
throw new HostProfileError(`duplicate field: ${field}`);
|
|
144
|
+
}
|
|
145
|
+
seenFields.add(field);
|
|
146
|
+
}
|
|
147
|
+
if (network && fields.length === 0 && hostClass === "disclose") {
|
|
148
|
+
throw new HostProfileError("a network disclose host must declare the fields it sends");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
class: hostClass,
|
|
152
|
+
risk,
|
|
153
|
+
minConfirmation,
|
|
154
|
+
idempotency,
|
|
155
|
+
network,
|
|
156
|
+
placement: placement,
|
|
157
|
+
fields: [...fields],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
23
160
|
export class HostAccess {
|
|
24
161
|
registry;
|
|
162
|
+
policy;
|
|
25
163
|
_granted;
|
|
26
|
-
constructor(registry, granted
|
|
164
|
+
constructor(registry, granted, policy = {
|
|
165
|
+
network: false,
|
|
166
|
+
fields: new Set(),
|
|
167
|
+
}) {
|
|
27
168
|
this.registry = registry;
|
|
169
|
+
this.policy = policy;
|
|
28
170
|
this._granted = new Set(granted);
|
|
29
171
|
}
|
|
30
172
|
call(name, ...args) {
|
|
31
173
|
if (!this._granted.has(name)) {
|
|
32
174
|
throw new Error(`host function not granted: ${name}`);
|
|
33
175
|
}
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
176
|
+
const entry = this.registry.hostMeta(name);
|
|
177
|
+
if (entry === undefined) {
|
|
36
178
|
throw new Error(`host function not installed: ${name}`);
|
|
37
|
-
|
|
179
|
+
}
|
|
180
|
+
if (entry.profile.network && !this.policy.network) {
|
|
181
|
+
throw new Error(`host requires network egress not granted: ${name}`);
|
|
182
|
+
}
|
|
183
|
+
for (const field of entry.profile.fields) {
|
|
184
|
+
if (!this.policy.fields.has(field)) {
|
|
185
|
+
throw new Error(`host may send an undeclared field: ${name}/${field}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return entry.fn(...args);
|
|
38
189
|
}
|
|
39
190
|
has(name) {
|
|
40
191
|
return this._granted.has(name) && this.registry.hasHost(name);
|
|
@@ -43,13 +194,20 @@ export class HostAccess {
|
|
|
43
194
|
export class HostRegistry {
|
|
44
195
|
_hosts = new Map();
|
|
45
196
|
_intents = new Map();
|
|
46
|
-
registerHost(name, fn) {
|
|
47
|
-
this._hosts.set(name,
|
|
197
|
+
registerHost(name, fn, profile = {}) {
|
|
198
|
+
this._hosts.set(name, {
|
|
199
|
+
fn,
|
|
200
|
+
profile: validateHostProfile(name, profile),
|
|
201
|
+
});
|
|
48
202
|
}
|
|
49
203
|
registerIntent(name, fn) {
|
|
50
204
|
this._intents.set(name, fn);
|
|
51
205
|
}
|
|
52
206
|
host(name) {
|
|
207
|
+
return this._hosts.get(name)?.fn;
|
|
208
|
+
}
|
|
209
|
+
/** Metadata used to enforce placement, egress, and confirmation (§12). */
|
|
210
|
+
hostMeta(name) {
|
|
53
211
|
return this._hosts.get(name);
|
|
54
212
|
}
|
|
55
213
|
hasHost(name) {
|
|
@@ -61,10 +219,16 @@ export class HostRegistry {
|
|
|
61
219
|
hasIntent(name) {
|
|
62
220
|
return this._intents.has(name);
|
|
63
221
|
}
|
|
64
|
-
access(granted) {
|
|
65
|
-
return new HostAccess(this, granted);
|
|
222
|
+
access(granted, policy) {
|
|
223
|
+
return new HostAccess(this, granted, policy);
|
|
66
224
|
}
|
|
67
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Reserved suspension reason for a requester key change (§7.4). A grant
|
|
228
|
+
* suspended this way cannot be resumed; the consumer re-introduces the
|
|
229
|
+
* contact and creates a new grant.
|
|
230
|
+
*/
|
|
231
|
+
export const IDENTITY_CHANGED_REASON = "identity_changed";
|
|
68
232
|
export class Runtime {
|
|
69
233
|
grants;
|
|
70
234
|
manifests;
|
|
@@ -123,7 +287,7 @@ export class Runtime {
|
|
|
123
287
|
return this.reject("unauthenticated", signal.request_id, undefined, String(error));
|
|
124
288
|
}
|
|
125
289
|
const signer = keyIdFromPublic(publicKey);
|
|
126
|
-
const grant = await this.
|
|
290
|
+
const grant = await this.resolveGrant(signer, signal.grant_digest, signal.intent);
|
|
127
291
|
let intentSpec = null;
|
|
128
292
|
let paramsSchema = null;
|
|
129
293
|
if (grant) {
|
|
@@ -148,7 +312,7 @@ export class Runtime {
|
|
|
148
312
|
supportedCcp: this.supportedCcp,
|
|
149
313
|
});
|
|
150
314
|
if (outcome !== AUTHORIZED && outcome !== CONFIRMATION_REQUIRED) {
|
|
151
|
-
return this.reject(outcome, signal.request_id, grant);
|
|
315
|
+
return this.reject(outcome, signal.request_id, grant, this.stateDetail(outcome, grant));
|
|
152
316
|
}
|
|
153
317
|
if (!(await this.replay.markIfNew(signal.request_id))) {
|
|
154
318
|
return this.reject("replay_detected", signal.request_id, grant);
|
|
@@ -162,7 +326,13 @@ export class Runtime {
|
|
|
162
326
|
await this.limits.release(grant.id, grant.limits);
|
|
163
327
|
return this.reject("capability_unavailable", signal.request_id, grant, `unavailable: ${unavailable.length > 0 ? unavailable.join(",") : signal.intent}`);
|
|
164
328
|
}
|
|
165
|
-
|
|
329
|
+
const deployment = this.deploymentError(grant, intentSpec, required);
|
|
330
|
+
if (deployment !== undefined) {
|
|
331
|
+
await this.limits.release(grant.id, grant.limits);
|
|
332
|
+
return this.reject("capability_unavailable", signal.request_id, grant, deployment);
|
|
333
|
+
}
|
|
334
|
+
const requiresConfirmation = outcome === CONFIRMATION_REQUIRED || this.hostFloor(intentSpec);
|
|
335
|
+
if (requiresConfirmation) {
|
|
166
336
|
const pendingId = randomId();
|
|
167
337
|
await this.pendingStore.put({
|
|
168
338
|
id: pendingId,
|
|
@@ -204,16 +374,206 @@ export class Runtime {
|
|
|
204
374
|
state: "cancelled",
|
|
205
375
|
};
|
|
206
376
|
}
|
|
207
|
-
|
|
377
|
+
// The grant may have been revoked or paused while the prompt was open
|
|
378
|
+
// (§9.4.3); re-read it rather than trusting the snapshot.
|
|
379
|
+
const current = (await this.grants.get(pending.grant.id)) ?? pending.grant;
|
|
380
|
+
const stateError = this.grantStateError(current, this.clock());
|
|
208
381
|
if (stateError) {
|
|
209
|
-
await this.limits.release(
|
|
210
|
-
return this.reject(stateError, pending.signal.request_id,
|
|
382
|
+
await this.limits.release(current.id, current.limits);
|
|
383
|
+
return this.reject(stateError, pending.signal.request_id, current, this.stateDetail(stateError, current));
|
|
211
384
|
}
|
|
212
|
-
return this.execute(pending.signal,
|
|
385
|
+
return this.execute(pending.signal, current, pending.intentSpec);
|
|
213
386
|
}
|
|
214
387
|
pending(pendingId) {
|
|
215
388
|
return Promise.resolve(this.pendingStore.get(pendingId));
|
|
216
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Pause one grant (§9.4.1). Configuration, limits, and history are kept.
|
|
392
|
+
*/
|
|
393
|
+
async pauseGrant(id, reason) {
|
|
394
|
+
return this.transition(id, "paused", reason);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Suspend one grant (§9.4.2). Used for security events such as a requester
|
|
398
|
+
* key change; resumption requires an explicit `resumeGrant`.
|
|
399
|
+
*/
|
|
400
|
+
async suspendGrant(id, reason) {
|
|
401
|
+
return this.transition(id, "suspended", reason);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Resume a paused or suspended grant (§9.4.3).
|
|
405
|
+
*
|
|
406
|
+
* Re-checks revocation, expiry, and the approved manifest before restoring
|
|
407
|
+
* authority. A revoked or expired grant cannot be resumed.
|
|
408
|
+
*/
|
|
409
|
+
async resumeGrant(id) {
|
|
410
|
+
const grant = await this.grants.get(id);
|
|
411
|
+
if (grant === undefined) {
|
|
412
|
+
throw new CcpError("no_matching_grant", `unknown grant: ${id}`);
|
|
413
|
+
}
|
|
414
|
+
if (grant.state === "revoked") {
|
|
415
|
+
throw new CcpError("grant_revoked", "a revoked grant cannot be resumed");
|
|
416
|
+
}
|
|
417
|
+
if (grant.state === "suspended" &&
|
|
418
|
+
grant.state_reason === IDENTITY_CHANGED_REASON) {
|
|
419
|
+
throw new CcpError("grant_suspended", "identity changed; re-introduce the contact and create a new grant");
|
|
420
|
+
}
|
|
421
|
+
if (grant.state === "expired" || this.clock() >= grant.expires_at) {
|
|
422
|
+
throw new CcpError("grant_expired", "an expired grant cannot be resumed");
|
|
423
|
+
}
|
|
424
|
+
if (this.manifests.get(grant.manifest_digest) === undefined) {
|
|
425
|
+
throw new CcpError("capability_unavailable", "the approved manifest is no longer stored");
|
|
426
|
+
}
|
|
427
|
+
if (grant.state === "active")
|
|
428
|
+
return grant;
|
|
429
|
+
return this.transition(id, "active", undefined);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Revoke one grant by id (§9.4.4). Permanent: the grant is not resumable and
|
|
433
|
+
* a new approval is required.
|
|
434
|
+
*
|
|
435
|
+
* Policy-created grants reuse their deterministic id, so revoking here also
|
|
436
|
+
* stops an admission rule from re-creating the same access (§25.8.3).
|
|
437
|
+
*/
|
|
438
|
+
async revokeGrant(id, reason) {
|
|
439
|
+
return this.transition(id, "revoked", reason);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Suspend every active grant bound to a contact (§7.4, §9.4.2).
|
|
443
|
+
* Returns the number of grants affected.
|
|
444
|
+
*/
|
|
445
|
+
async suspendContact(contactId, reason) {
|
|
446
|
+
const affected = (await this.grants.all()).filter((grant) => grant.contact_id === contactId && grant.state !== "revoked");
|
|
447
|
+
for (const grant of affected) {
|
|
448
|
+
await this.setGrantState(grant, "suspended", reason ?? "contact suspended");
|
|
449
|
+
}
|
|
450
|
+
return affected.length;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Verify an identity document for a known contact (§7.4, §25.8.4).
|
|
454
|
+
*
|
|
455
|
+
* On `identity_changed` — a valid signature from a different key — the
|
|
456
|
+
* contact's grants are suspended before the error is rethrown. A bad
|
|
457
|
+
* signature never suspends anything, so an attacker cannot force a
|
|
458
|
+
* suspension by sending garbage.
|
|
459
|
+
*/
|
|
460
|
+
async verifyContactIdentity(document, options) {
|
|
461
|
+
try {
|
|
462
|
+
return verifyIdentityDocument(document, options);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (error instanceof IdentityError && error.code === "identity_changed") {
|
|
466
|
+
const suspended = await this.suspendContact(options.contactId, IDENTITY_CHANGED_REASON);
|
|
467
|
+
await this.audit.append({
|
|
468
|
+
event: "identity_changed",
|
|
469
|
+
contact_id: options.contactId,
|
|
470
|
+
detail: `${suspended} grant(s) suspended`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Admit a request under a reviewed policy (§25.8.3).
|
|
478
|
+
*
|
|
479
|
+
* `allow` creates or reuses the grant with the policy's deterministic id,
|
|
480
|
+
* records the admitting rule in the audit log, and leaves the request to
|
|
481
|
+
* proceed through `submit` normally. Revoked ids are consulted from the
|
|
482
|
+
* grant store, so a revocation is never undone here.
|
|
483
|
+
*/
|
|
484
|
+
async admitRequest(options) {
|
|
485
|
+
const { policy, request } = options;
|
|
486
|
+
const manifest = typeof options.manifest === "string"
|
|
487
|
+
? verifyManifest(options.manifest)
|
|
488
|
+
: options.manifest;
|
|
489
|
+
const revoked = new Set((await this.grants.all())
|
|
490
|
+
.filter((grant) => grant.state === "revoked")
|
|
491
|
+
.map((grant) => grant.id));
|
|
492
|
+
const result = admit(policy, request, { revoked });
|
|
493
|
+
if (result.decision !== "allow")
|
|
494
|
+
return { result };
|
|
495
|
+
const granted = admittedGrant(policy, request, result);
|
|
496
|
+
if (granted === undefined)
|
|
497
|
+
return { result };
|
|
498
|
+
// A policy may never admit an intent whose hosts require a human.
|
|
499
|
+
const intentSpec = manifest.manifest.intents?.[granted.intent];
|
|
500
|
+
for (const capability of intentSpec?.required_capabilities ?? []) {
|
|
501
|
+
const meta = this.hosts.hostMeta(capability);
|
|
502
|
+
if (meta === undefined) {
|
|
503
|
+
return { result: { decision: "deny", reason: "capability_unavailable" } };
|
|
504
|
+
}
|
|
505
|
+
if (meta.profile.minConfirmation !== "none") {
|
|
506
|
+
return { result: { decision: "deny", reason: "risk_not_admittable" } };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const existing = await this.grants.get(granted.id);
|
|
510
|
+
if (existing !== undefined)
|
|
511
|
+
return { result, grant: existing };
|
|
512
|
+
const grant = createGrant({
|
|
513
|
+
id: granted.id,
|
|
514
|
+
contactId: request.contactId,
|
|
515
|
+
requesterKey: request.requesterKey,
|
|
516
|
+
manifest,
|
|
517
|
+
intents: [granted.intent],
|
|
518
|
+
limits: granted.limits,
|
|
519
|
+
confirmation: options.confirmation ?? { [granted.intent]: "none" },
|
|
520
|
+
createdAt: this.clock(),
|
|
521
|
+
expiresAt: granted.expiresAt,
|
|
522
|
+
admittedBy: result.ruleId,
|
|
523
|
+
});
|
|
524
|
+
await this.grants.add(grant);
|
|
525
|
+
this.manifests.add(manifest);
|
|
526
|
+
await this.audit.append({
|
|
527
|
+
event: "admitted",
|
|
528
|
+
grant_id: grant.id,
|
|
529
|
+
intent: granted.intent,
|
|
530
|
+
rule_id: result.ruleId,
|
|
531
|
+
});
|
|
532
|
+
return { result, grant };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Resolve the grant for this request.
|
|
536
|
+
*
|
|
537
|
+
* A requester may hold several grants over one manifest digest (for
|
|
538
|
+
* example, a manual grant and a policy-created one). The store lookup is
|
|
539
|
+
* the fast path; when it lands on a grant that does not enable the intent,
|
|
540
|
+
* prefer a grant that does. Revocation remains per grant (§25.8.3).
|
|
541
|
+
*/
|
|
542
|
+
async resolveGrant(requesterKey, manifestDigest, intent) {
|
|
543
|
+
const found = await this.grants.find(requesterKey, manifestDigest);
|
|
544
|
+
if (found !== undefined && found.intents.includes(intent))
|
|
545
|
+
return found;
|
|
546
|
+
const candidates = (await this.grants.all()).filter((grant) => grant.requester_key === requesterKey &&
|
|
547
|
+
grant.manifest_digest === manifestDigest &&
|
|
548
|
+
grant.intents.includes(intent));
|
|
549
|
+
if (candidates.length === 0)
|
|
550
|
+
return found;
|
|
551
|
+
return candidates.find((grant) => grant.state === "active") ?? candidates[0];
|
|
552
|
+
}
|
|
553
|
+
async transition(id, state, reason) {
|
|
554
|
+
const grant = await this.grants.get(id);
|
|
555
|
+
if (grant === undefined) {
|
|
556
|
+
throw new CcpError("no_matching_grant", `unknown grant: ${id}`);
|
|
557
|
+
}
|
|
558
|
+
if (grant.state === "revoked" && state !== "revoked") {
|
|
559
|
+
throw new CcpError("grant_revoked", "a revoked grant is final");
|
|
560
|
+
}
|
|
561
|
+
return this.setGrantState(grant, state, reason);
|
|
562
|
+
}
|
|
563
|
+
async setGrantState(grant, state, reason) {
|
|
564
|
+
const updated = {
|
|
565
|
+
...grant,
|
|
566
|
+
state,
|
|
567
|
+
state_reason: state === "active" ? null : reason ?? grant.state_reason ?? null,
|
|
568
|
+
};
|
|
569
|
+
await this.grants.add(updated);
|
|
570
|
+
await this.audit.append({
|
|
571
|
+
event: state === "active" ? "resumed" : state,
|
|
572
|
+
grant_id: grant.id,
|
|
573
|
+
detail: reason,
|
|
574
|
+
});
|
|
575
|
+
return updated;
|
|
576
|
+
}
|
|
217
577
|
async execute(signal, grant, intentSpec) {
|
|
218
578
|
await this.audit.append({
|
|
219
579
|
event: "executing",
|
|
@@ -234,7 +594,7 @@ export class Runtime {
|
|
|
234
594
|
}
|
|
235
595
|
let result;
|
|
236
596
|
try {
|
|
237
|
-
result = await executor(signal.params, this.hosts.access(grant.capabilities));
|
|
597
|
+
result = await executor(signal.params, this.hosts.access(grant.capabilities, this.egressPolicy(grant)));
|
|
238
598
|
}
|
|
239
599
|
catch (error) {
|
|
240
600
|
await this.limits.release(grant.id, grant.limits);
|
|
@@ -284,6 +644,51 @@ export class Runtime {
|
|
|
284
644
|
state: "completed",
|
|
285
645
|
};
|
|
286
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Enforce the grant's placement and egress ceilings before execution (§12).
|
|
649
|
+
* A host that needs network access cannot run under a grant that denies it.
|
|
650
|
+
*/
|
|
651
|
+
deploymentError(grant, intentSpec, required) {
|
|
652
|
+
const placement = typeof intentSpec.placement === "string" ? intentSpec.placement : "local";
|
|
653
|
+
if (!(grant.placement ?? []).includes(placement)) {
|
|
654
|
+
return `placement ${placement} is not granted`;
|
|
655
|
+
}
|
|
656
|
+
const policy = this.egressPolicy(grant);
|
|
657
|
+
for (const capability of required) {
|
|
658
|
+
const meta = this.hosts.hostMeta(capability);
|
|
659
|
+
if (meta === undefined)
|
|
660
|
+
return `host ${capability} is not installed`;
|
|
661
|
+
if (!meta.profile.placement.includes(placement)) {
|
|
662
|
+
return `host ${capability} cannot run with ${placement} placement`;
|
|
663
|
+
}
|
|
664
|
+
if (meta.profile.network && !policy.network) {
|
|
665
|
+
return `host ${capability} requires network egress not granted`;
|
|
666
|
+
}
|
|
667
|
+
for (const field of meta.profile.fields) {
|
|
668
|
+
if (!policy.fields.has(field)) {
|
|
669
|
+
return `host ${capability} may send undeclared field ${field}`;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
/** True when a required host declares a per-action confirmation floor. */
|
|
676
|
+
hostFloor(intentSpec) {
|
|
677
|
+
for (const capability of intentSpec.required_capabilities ?? []) {
|
|
678
|
+
const meta = this.hosts.hostMeta(capability);
|
|
679
|
+
if (meta?.profile.minConfirmation === "per_action")
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
egressPolicy(grant) {
|
|
685
|
+
const egress = grant.egress ?? {};
|
|
686
|
+
const fields = Array.isArray(egress.fields) ? egress.fields : [];
|
|
687
|
+
return {
|
|
688
|
+
network: egress.network === true,
|
|
689
|
+
fields: new Set(fields.filter((field) => typeof field === "string")),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
287
692
|
grantStateError(grant, now) {
|
|
288
693
|
if (grant.state === "revoked")
|
|
289
694
|
return "grant_revoked";
|
|
@@ -296,6 +701,20 @@ export class Runtime {
|
|
|
296
701
|
}
|
|
297
702
|
return undefined;
|
|
298
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Surface a recorded pause/suspend/revoke reason (§9.4.2). The reason is
|
|
706
|
+
* local and must not contain secrets; hosts choose what to record.
|
|
707
|
+
*/
|
|
708
|
+
stateDetail(outcome, grant) {
|
|
709
|
+
if (grant === undefined)
|
|
710
|
+
return undefined;
|
|
711
|
+
if (outcome === "grant_revoked" ||
|
|
712
|
+
outcome === "grant_paused" ||
|
|
713
|
+
outcome === "grant_suspended") {
|
|
714
|
+
return grant.state_reason ?? undefined;
|
|
715
|
+
}
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
299
718
|
async reject(code, requestId, grant, detail) {
|
|
300
719
|
await this.audit.append({
|
|
301
720
|
event: "rejected",
|