@loomwork/effect-runtime 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +65 -2
- package/dist/EffectRuntime.d.ts +121 -0
- package/dist/EffectRuntime.d.ts.map +1 -0
- package/dist/EffectRuntime.js +422 -0
- package/dist/EffectRuntime.js.map +1 -0
- package/dist/authz.d.ts +228 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +281 -0
- package/dist/authz.js.map +1 -0
- package/dist/effects/A2AEffect.d.ts +14 -0
- package/dist/effects/A2AEffect.d.ts.map +1 -0
- package/dist/effects/A2AEffect.js +21 -0
- package/dist/effects/A2AEffect.js.map +1 -0
- package/dist/effects/HumanEffect.d.ts +13 -0
- package/dist/effects/HumanEffect.d.ts.map +1 -0
- package/dist/effects/HumanEffect.js +21 -0
- package/dist/effects/HumanEffect.js.map +1 -0
- package/dist/effects/LazyEffect.d.ts +10 -0
- package/dist/effects/LazyEffect.d.ts.map +1 -0
- package/dist/effects/LazyEffect.js +28 -0
- package/dist/effects/LazyEffect.js.map +1 -0
- package/dist/effects/LlmEffect.d.ts +27 -0
- package/dist/effects/LlmEffect.d.ts.map +1 -0
- package/dist/effects/LlmEffect.js +27 -0
- package/dist/effects/LlmEffect.js.map +1 -0
- package/dist/effects/ToolEffect.d.ts +38 -0
- package/dist/effects/ToolEffect.d.ts.map +1 -0
- package/dist/effects/ToolEffect.js +31 -0
- package/dist/effects/ToolEffect.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +76 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +212 -0
- package/dist/policy.js.map +1 -0
- package/dist/types.d.ts +416 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -3
- package/src/EffectRuntime.ts +484 -0
- package/src/authz.ts +424 -0
- package/src/effects/HumanEffect.ts +25 -0
- package/src/effects/LazyEffect.ts +25 -0
- package/src/effects/LlmEffect.ts +39 -0
- package/src/effects/ToolEffect.ts +59 -0
- package/src/index.ts +48 -0
- package/src/policy.ts +296 -0
- package/src/types.ts +428 -0
package/src/authz.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// ── Capability protection (Loom 2.0 K2) — the kernel's authority ring ─────────
|
|
2
|
+
//
|
|
3
|
+
// Every side effect flows through EffectRuntime.execute, so that is the one boundary
|
|
4
|
+
// a library CAN enforce (unlike hardware memory protection). A CapabilityAuthorizer
|
|
5
|
+
// decides, per invocation, whether the calling execution holds the authority to run
|
|
6
|
+
// the effect. Grants are unforgeable: an execution cannot grant itself a capability —
|
|
7
|
+
// grants live only in the authorizer, wired statically at boot (P5). A denied effect
|
|
8
|
+
// never runs; the denial is emitted as a durable EFFECT_DENIED fact (P9) and fails
|
|
9
|
+
// the step. Enforcement is opt-in: no authorizer ⇒ today's permissive behaviour.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* K12.1 — an authenticated principal chain, populated at the trust boundary
|
|
13
|
+
* (A2A server / resume API) and propagated immutably through dispatch. The
|
|
14
|
+
* KERNEL CARRIES IT OPAQUELY and never interprets it — authorizers, budget
|
|
15
|
+
* scopes, and stores do. Keeping the kernel free of a tenant model is what keeps
|
|
16
|
+
* Loom a generic execution kernel (P10). Extra fields are allowed; a
|
|
17
|
+
* `delegation` (K12.4) rides here.
|
|
18
|
+
*/
|
|
19
|
+
export interface PrincipalChain {
|
|
20
|
+
/** The tenant boundary — an opaque, pseudonymous id (never human PII; see K10). */
|
|
21
|
+
tenant?: string;
|
|
22
|
+
/** The acting identity. */
|
|
23
|
+
actor?: string;
|
|
24
|
+
/** The identity this action is performed on behalf of (delegation origin). */
|
|
25
|
+
onBehalfOf?: string;
|
|
26
|
+
/** K12.4 — a verifiable attenuated-delegation token, checked at the callee. */
|
|
27
|
+
delegation?: DelegatedGrant;
|
|
28
|
+
/**
|
|
29
|
+
* R3k (ADR 0111 D2/D4) — set ONLY by the kernel's principal resolver, never by a caller: the
|
|
30
|
+
* execution's recorded principal carried a delegation (this is its digest id) that no codec or
|
|
31
|
+
* resolver could restore. The effect seam refuses every effect of such a principal BEFORE any
|
|
32
|
+
* authorizer is consulted ({@link principalBlocked}); it never falls back to a broader identity.
|
|
33
|
+
*/
|
|
34
|
+
delegationUnresolved?: string;
|
|
35
|
+
/** R3k — the recorded principal was crypto-shredded: every effect is denied (never the resumer's). */
|
|
36
|
+
principalShredded?: true;
|
|
37
|
+
/** R3k — the recorded principal could not be rehydrated (a `ref` with no codec, an unknown record version). */
|
|
38
|
+
principalUnresolved?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* R3k (ADR 0111 D4) — why the KERNEL refuses every effect of this principal before any
|
|
44
|
+
* authorizer is asked, or `undefined` when it does not. A `PolicyAuthorizer`, a `GrantTable` or a
|
|
45
|
+
* 3P authorizer would otherwise judge a chain whose delegation could not be restored on its own
|
|
46
|
+
* terms (an unattenuated child after a crash is the privilege escalation this closes).
|
|
47
|
+
*/
|
|
48
|
+
export function principalBlocked(principal: PrincipalChain | undefined): string | undefined {
|
|
49
|
+
if (principal === undefined) return undefined;
|
|
50
|
+
if (principal.principalShredded === true) {
|
|
51
|
+
return "the execution's recorded principal was crypto-shredded";
|
|
52
|
+
}
|
|
53
|
+
if (principal.delegationUnresolved !== undefined) {
|
|
54
|
+
return `the execution's recorded delegation "${String(principal.delegationUnresolved)}" could not be restored (no principalCodec/resolver)`;
|
|
55
|
+
}
|
|
56
|
+
if (principal.principalUnresolved !== undefined) {
|
|
57
|
+
return `the execution's recorded principal could not be restored: ${String(principal.principalUnresolved)}`;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CapabilityRequest {
|
|
63
|
+
toolId: string;
|
|
64
|
+
/**
|
|
65
|
+
* R12 — the sub-resource this call targets, when the effect declares one
|
|
66
|
+
* (`Effect.resourceOf`): e.g. `"<server>/<tool>"` for `mcp.invoke`. A grant naming
|
|
67
|
+
* {@link capabilityIds | `<toolId>:<resource>`} authorizes exactly that resource; a grant on
|
|
68
|
+
* the bare `toolId` still authorizes every resource of the effect (unchanged behaviour).
|
|
69
|
+
*/
|
|
70
|
+
resource?: string;
|
|
71
|
+
executionId: string;
|
|
72
|
+
workflowId: string;
|
|
73
|
+
stepId: string;
|
|
74
|
+
/** K12.1 — the authenticated principal chain, if one was established. */
|
|
75
|
+
principal?: PrincipalChain;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* R12 — the capability ids a request is authorized under: the effect id (covers every resource
|
|
80
|
+
* of the effect — the legacy, coarse grant) and, when the call names a resource, the qualified
|
|
81
|
+
* `<toolId>:<resource>` id (covers just that resource).
|
|
82
|
+
*/
|
|
83
|
+
export function capabilityId(req: CapabilityRequest): string {
|
|
84
|
+
return req.resource === undefined ? req.toolId : `${req.toolId}:${req.resource}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function capabilityIds(req: CapabilityRequest): readonly string[] {
|
|
88
|
+
return req.resource === undefined ? [req.toolId] : [req.toolId, `${req.toolId}:${req.resource}`];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CapabilityDecision {
|
|
92
|
+
allowed: boolean;
|
|
93
|
+
reason?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The protection ring. Pure and synchronous so the check is deterministic and
|
|
97
|
+
// replay-safe (the same request always yields the same decision).
|
|
98
|
+
export interface CapabilityAuthorizer {
|
|
99
|
+
authorize(req: CapabilityRequest): CapabilityDecision;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class CapabilityDeniedError extends Error {
|
|
103
|
+
readonly toolId: string;
|
|
104
|
+
readonly reason: string;
|
|
105
|
+
constructor(toolId: string, reason: string) {
|
|
106
|
+
super(`Capability denied: effect "${toolId}" — ${reason}`);
|
|
107
|
+
this.name = "CapabilityDeniedError";
|
|
108
|
+
this.toolId = toolId;
|
|
109
|
+
this.reason = reason;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface GrantTableOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Which field identifies the "principal" whose grants apply. Default: the
|
|
116
|
+
* workflowId (a flow is the unit of authority). Return `executionId` for
|
|
117
|
+
* per-execution grants, or read a grant-set id from the request.
|
|
118
|
+
*/
|
|
119
|
+
principal?: (req: CapabilityRequest) => string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The least-privilege authorizer: a static table mapping a principal to the set of
|
|
124
|
+
* effect ids it may invoke (`"*"` grants all). Anything not explicitly granted is
|
|
125
|
+
* denied. Grants are declared at boot and cannot be mutated at runtime, so authority
|
|
126
|
+
* is fixed and auditable.
|
|
127
|
+
*/
|
|
128
|
+
export class GrantTable implements CapabilityAuthorizer {
|
|
129
|
+
private readonly grants: Map<string, Set<string>>;
|
|
130
|
+
private readonly principalOf: (req: CapabilityRequest) => string;
|
|
131
|
+
|
|
132
|
+
constructor(grants: Record<string, readonly string[]>, options: GrantTableOptions = {}) {
|
|
133
|
+
this.grants = new Map(Object.entries(grants).map(([k, v]) => [k, new Set(v)]));
|
|
134
|
+
this.principalOf = options.principal ?? ((req) => req.workflowId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
authorize(req: CapabilityRequest): CapabilityDecision {
|
|
138
|
+
const principal = this.principalOf(req);
|
|
139
|
+
const granted = this.grants.get(principal);
|
|
140
|
+
if (granted === undefined) {
|
|
141
|
+
return { allowed: false, reason: `principal "${principal}" has no grants` };
|
|
142
|
+
}
|
|
143
|
+
if (granted.has("*") || capabilityIds(req).some((id) => granted.has(id))) {
|
|
144
|
+
return { allowed: true };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
allowed: false,
|
|
148
|
+
reason: `effect "${capabilityId(req)}" not granted to "${principal}"`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A read-only projection of the static grants — principal → granted effect ids,
|
|
154
|
+
* or `"*"` when all effects are granted. For pre-flight tooling (e.g. `@loomwork/verify`)
|
|
155
|
+
* that needs to inspect authority without a live {@link CapabilityRequest}.
|
|
156
|
+
*/
|
|
157
|
+
view(): Record<string, readonly string[] | "*"> {
|
|
158
|
+
const out: Record<string, readonly string[] | "*"> = {};
|
|
159
|
+
for (const [principal, set] of this.grants) {
|
|
160
|
+
out[principal] = set.has("*") ? "*" : [...set];
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface DelegationAuthorizerOptions {
|
|
167
|
+
/**
|
|
168
|
+
* Shared secret used to verify delegation signatures at this trust boundary.
|
|
169
|
+
*
|
|
170
|
+
* NOTE this is SYMMETRIC: a party that can verify a token can also mint one.
|
|
171
|
+
* That is why {@link DelegationAuthorizerOptions.base} is required — see below.
|
|
172
|
+
*/
|
|
173
|
+
secret: string;
|
|
174
|
+
/**
|
|
175
|
+
* The callee's OWN authority — an effect is allowed only if it is in BOTH the
|
|
176
|
+
* verified delegated set AND the callee's own grants (never ambient escalation).
|
|
177
|
+
*
|
|
178
|
+
* REQUIRED, and it is load-bearing for security rather than a convenience.
|
|
179
|
+
* Because `secret` is symmetric, the verifier is also a valid minter: a callee
|
|
180
|
+
* can always sign itself a token granting `"*"`, and that token is
|
|
181
|
+
* cryptographically indistinguishable from a genuine one. The delegation check
|
|
182
|
+
* therefore cannot bound a callee's authority on its own — only the
|
|
183
|
+
* intersection with `base` can. With `base` set, minting a token gains a callee
|
|
184
|
+
* nothing it did not already hold, which is what makes the symmetric secret
|
|
185
|
+
* safe here.
|
|
186
|
+
*
|
|
187
|
+
* This option used to be optional, documented as "omit to allow the full
|
|
188
|
+
* verified delegated set (the callee delegates all of its authority decisions
|
|
189
|
+
* upstream)". That mode could not deliver what it claimed: upstream cannot
|
|
190
|
+
* retain authority over a callee that holds the minting key. Delegating
|
|
191
|
+
* authority ACROSS a trust boundary needs asymmetric signatures (the verifier
|
|
192
|
+
* must not be able to mint), which this primitive does not implement.
|
|
193
|
+
*/
|
|
194
|
+
base: CapabilityAuthorizer;
|
|
195
|
+
/** Clock for token-expiry checks (epoch ms). Injectable for tests; defaults to `Date.now`. */
|
|
196
|
+
now?: () => number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* K12.4 — authorizes an effect against a VERIFIED attenuated delegation carried
|
|
201
|
+
* in `req.principal.delegation`. The token's signature is checked first (a
|
|
202
|
+
* forged or tampered delegation is denied outright), then the effect must be in
|
|
203
|
+
* the **intersection** of the delegated grants and the callee's own authority
|
|
204
|
+
* (`base`) — a delegate can never invoke an effect the delegator lacked, nor one
|
|
205
|
+
* the callee itself lacks. No delegation present ⇒ falls through to `base`.
|
|
206
|
+
*
|
|
207
|
+
* TRUST MODEL — delegator and callee share one secret, so they are one trust
|
|
208
|
+
* domain. The signature stops a THIRD party (a caller, a middlebox) forging or
|
|
209
|
+
* tampering with a grant set in transit; it does not, and cannot, stop the
|
|
210
|
+
* verifier minting its own. Authority over the callee comes from `base`, never
|
|
211
|
+
* from the token. Cross-organisation delegation, where the verifier must not be
|
|
212
|
+
* able to mint, is out of scope for this primitive and needs asymmetric
|
|
213
|
+
* signatures. See ADR 0024.
|
|
214
|
+
*/
|
|
215
|
+
export class DelegationAuthorizer implements CapabilityAuthorizer {
|
|
216
|
+
constructor(private readonly options: DelegationAuthorizerOptions) {
|
|
217
|
+
// The type alone is not the enforcement — JS callers and `as any` bypass it,
|
|
218
|
+
// and an absent `base` silently turns the intersection into a no-op, which
|
|
219
|
+
// reads as a working gate while bounding nothing.
|
|
220
|
+
if (options.base === undefined || options.base === null) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"DelegationAuthorizer: `base` is required — the delegation secret is symmetric, so a " +
|
|
223
|
+
"verified token cannot bound a callee's authority on its own (see ADR 0024)."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
authorize(req: CapabilityRequest): CapabilityDecision {
|
|
229
|
+
// Defence in depth: the kernel already refuses these before any authorizer (principalBlocked).
|
|
230
|
+
const blocked = principalBlocked(req.principal);
|
|
231
|
+
if (blocked !== undefined) return { allowed: false, reason: blocked };
|
|
232
|
+
const token = req.principal?.delegation;
|
|
233
|
+
if (token === undefined) {
|
|
234
|
+
return this.options.base.authorize(req);
|
|
235
|
+
}
|
|
236
|
+
if (!verifyDelegation(this.options.secret, token)) {
|
|
237
|
+
return { allowed: false, reason: "delegation signature invalid (forged or tampered)" };
|
|
238
|
+
}
|
|
239
|
+
// Reject expired tokens (exp is signature-bound, so it can't be extended). A token
|
|
240
|
+
// without exp never expires — the same as before this field existed.
|
|
241
|
+
if (token.exp !== undefined && (this.options.now?.() ?? Date.now()) >= token.exp) {
|
|
242
|
+
return { allowed: false, reason: `delegation expired at ${token.exp}` };
|
|
243
|
+
}
|
|
244
|
+
// K12.4 — bind the token to its intended holder. A delegation minted for
|
|
245
|
+
// `issuedTo` may only be presented BY that principal (`req.principal.actor`).
|
|
246
|
+
// The signature proves authenticity, not that the bearer is the intended
|
|
247
|
+
// holder — without this check a captured/misrouted token is a confused deputy,
|
|
248
|
+
// usable by whoever holds it.
|
|
249
|
+
if (req.principal?.actor !== token.issuedTo) {
|
|
250
|
+
return {
|
|
251
|
+
allowed: false,
|
|
252
|
+
reason: `delegation issued to "${token.issuedTo}" but presented by "${req.principal?.actor ?? "(none)"}"`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const delegated = new Set(token.grants);
|
|
256
|
+
const inDelegated = delegated.has("*") || capabilityIds(req).some((id) => delegated.has(id));
|
|
257
|
+
if (!inDelegated) {
|
|
258
|
+
return {
|
|
259
|
+
allowed: false,
|
|
260
|
+
reason: `effect "${capabilityId(req)}" not in the delegated grant set`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// Intersection: the callee must ALSO hold the authority (no ambient escalation).
|
|
264
|
+
// This is what bounds a callee that could mint its own token — never the signature.
|
|
265
|
+
const baseDecision = this.options.base.authorize(req);
|
|
266
|
+
if (!baseDecision.allowed) {
|
|
267
|
+
return {
|
|
268
|
+
allowed: false,
|
|
269
|
+
reason: `delegated, but the callee itself lacks "${req.toolId}": ${baseDecision.reason ?? ""}`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return { allowed: true };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── K12.4 — verifiable attenuated delegation (macaroon-lite) ─────────────────
|
|
277
|
+
//
|
|
278
|
+
// The evaluation's amendment: "an attenuated grant set carried as a plain
|
|
279
|
+
// request field is forgeable by any caller or middlebox." So a delegation is
|
|
280
|
+
// SIGNED (HMAC) by the delegator and VERIFIED at the callee's trust boundary
|
|
281
|
+
// before its grants are honored. Attenuation can only NARROW: a holder derives
|
|
282
|
+
// a more-restricted token, never a broader one, because the delegated grant set
|
|
283
|
+
// is bound into the signature. The intersection is computed from the VERIFIED
|
|
284
|
+
// set, never a forgeable field.
|
|
285
|
+
|
|
286
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
287
|
+
|
|
288
|
+
/** A signed delegation: the grant subset a delegator conferred, HMAC-bound. */
|
|
289
|
+
export interface DelegatedGrant {
|
|
290
|
+
/** The delegated capability ids (effect ids the callee may invoke on the caller's behalf). */
|
|
291
|
+
grants: string[];
|
|
292
|
+
/** The principal this token was issued to (bound into the signature). */
|
|
293
|
+
issuedTo: string;
|
|
294
|
+
/** Optional absolute expiry (epoch ms), bound into the signature; rejected once past. */
|
|
295
|
+
exp?: number;
|
|
296
|
+
/** HMAC-SHA256 over `${issuedTo}\n${sorted grants}[\nexp=…]` with the shared delegation secret. */
|
|
297
|
+
sig: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// `exp` is appended only when present, so tokens without it sign the exact prior payload
|
|
301
|
+
// (backward-compatible), while a tampered `exp` breaks the signature.
|
|
302
|
+
function delegationPayload(issuedTo: string, grants: readonly string[], exp?: number): string {
|
|
303
|
+
const base = `${issuedTo}\n${[...grants].sort().join(",")}`;
|
|
304
|
+
return exp !== undefined ? `${base}\nexp=${exp}` : base;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Mint a delegation token: the delegator signs the subset it confers on `issuedTo`. */
|
|
308
|
+
export function signDelegation(
|
|
309
|
+
secret: string,
|
|
310
|
+
issuedTo: string,
|
|
311
|
+
grants: string[],
|
|
312
|
+
exp?: number
|
|
313
|
+
): DelegatedGrant {
|
|
314
|
+
const sig = createHmac("sha256", secret)
|
|
315
|
+
.update(delegationPayload(issuedTo, grants, exp))
|
|
316
|
+
.digest("hex");
|
|
317
|
+
return { grants: [...grants], issuedTo, ...(exp !== undefined && { exp }), sig };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* R3k (ADR 0111 D1) — a stable, NON-bearer identifier of a token, for the durable principal
|
|
322
|
+
* record: SHA-256 over the signature, so the log can say which delegation an execution held
|
|
323
|
+
* without holding anything that can be presented. The token itself is never written.
|
|
324
|
+
*/
|
|
325
|
+
export function delegationDigest(token: DelegatedGrant): string {
|
|
326
|
+
return createHash("sha256").update(String(token.sig)).digest("hex");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Verify a delegation's signature (constant-time). A forged/tampered token fails. */
|
|
330
|
+
export function verifyDelegation(secret: string, token: DelegatedGrant): boolean {
|
|
331
|
+
const expected = createHmac("sha256", secret)
|
|
332
|
+
.update(delegationPayload(token.issuedTo, token.grants, token.exp))
|
|
333
|
+
.digest("hex");
|
|
334
|
+
const a = Buffer.from(expected, "hex");
|
|
335
|
+
const b = Buffer.from(token.sig, "hex");
|
|
336
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Attenuate a delegation to a narrower subset, re-signed for a new holder — only
|
|
341
|
+
* capabilities already in the token survive, and the parent's expiry is inherited.
|
|
342
|
+
* Requires the shared secret (the delegator/mint authority performs it); a
|
|
343
|
+
* pure-holder chained-HMAC variant is a future option.
|
|
344
|
+
*
|
|
345
|
+
* "Never broadening" is enforced on BOTH axes:
|
|
346
|
+
*
|
|
347
|
+
* - **Grants.** A capability absent from the parent cannot appear in the child. A parent
|
|
348
|
+
* holding `"*"` may attenuate to any concrete subset (that is narrowing) — previously
|
|
349
|
+
* such a subset was filtered against the literal `["*"]` array and came back EMPTY, so a
|
|
350
|
+
* wildcard delegator could not attenuate at all.
|
|
351
|
+
* - **Expiry.** The parent's `exp` is inherited, and `exp` may only be brought EARLIER.
|
|
352
|
+
* This was the important one: `exp` used not to be propagated, so attenuating a token
|
|
353
|
+
* that expired in an hour produced a child that **never expired** — narrowing the grant
|
|
354
|
+
* set while silently making the token immortal.
|
|
355
|
+
*
|
|
356
|
+
* Throws on an unverifiable parent. The signature is what makes the grant set trustworthy,
|
|
357
|
+
* and this function holds the secret — so attenuating an unchecked token would launder a
|
|
358
|
+
* forged one into a validly signed child. The old contract merely *documented* that the
|
|
359
|
+
* caller must pass a verified token; a security primitive should not depend on that.
|
|
360
|
+
*/
|
|
361
|
+
export function attenuateDelegation(
|
|
362
|
+
secret: string,
|
|
363
|
+
token: DelegatedGrant,
|
|
364
|
+
issuedTo: string,
|
|
365
|
+
subset: string[],
|
|
366
|
+
exp?: number
|
|
367
|
+
): DelegatedGrant {
|
|
368
|
+
if (!verifyDelegation(secret, token)) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
"attenuateDelegation: parent delegation failed verification — refusing to sign a child from an unverified token"
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const parentIsWildcard = token.grants.includes("*");
|
|
374
|
+
const narrowed = parentIsWildcard ? [...subset] : subset.filter((g) => token.grants.includes(g));
|
|
375
|
+
// Inherit the parent's expiry; an explicit `exp` may only tighten it.
|
|
376
|
+
const inherited = token.exp;
|
|
377
|
+
const nextExp =
|
|
378
|
+
inherited === undefined ? exp : exp === undefined ? inherited : Math.min(inherited, exp);
|
|
379
|
+
return signDelegation(secret, issuedTo, narrowed, nextExp);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
import type { Scheduler } from "@loomwork/scheduler";
|
|
383
|
+
import type { Warmer } from "./types";
|
|
384
|
+
|
|
385
|
+
export interface EffectRuntimeOptions {
|
|
386
|
+
/** When set, every effect is authorized against it; ungranted effects are denied (K2). */
|
|
387
|
+
authorizer?: CapabilityAuthorizer;
|
|
388
|
+
/**
|
|
389
|
+
* When set, effects run through this scheduler (M6) — bounded concurrency + priority
|
|
390
|
+
* ordering across all of a runtime's effects (admission control). Omit for the
|
|
391
|
+
* zero-overhead direct path.
|
|
392
|
+
*/
|
|
393
|
+
scheduler?: Scheduler;
|
|
394
|
+
/** Advisory pre-warm hook, invoked off-log before each effect (M6). */
|
|
395
|
+
warmer?: Warmer;
|
|
396
|
+
/** ADR 0119 — where a swallowed delta-sink throw is reported. Defaults to `console.warn`. */
|
|
397
|
+
onWarn?: (message: string) => void;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* R16 / R17 — THE identity key: `[tenant, actor, onBehalfOf]`, one string, shared by everything that
|
|
402
|
+
* scopes state to "who" (a conversation's owner, a goal's owner). Two principals are the same identity
|
|
403
|
+
* iff all three fields are equal (an absent field equals only an absent field), so a delegate acting
|
|
404
|
+
* `onBehalfOf` someone is a DIFFERENT identity from that someone. A credential (a delegation token)
|
|
405
|
+
* is not part of identity. Keep one definition: two keys for one idea is how a scope check drifts.
|
|
406
|
+
*/
|
|
407
|
+
export function identityKeyOf(i: { tenant?: string; actor: string; onBehalfOf?: string }): string {
|
|
408
|
+
return JSON.stringify([i.tenant ?? null, i.actor, i.onBehalfOf ?? null]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The identity key of a principal, or `undefined` when it names no actor (it can own nothing) or the
|
|
413
|
+
* kernel marked it unusable ({@link principalBlocked}: shredded, delegation or record unresolved).
|
|
414
|
+
*/
|
|
415
|
+
export function principalIdentityKey(principal: PrincipalChain | undefined): string | undefined {
|
|
416
|
+
if (principal === undefined || principalBlocked(principal) !== undefined) return undefined;
|
|
417
|
+
const actor = principal.actor;
|
|
418
|
+
if (typeof actor !== "string" || actor.trim() === "") return undefined;
|
|
419
|
+
return identityKeyOf({
|
|
420
|
+
actor,
|
|
421
|
+
...(typeof principal.tenant === "string" && { tenant: principal.tenant }),
|
|
422
|
+
...(typeof principal.onBehalfOf === "string" && { onBehalfOf: principal.onBehalfOf }),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SuspendError } from "@loomwork/core";
|
|
2
|
+
import type { Effect, EffectContext } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface HumanEffectConfig {
|
|
5
|
+
on: string;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
correlationKey?: (input: unknown) => string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class HumanEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
|
|
11
|
+
readonly kind = "human" as const;
|
|
12
|
+
|
|
13
|
+
constructor(private readonly config: HumanEffectConfig) {}
|
|
14
|
+
|
|
15
|
+
execute(input: TIn, _ctx: EffectContext): Promise<TOut> {
|
|
16
|
+
const correlationKey =
|
|
17
|
+
this.config.correlationKey !== undefined ? this.config.correlationKey(input) : undefined;
|
|
18
|
+
|
|
19
|
+
throw new SuspendError({
|
|
20
|
+
on: this.config.on,
|
|
21
|
+
...(this.config.timeout !== undefined && { timeout: this.config.timeout }),
|
|
22
|
+
...(correlationKey !== undefined && { correlationKey }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HandlerFn, HandlerLoader, HandlerRef, HandlerSpec } from "@loomwork/core";
|
|
2
|
+
import type { Effect, EffectContext } from "../types";
|
|
3
|
+
|
|
4
|
+
function isHandlerRef<TIn, TOut>(spec: HandlerSpec<TIn, TOut>): spec is HandlerRef<TIn, TOut> {
|
|
5
|
+
return (spec as HandlerRef<TIn, TOut>)._loomHandlerRef === true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class LazyEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
|
|
9
|
+
readonly kind = "tool" as const;
|
|
10
|
+
private resolved: HandlerFn<TIn, TOut> | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(private readonly spec: HandlerSpec<TIn, TOut>) {}
|
|
13
|
+
|
|
14
|
+
async execute(input: TIn, ctx: EffectContext): Promise<TOut> {
|
|
15
|
+
if (!this.resolved) {
|
|
16
|
+
if (isHandlerRef(this.spec)) {
|
|
17
|
+
this.resolved = this.spec.fn as HandlerFn<TIn, TOut>;
|
|
18
|
+
} else {
|
|
19
|
+
const mod = await (this.spec as HandlerLoader<TIn, TOut>)();
|
|
20
|
+
this.resolved = mod.default;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return this.resolved(input, ctx);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TaintedValue } from "@loomwork/classify";
|
|
2
|
+
import type { Effect, EffectContext, EffectDelta, EffectReasoningHook } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface LlmEffectOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Advisory pre-warm — invoked by the kernel ahead of use to keep the
|
|
7
|
+
* provider's connection/model cache hot (see {@link Effect.warm}). Wired by
|
|
8
|
+
* `createLlmEffect` to the provider's `warm()`. Latency-only.
|
|
9
|
+
*/
|
|
10
|
+
warm?: () => void | Promise<void>;
|
|
11
|
+
/** ADR 0119 — the configured delta sink the runtime guards (see `Effect.deltaSink`). */
|
|
12
|
+
deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
|
|
13
|
+
/** ADR 0133 D6 - the configured reasoning sink the runtime guards (see `Effect.reasoningSink`). */
|
|
14
|
+
reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
|
|
15
|
+
/** ADR 0133 D7 - what the composition root may ask about the effect's reasoning posture. */
|
|
16
|
+
reasoning?: EffectReasoningHook;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class LlmEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
|
|
20
|
+
readonly kind = "llm" as const;
|
|
21
|
+
readonly warm?: () => void | Promise<void>;
|
|
22
|
+
readonly deltaSink?: (chunk: EffectDelta, ctx: EffectContext) => void;
|
|
23
|
+
readonly reasoningSink?: (delta: TaintedValue<string>, ctx: EffectContext) => void;
|
|
24
|
+
readonly reasoning?: EffectReasoningHook;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly fn: (input: TIn, ctx: EffectContext) => Promise<TOut>,
|
|
28
|
+
options: LlmEffectOptions = {}
|
|
29
|
+
) {
|
|
30
|
+
if (options.warm !== undefined) this.warm = options.warm;
|
|
31
|
+
if (options.deltaSink !== undefined) this.deltaSink = options.deltaSink;
|
|
32
|
+
if (options.reasoningSink !== undefined) this.reasoningSink = options.reasoningSink;
|
|
33
|
+
if (options.reasoning !== undefined) this.reasoning = options.reasoning;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
execute(input: TIn, ctx: EffectContext): Promise<TOut> {
|
|
37
|
+
return this.fn(input, ctx);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Effect, EffectContext, EffectMode, ErrorChannelSchema } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface ToolEffectOptions {
|
|
4
|
+
/**
|
|
5
|
+
* K9.3 — declared delivery semantic for this tool (see {@link Effect.idempotent}).
|
|
6
|
+
* `false` = mutating & non-idempotent: recovery parks an unknown-state attempt at
|
|
7
|
+
* EFFECT_INDOUBT instead of silently re-running it. Omit for the legacy
|
|
8
|
+
* at-least-once re-run.
|
|
9
|
+
*/
|
|
10
|
+
idempotent?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* DE-4 — declared failure channels for this tool (see {@link Effect.errors}).
|
|
13
|
+
* Pass the same map declared on the `tool$` contract so the kernel's registry
|
|
14
|
+
* fallback sees it. Omit for legacy untyped failure semantics.
|
|
15
|
+
*/
|
|
16
|
+
errors?: Record<string, ErrorChannelSchema>;
|
|
17
|
+
/**
|
|
18
|
+
* ADR 0073 — which invocation seam this tool belongs to. Pass the same value
|
|
19
|
+
* declared on the `tool$` contract; this registered copy is the enforced one.
|
|
20
|
+
* Omit to keep today's behaviour on both seams.
|
|
21
|
+
*/
|
|
22
|
+
mode?: EffectMode;
|
|
23
|
+
/**
|
|
24
|
+
* R12 — the sub-resource a call targets, for per-resource authorization (see
|
|
25
|
+
* {@link Effect.resourceOf}). Omit for effect-wide authorization only.
|
|
26
|
+
*/
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: the option is not generic; the effect's TIn types the call site
|
|
28
|
+
resourceOf?: (input: any) => string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ToolEffect<TIn = unknown, TOut = unknown> implements Effect<TIn, TOut> {
|
|
32
|
+
readonly kind = "tool" as const;
|
|
33
|
+
readonly idempotent?: boolean;
|
|
34
|
+
readonly errors?: Record<string, ErrorChannelSchema>;
|
|
35
|
+
readonly mode?: EffectMode;
|
|
36
|
+
readonly resourceOf?: (input: TIn) => string | undefined;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly fn: (input: TIn, ctx: EffectContext) => Promise<TOut>,
|
|
40
|
+
options: ToolEffectOptions = {}
|
|
41
|
+
) {
|
|
42
|
+
if (options.idempotent !== undefined) {
|
|
43
|
+
this.idempotent = options.idempotent;
|
|
44
|
+
}
|
|
45
|
+
if (options.errors !== undefined) {
|
|
46
|
+
this.errors = options.errors;
|
|
47
|
+
}
|
|
48
|
+
if (options.mode !== undefined) {
|
|
49
|
+
this.mode = options.mode;
|
|
50
|
+
}
|
|
51
|
+
if (options.resourceOf !== undefined) {
|
|
52
|
+
this.resourceOf = options.resourceOf;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
execute(input: TIn, ctx: EffectContext): Promise<TOut> {
|
|
57
|
+
return this.fn(input, ctx);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export { EffectRuntime } from "./EffectRuntime";
|
|
2
|
+
export { LazyEffect } from "./effects/LazyEffect";
|
|
3
|
+
export { ToolEffect, type ToolEffectOptions } from "./effects/ToolEffect";
|
|
4
|
+
export { LlmEffect, type LlmEffectOptions } from "./effects/LlmEffect";
|
|
5
|
+
export { HumanEffect } from "./effects/HumanEffect";
|
|
6
|
+
export { EffectModeViolationError } from "./types";
|
|
7
|
+
export type {
|
|
8
|
+
DeltaSink,
|
|
9
|
+
Effect,
|
|
10
|
+
EffectDelta,
|
|
11
|
+
EffectContext,
|
|
12
|
+
EffectKind,
|
|
13
|
+
EffectMiddleware,
|
|
14
|
+
EffectReasoningDefaults,
|
|
15
|
+
EffectReasoningHook,
|
|
16
|
+
EffectMode,
|
|
17
|
+
EffectNext,
|
|
18
|
+
ErrorChannelSchema,
|
|
19
|
+
ResourceSnapshot,
|
|
20
|
+
TransientIdentity,
|
|
21
|
+
TransientInvokeOptions,
|
|
22
|
+
Warmer,
|
|
23
|
+
} from "./types";
|
|
24
|
+
export {
|
|
25
|
+
CapabilityDeniedError,
|
|
26
|
+
capabilityIds,
|
|
27
|
+
GrantTable,
|
|
28
|
+
DelegationAuthorizer,
|
|
29
|
+
signDelegation,
|
|
30
|
+
verifyDelegation,
|
|
31
|
+
attenuateDelegation,
|
|
32
|
+
delegationDigest,
|
|
33
|
+
principalBlocked,
|
|
34
|
+
principalIdentityKey,
|
|
35
|
+
identityKeyOf,
|
|
36
|
+
} from "./authz";
|
|
37
|
+
export { PolicyAuthorizer } from "./policy";
|
|
38
|
+
export type { PolicyRules, PolicyMatchRule, PolicyAuthorizerOptions } from "./policy";
|
|
39
|
+
export type {
|
|
40
|
+
CapabilityAuthorizer,
|
|
41
|
+
CapabilityDecision,
|
|
42
|
+
CapabilityRequest,
|
|
43
|
+
DelegatedGrant,
|
|
44
|
+
DelegationAuthorizerOptions,
|
|
45
|
+
EffectRuntimeOptions,
|
|
46
|
+
GrantTableOptions,
|
|
47
|
+
PrincipalChain,
|
|
48
|
+
} from "./authz";
|