@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/policy.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// ── Policy enforcement (policy$ → the K2 protection ring) ─────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A `PolicyAuthorizer` turns declarative `policy$` definitions into per-effect
|
|
4
|
+
// authorization decisions, so a `PolicyDefinition` attached to a flow/agent (or
|
|
5
|
+
// declared app-globally) is actually ENFORCED at the one boundary every side
|
|
6
|
+
// effect flows through (`EffectRuntime.execute`). It composes UNDER an existing
|
|
7
|
+
// authorizer (e.g. a `GrantTable`): an effect runs only if BOTH allow — policy
|
|
8
|
+
// can tighten, never widen, a grant (defense in depth). Pure + synchronous like
|
|
9
|
+
// every authorizer, so the decision is deterministic and replay-safe.
|
|
10
|
+
|
|
11
|
+
import type { PolicyDefinition } from "@loomwork/core";
|
|
12
|
+
import { capabilityId } from "./authz";
|
|
13
|
+
import type { CapabilityAuthorizer, CapabilityDecision, CapabilityRequest } from "./authz";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single matcher rule (the "rich" form). Every present field must match the
|
|
17
|
+
* request (an omitted field is a wildcard); `deny` flips a match from allow to deny.
|
|
18
|
+
* `effect`/`tenant`/`step` are globs (`*` matches any run of characters).
|
|
19
|
+
*/
|
|
20
|
+
export interface PolicyMatchRule {
|
|
21
|
+
/** Glob on the effect (tool) id, e.g. `"external-*"`. */
|
|
22
|
+
effect?: string;
|
|
23
|
+
/** Glob on `principal.tenant`. */
|
|
24
|
+
tenant?: string;
|
|
25
|
+
/** Glob on the step id. */
|
|
26
|
+
step?: string;
|
|
27
|
+
/** When true, a match DENIES (default: a match ALLOWS). */
|
|
28
|
+
deny?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The interpreted shape of `PolicyDefinition.rules`. Both forms may be combined:
|
|
33
|
+
* the simple `allow`/`deny` effect lists AND the richer `match` rules. Semantics
|
|
34
|
+
* are deny-wins — see {@link PolicyAuthorizer}.
|
|
35
|
+
*/
|
|
36
|
+
export interface PolicyRules {
|
|
37
|
+
/** Effect ids / globs that are permitted (an allow-list restricts to these). */
|
|
38
|
+
allow?: readonly string[];
|
|
39
|
+
/** Effect ids / globs that are forbidden (deny wins over allow). */
|
|
40
|
+
deny?: readonly string[];
|
|
41
|
+
/** Richer matchers, evaluated in order; deny matches win. */
|
|
42
|
+
match?: readonly PolicyMatchRule[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compile a glob (`*` → `.*`) to an anchored RegExp, escaping every other regex metachar. */
|
|
46
|
+
function globToRegExp(glob: string): RegExp {
|
|
47
|
+
const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
48
|
+
return new RegExp(`^${escaped}$`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A compiled allow/deny list, split so an **exact** id is an O(1) `Set` membership test and
|
|
53
|
+
* only genuine globs fall back to a linear RegExp scan (the common case — a list of concrete
|
|
54
|
+
* effect ids — never touches the glob path). Compiled once at construction; matched per effect.
|
|
55
|
+
*/
|
|
56
|
+
interface PatternSet {
|
|
57
|
+
exact: Set<string>;
|
|
58
|
+
globs: RegExp[];
|
|
59
|
+
/**
|
|
60
|
+
* R12 — entries of the form `<effect>:<resource…>`, keyed by the EXACT effect before the first
|
|
61
|
+
* `:`. Only these may match a request's qualified id when the pattern is an ALLOW: a bare glob
|
|
62
|
+
* (`*.search`) must never widen onto a model/MCP-chosen resource name.
|
|
63
|
+
*/
|
|
64
|
+
qualified: Map<string, PatternSet>;
|
|
65
|
+
}
|
|
66
|
+
function compilePatterns(entries: readonly string[]): PatternSet {
|
|
67
|
+
const exact = new Set<string>();
|
|
68
|
+
const globs: RegExp[] = [];
|
|
69
|
+
const qualified = new Map<string, string[]>();
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.includes("*")) globs.push(globToRegExp(entry));
|
|
72
|
+
else exact.add(entry);
|
|
73
|
+
const colon = entry.indexOf(":");
|
|
74
|
+
if (colon > 0) {
|
|
75
|
+
const prefix = entry.slice(0, colon);
|
|
76
|
+
const list = qualified.get(prefix) ?? [];
|
|
77
|
+
list.push(entry);
|
|
78
|
+
qualified.set(prefix, list);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const q = new Map<string, PatternSet>();
|
|
82
|
+
for (const [prefix, list] of qualified) {
|
|
83
|
+
const e = new Set<string>();
|
|
84
|
+
const g: RegExp[] = [];
|
|
85
|
+
for (const entry of list) {
|
|
86
|
+
if (entry.includes("*")) g.push(globToRegExp(entry));
|
|
87
|
+
else e.add(entry);
|
|
88
|
+
}
|
|
89
|
+
q.set(prefix, { exact: e, globs: g, qualified: new Map() });
|
|
90
|
+
}
|
|
91
|
+
return { exact, globs, qualified: q };
|
|
92
|
+
}
|
|
93
|
+
function patternSetMatches(set: PatternSet, value: string): boolean {
|
|
94
|
+
return set.exact.has(value) || set.globs.some((g) => g.test(value));
|
|
95
|
+
}
|
|
96
|
+
/** DENY: stricter is safe — the bare effect id OR the qualified id may trigger it. */
|
|
97
|
+
function denyMatches(set: PatternSet, req: CapabilityRequest): boolean {
|
|
98
|
+
if (patternSetMatches(set, req.toolId)) return true;
|
|
99
|
+
return req.resource !== undefined && patternSetMatches(set, capabilityId(req));
|
|
100
|
+
}
|
|
101
|
+
/** ALLOW: the bare effect id; the qualified id only through a pattern naming this effect exactly. */
|
|
102
|
+
function allowMatches(set: PatternSet, req: CapabilityRequest): boolean {
|
|
103
|
+
if (patternSetMatches(set, req.toolId)) return true;
|
|
104
|
+
if (req.resource === undefined) return false;
|
|
105
|
+
const q = set.qualified.get(req.toolId);
|
|
106
|
+
return q !== undefined && patternSetMatches(q, capabilityId(req));
|
|
107
|
+
}
|
|
108
|
+
function patternSetEmpty(set: PatternSet): boolean {
|
|
109
|
+
return set.exact.size === 0 && set.globs.length === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CompiledMatch {
|
|
113
|
+
effect?: RegExp;
|
|
114
|
+
/** The exact effect id a qualified (`<effect>:<resource>`) allow pattern names. */
|
|
115
|
+
effectQualifiedFor?: string[];
|
|
116
|
+
tenant?: RegExp;
|
|
117
|
+
step?: RegExp;
|
|
118
|
+
deny: boolean;
|
|
119
|
+
}
|
|
120
|
+
interface CompiledPolicy {
|
|
121
|
+
allow: PatternSet;
|
|
122
|
+
deny: PatternSet;
|
|
123
|
+
match: CompiledMatch[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function asRules(rules: Record<string, unknown>): PolicyRules {
|
|
127
|
+
// `PolicyDefinition.rules` is a freeform bag; read the fields we interpret and
|
|
128
|
+
// ignore the rest (forward-compatible). Non-array fields degrade to empty.
|
|
129
|
+
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
130
|
+
return {
|
|
131
|
+
allow: arr(rules.allow).filter((x): x is string => typeof x === "string"),
|
|
132
|
+
deny: arr(rules.deny).filter((x): x is string => typeof x === "string"),
|
|
133
|
+
match: arr(rules.match).filter(
|
|
134
|
+
(x): x is PolicyMatchRule => typeof x === "object" && x !== null
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function compile(policy: PolicyDefinition): CompiledPolicy {
|
|
140
|
+
const rules = asRules(policy.rules);
|
|
141
|
+
return {
|
|
142
|
+
allow: compilePatterns(rules.allow ?? []),
|
|
143
|
+
deny: compilePatterns(rules.deny ?? []),
|
|
144
|
+
match: (rules.match ?? []).map((m) => ({
|
|
145
|
+
deny: m.deny === true,
|
|
146
|
+
...(m.effect !== undefined && {
|
|
147
|
+
effect: globToRegExp(m.effect),
|
|
148
|
+
...(m.effect.indexOf(":") > 0 && {
|
|
149
|
+
effectQualifiedFor: [m.effect.slice(0, m.effect.indexOf(":"))],
|
|
150
|
+
}),
|
|
151
|
+
}),
|
|
152
|
+
...(m.tenant !== undefined && { tenant: globToRegExp(m.tenant) }),
|
|
153
|
+
...(m.step !== undefined && { step: globToRegExp(m.step) }),
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* R12 — a DENY rule fires on the bare or qualified id; an ALLOW rule on the qualified id only
|
|
160
|
+
* when its own pattern names this effect exactly before the first `:`.
|
|
161
|
+
*/
|
|
162
|
+
function effectMatches(rule: CompiledMatch, req: CapabilityRequest): boolean {
|
|
163
|
+
if (rule.effect === undefined) return true;
|
|
164
|
+
if (rule.effect.test(req.toolId)) return true;
|
|
165
|
+
if (req.resource === undefined) return false;
|
|
166
|
+
if (!rule.deny && !rule.effectQualifiedFor?.includes(req.toolId)) return false;
|
|
167
|
+
return rule.effect.test(capabilityId(req));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A compiled match rule fires when every present field matches the request. */
|
|
171
|
+
function matchFires(rule: CompiledMatch, req: CapabilityRequest): boolean {
|
|
172
|
+
if (rule.effect && !effectMatches(rule, req)) return false;
|
|
173
|
+
if (rule.tenant && !rule.tenant.test(req.principal?.tenant ?? "")) return false;
|
|
174
|
+
if (rule.step && !rule.step.test(req.stepId)) return false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PolicyAuthorizerOptions {
|
|
179
|
+
/**
|
|
180
|
+
* App-global policies applied to EVERY request, on top of the per-principal set.
|
|
181
|
+
* (From `defineConfig({ policies })`.)
|
|
182
|
+
*/
|
|
183
|
+
global?: readonly PolicyDefinition[];
|
|
184
|
+
/**
|
|
185
|
+
* The authorizer this one composes UNDER — an effect is allowed only if the base
|
|
186
|
+
* ALSO allows (defense in depth). If the base denies, that denial is returned
|
|
187
|
+
* verbatim and the policy rules are never consulted.
|
|
188
|
+
*/
|
|
189
|
+
base?: CapabilityAuthorizer;
|
|
190
|
+
/**
|
|
191
|
+
* Which request field identifies the principal whose policies apply. Default: the
|
|
192
|
+
* `workflowId` (a flow is the unit of authority, matching `GrantTable`).
|
|
193
|
+
*/
|
|
194
|
+
principal?: (req: CapabilityRequest) => string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Enforces `policy$` definitions as a {@link CapabilityAuthorizer}. Evaluation over
|
|
199
|
+
* the applicable policies (global ∪ the principal's) is **deny-wins**:
|
|
200
|
+
*
|
|
201
|
+
* 1. any `deny` entry or `deny` match that fires → **denied**;
|
|
202
|
+
* 2. else if an allow-constraint exists (a non-empty `allow` list, or any allow
|
|
203
|
+
* `match` rule) and nothing allows the effect → **denied**;
|
|
204
|
+
* 3. else → **allowed**.
|
|
205
|
+
*
|
|
206
|
+
* A principal with no policies (and no global policies) is allowed — policies are
|
|
207
|
+
* purely additive tightening, so wiring the authorizer never denies an effect that
|
|
208
|
+
* has no policy governing it (the composed `base`, if any, still applies).
|
|
209
|
+
*/
|
|
210
|
+
export class PolicyAuthorizer implements CapabilityAuthorizer {
|
|
211
|
+
private readonly byPrincipal: Map<string, CompiledPolicy[]>;
|
|
212
|
+
private readonly global: CompiledPolicy[];
|
|
213
|
+
private readonly base: CapabilityAuthorizer | undefined;
|
|
214
|
+
private readonly principalOf: (req: CapabilityRequest) => string;
|
|
215
|
+
|
|
216
|
+
constructor(
|
|
217
|
+
policiesByPrincipal: Record<string, readonly PolicyDefinition[]>,
|
|
218
|
+
options: PolicyAuthorizerOptions = {}
|
|
219
|
+
) {
|
|
220
|
+
this.byPrincipal = new Map(
|
|
221
|
+
Object.entries(policiesByPrincipal).map(([k, v]) => [k, v.map(compile)])
|
|
222
|
+
);
|
|
223
|
+
this.global = (options.global ?? []).map(compile);
|
|
224
|
+
this.base = options.base;
|
|
225
|
+
this.principalOf = options.principal ?? ((req) => req.workflowId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
authorize(req: CapabilityRequest): CapabilityDecision {
|
|
229
|
+
// Defense in depth: the base authorizer must allow first; its denial is final.
|
|
230
|
+
if (this.base !== undefined) {
|
|
231
|
+
const baseDecision = this.base.authorize(req);
|
|
232
|
+
if (!baseDecision.allowed) return baseDecision;
|
|
233
|
+
}
|
|
234
|
+
return this.evaluate(req);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private evaluate(req: CapabilityRequest): CapabilityDecision {
|
|
238
|
+
// Scan the global group then the principal's group directly — no merged-array allocation on
|
|
239
|
+
// this per-effect hot path. `state` accumulates the allow-list constraint across both groups.
|
|
240
|
+
const state: AllowState = { constrained: false, allowed: false };
|
|
241
|
+
const denial =
|
|
242
|
+
this.scan(this.global, req, state) ??
|
|
243
|
+
this.scan(this.byPrincipal.get(this.principalOf(req)), req, state);
|
|
244
|
+
if (denial !== undefined) return denial;
|
|
245
|
+
|
|
246
|
+
if (state.constrained && !state.allowed) {
|
|
247
|
+
return {
|
|
248
|
+
allowed: false,
|
|
249
|
+
reason: `effect "${capabilityId(req)}" not permitted by policy allow-list`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { allowed: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Scan one policy group. Returns a denial (deny-wins → final) or `undefined`, mutating `state`
|
|
257
|
+
* with any allow-list constraint seen. `undefined` policies (no group for the principal) no-op.
|
|
258
|
+
*/
|
|
259
|
+
private scan(
|
|
260
|
+
policies: CompiledPolicy[] | undefined,
|
|
261
|
+
req: CapabilityRequest,
|
|
262
|
+
state: AllowState
|
|
263
|
+
): CapabilityDecision | undefined {
|
|
264
|
+
if (policies === undefined) return undefined;
|
|
265
|
+
const deny: CapabilityDecision = {
|
|
266
|
+
allowed: false,
|
|
267
|
+
reason: `effect "${capabilityId(req)}" denied by policy`,
|
|
268
|
+
};
|
|
269
|
+
for (const policy of policies) {
|
|
270
|
+
// (1) deny-wins — an explicit deny list entry.
|
|
271
|
+
if (denyMatches(policy.deny, req)) return deny;
|
|
272
|
+
// (2) allow-list — its presence restricts to matching effects.
|
|
273
|
+
if (!patternSetEmpty(policy.allow)) {
|
|
274
|
+
state.constrained = true;
|
|
275
|
+
if (allowMatches(policy.allow, req)) state.allowed = true;
|
|
276
|
+
}
|
|
277
|
+
// (3) rich match rules — deny fires immediately; allow contributes.
|
|
278
|
+
for (const rule of policy.match) {
|
|
279
|
+
const fires = matchFires(rule, req);
|
|
280
|
+
if (rule.deny) {
|
|
281
|
+
if (fires) return deny;
|
|
282
|
+
} else {
|
|
283
|
+
state.constrained = true;
|
|
284
|
+
if (fires) state.allowed = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Mutable allow-list accumulator threaded across policy groups in one authorization. */
|
|
293
|
+
interface AllowState {
|
|
294
|
+
constrained: boolean;
|
|
295
|
+
allowed: boolean;
|
|
296
|
+
}
|