@intx/inference 0.1.2 → 0.2.2
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 +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +68 -0
- package/dist/assembly.js +132 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +32 -0
- package/dist/authz-extension.js +100 -0
- package/dist/correlation.d.ts +25 -0
- package/dist/correlation.js +32 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +199 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +27 -0
- package/dist/gates.js +80 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1319 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +33 -0
- package/dist/providers/anthropic.js +670 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +3 -0
- package/dist/providers/google-genai.js +1196 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.js +609 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +920 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +117 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +21 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/audit-collector.ts
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
// Audit collector: accumulates tool invocation records for persistence.
|
|
2
|
-
//
|
|
3
|
-
// The collector correlates three data sources into complete AuditRecord
|
|
4
|
-
// objects:
|
|
5
|
-
// 1. tool.start events — tool name and arguments (allowed calls only)
|
|
6
|
-
// 2. AuthzDecision via onDecision — governance decision
|
|
7
|
-
// 3. tool.done events — result and completion metadata
|
|
8
|
-
//
|
|
9
|
-
// Correlation is by callId. For blocked calls, no tool.start is emitted;
|
|
10
|
-
// the collector creates the record from the buffered decision and the
|
|
11
|
-
// tool.done event alone.
|
|
12
|
-
//
|
|
13
|
-
// Wiring: the caller must connect onDecision to the authz extension's
|
|
14
|
-
// onDecision callback, and onEvent to the reactor's event stream. The
|
|
15
|
-
// types alone do not enforce this — it is a composition-layer concern.
|
|
16
|
-
|
|
17
|
-
import type { AuditRecord, AuditAuthz } from "@intx/types/audit";
|
|
18
|
-
import type { InferenceEvent } from "@intx/types/runtime";
|
|
19
|
-
import { getLogger } from "@intx/log";
|
|
20
|
-
import type { AuthzDecision } from "./authz-extension";
|
|
21
|
-
|
|
22
|
-
const logger = getLogger(["interchange", "audit-collector"]);
|
|
23
|
-
|
|
24
|
-
type PendingRecord = {
|
|
25
|
-
callId: string;
|
|
26
|
-
tool: string;
|
|
27
|
-
arguments: Record<string, unknown>;
|
|
28
|
-
authz: AuditAuthz | null;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type AuditCollector = {
|
|
32
|
-
onEvent(event: InferenceEvent): void;
|
|
33
|
-
onDecision(decision: AuthzDecision): void;
|
|
34
|
-
flush(): AuditRecord[];
|
|
35
|
-
pending(): number;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function coerceContent(content: unknown): string | Record<string, unknown> {
|
|
39
|
-
if (typeof content === "string") return content;
|
|
40
|
-
if (typeof content === "object" && content !== null) {
|
|
41
|
-
// content is a non-null object — compatible with Record<string, unknown>
|
|
42
|
-
// but TypeScript can't verify the index signature without a cast.
|
|
43
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-null object is structurally compatible with Record<string, unknown> but TS won't widen
|
|
44
|
-
return content as Record<string, unknown>;
|
|
45
|
-
}
|
|
46
|
-
throw new Error(`Unexpected tool result content type: ${typeof content}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function mapGrant(g: AuthzDecision["matchingGrants"][number]) {
|
|
50
|
-
return {
|
|
51
|
-
id: g.id,
|
|
52
|
-
resource: g.resource,
|
|
53
|
-
action: g.action,
|
|
54
|
-
effect: g.effect,
|
|
55
|
-
origin: g.origin,
|
|
56
|
-
specificity: g.specificity,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function decisionToAuthz(d: AuthzDecision): AuditAuthz {
|
|
61
|
-
return {
|
|
62
|
-
effect: d.effect,
|
|
63
|
-
resolvedBy: d.resolvedBy ? mapGrant(d.resolvedBy) : null,
|
|
64
|
-
matchingGrants: d.matchingGrants.map(mapGrant),
|
|
65
|
-
blocked: d.blocked,
|
|
66
|
-
...(d.blockReason !== undefined ? { blockReason: d.blockReason } : {}),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function createAuditCollector(sessionId: string): AuditCollector {
|
|
71
|
-
const decisions = new Map<string, AuthzDecision>();
|
|
72
|
-
const pendingRecords = new Map<string, PendingRecord>();
|
|
73
|
-
const completed: AuditRecord[] = [];
|
|
74
|
-
|
|
75
|
-
function onDecision(decision: AuthzDecision): void {
|
|
76
|
-
decisions.set(decision.callId, decision);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function onEvent(event: InferenceEvent): void {
|
|
80
|
-
if (event.type === "tool.start") {
|
|
81
|
-
const call = event.data.call;
|
|
82
|
-
const decision = decisions.get(call.id);
|
|
83
|
-
decisions.delete(call.id);
|
|
84
|
-
|
|
85
|
-
pendingRecords.set(call.id, {
|
|
86
|
-
callId: call.id,
|
|
87
|
-
tool: call.name,
|
|
88
|
-
arguments: call.arguments,
|
|
89
|
-
authz: decision ? decisionToAuthz(decision) : null,
|
|
90
|
-
});
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (event.type === "tool.done") {
|
|
95
|
-
const result = event.data.result;
|
|
96
|
-
const pending = pendingRecords.get(result.callId);
|
|
97
|
-
|
|
98
|
-
if (pending) {
|
|
99
|
-
pendingRecords.delete(result.callId);
|
|
100
|
-
completed.push({
|
|
101
|
-
callId: pending.callId,
|
|
102
|
-
tool: pending.tool,
|
|
103
|
-
arguments: pending.arguments,
|
|
104
|
-
authz: pending.authz,
|
|
105
|
-
result: {
|
|
106
|
-
content: coerceContent(result.content),
|
|
107
|
-
isError: result.isError === true,
|
|
108
|
-
},
|
|
109
|
-
timestamp: new Date().toISOString(),
|
|
110
|
-
sessionId,
|
|
111
|
-
seq: event.seq,
|
|
112
|
-
});
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Blocked call: no tool.start was emitted. Build the record from
|
|
117
|
-
// the buffered decision and the tool.done event.
|
|
118
|
-
const decision = decisions.get(result.callId);
|
|
119
|
-
if (decision === undefined) {
|
|
120
|
-
// Orphaned tool.done: no tool.start or authz decision was recorded.
|
|
121
|
-
// Emit a degraded record rather than crashing the session — the audit
|
|
122
|
-
// system is observational infrastructure and must not veto execution.
|
|
123
|
-
|
|
124
|
-
logger.warn`Orphaned tool.done for callId "${result.callId}": no tool.start or authz decision was recorded`;
|
|
125
|
-
completed.push({
|
|
126
|
-
callId: result.callId,
|
|
127
|
-
tool: "$orphaned",
|
|
128
|
-
arguments: {},
|
|
129
|
-
authz: null,
|
|
130
|
-
result: {
|
|
131
|
-
content: coerceContent(result.content),
|
|
132
|
-
isError: result.isError === true,
|
|
133
|
-
},
|
|
134
|
-
timestamp: new Date().toISOString(),
|
|
135
|
-
sessionId,
|
|
136
|
-
seq: event.seq,
|
|
137
|
-
});
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
decisions.delete(result.callId);
|
|
141
|
-
|
|
142
|
-
completed.push({
|
|
143
|
-
callId: result.callId,
|
|
144
|
-
tool: decision.tool,
|
|
145
|
-
arguments: {},
|
|
146
|
-
authz: decisionToAuthz(decision),
|
|
147
|
-
result: {
|
|
148
|
-
content: coerceContent(result.content),
|
|
149
|
-
isError: result.isError === true,
|
|
150
|
-
},
|
|
151
|
-
timestamp: new Date().toISOString(),
|
|
152
|
-
sessionId,
|
|
153
|
-
seq: event.seq,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function flush(): AuditRecord[] {
|
|
159
|
-
return completed.splice(0);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function pendingCount(): number {
|
|
163
|
-
return pendingRecords.size + decisions.size;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
onEvent,
|
|
168
|
-
onDecision,
|
|
169
|
-
flush,
|
|
170
|
-
pending: pendingCount,
|
|
171
|
-
};
|
|
172
|
-
}
|
package/src/auth.test.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
// Credential-sentinel substitution. Adapters declare which credential
|
|
2
|
-
// shape they want by placing one of the exported sentinel strings as
|
|
3
|
-
// the header value; `injectCredentials` walks the header map and
|
|
4
|
-
// rewrites exact-match values with material derived from
|
|
5
|
-
// `InferenceSource.apiKey`. The harness uses this in place of the
|
|
6
|
-
// previous per-header hardcoded branches so adding a new provider
|
|
7
|
-
// requires no harness change.
|
|
8
|
-
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
10
|
-
|
|
11
|
-
import type { InferenceSource } from "@intx/types/runtime";
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
BEARER_CREDENTIAL_SENTINEL,
|
|
15
|
-
CREDENTIAL_SENTINEL,
|
|
16
|
-
injectCredentials,
|
|
17
|
-
} from "./auth";
|
|
18
|
-
|
|
19
|
-
const SOURCE: InferenceSource = {
|
|
20
|
-
id: "test:model",
|
|
21
|
-
provider: "test",
|
|
22
|
-
baseURL: "https://test.invalid",
|
|
23
|
-
apiKey: "sk-test-secret",
|
|
24
|
-
model: "test-model",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
describe("injectCredentials", () => {
|
|
28
|
-
test("replaces CREDENTIAL_SENTINEL with apiKey verbatim", () => {
|
|
29
|
-
const out = injectCredentials(
|
|
30
|
-
{
|
|
31
|
-
"x-api-key": CREDENTIAL_SENTINEL,
|
|
32
|
-
"content-type": "application/json",
|
|
33
|
-
},
|
|
34
|
-
SOURCE,
|
|
35
|
-
);
|
|
36
|
-
expect(out["x-api-key"]).toBe("sk-test-secret");
|
|
37
|
-
expect(out["content-type"]).toBe("application/json");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("replaces BEARER_CREDENTIAL_SENTINEL with Bearer-prefixed apiKey", () => {
|
|
41
|
-
const out = injectCredentials(
|
|
42
|
-
{
|
|
43
|
-
authorization: BEARER_CREDENTIAL_SENTINEL,
|
|
44
|
-
"content-type": "application/json",
|
|
45
|
-
},
|
|
46
|
-
SOURCE,
|
|
47
|
-
);
|
|
48
|
-
expect(out["authorization"]).toBe("Bearer sk-test-secret");
|
|
49
|
-
expect(out["content-type"]).toBe("application/json");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("non-sentinel values pass through unchanged", () => {
|
|
53
|
-
const out = injectCredentials(
|
|
54
|
-
{
|
|
55
|
-
"content-type": "application/json",
|
|
56
|
-
"anthropic-version": "2023-06-01",
|
|
57
|
-
"user-agent": "test",
|
|
58
|
-
},
|
|
59
|
-
SOURCE,
|
|
60
|
-
);
|
|
61
|
-
expect(out["content-type"]).toBe("application/json");
|
|
62
|
-
expect(out["anthropic-version"]).toBe("2023-06-01");
|
|
63
|
-
expect(out["user-agent"]).toBe("test");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("replaces sentinels regardless of header name (new providers need no harness change)", () => {
|
|
67
|
-
// The whole point of sentinel-based replacement: a brand-new
|
|
68
|
-
// provider that uses, say, `x-goog-api-key` works without
|
|
69
|
-
// touching this function. The exact header name is irrelevant
|
|
70
|
-
// to the substitution logic.
|
|
71
|
-
const out = injectCredentials(
|
|
72
|
-
{ "x-goog-api-key": CREDENTIAL_SENTINEL },
|
|
73
|
-
SOURCE,
|
|
74
|
-
);
|
|
75
|
-
expect(out["x-goog-api-key"]).toBe("sk-test-secret");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("substring matches are not replaced (exact match only)", () => {
|
|
79
|
-
// A header value that *contains* the sentinel literal as a
|
|
80
|
-
// substring -- but isn't exactly equal to it -- is left alone.
|
|
81
|
-
// Partial replacement would be surprising and no legitimate
|
|
82
|
-
// adapter constructs composite values around the sentinel.
|
|
83
|
-
const wrapped = `prefix ${CREDENTIAL_SENTINEL} suffix`;
|
|
84
|
-
const out = injectCredentials({ "x-weird-header": wrapped }, SOURCE);
|
|
85
|
-
expect(out["x-weird-header"]).toBe(wrapped);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("returns a new object and does not mutate the input", () => {
|
|
89
|
-
const input: Record<string, string> = {
|
|
90
|
-
"x-api-key": CREDENTIAL_SENTINEL,
|
|
91
|
-
};
|
|
92
|
-
const out = injectCredentials(input, SOURCE);
|
|
93
|
-
expect(input["x-api-key"]).toBe(CREDENTIAL_SENTINEL);
|
|
94
|
-
expect(out["x-api-key"]).toBe("sk-test-secret");
|
|
95
|
-
expect(out).not.toBe(input);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("handles multiple sentinels of mixed shapes in one request", () => {
|
|
99
|
-
// Pathological but well-defined: an adapter that wants both
|
|
100
|
-
// a verbatim credential header and a Bearer header (some
|
|
101
|
-
// vendors do this for legacy + modern endpoints) gets both
|
|
102
|
-
// replacements applied in one pass.
|
|
103
|
-
const out = injectCredentials(
|
|
104
|
-
{
|
|
105
|
-
"x-api-key": CREDENTIAL_SENTINEL,
|
|
106
|
-
authorization: BEARER_CREDENTIAL_SENTINEL,
|
|
107
|
-
},
|
|
108
|
-
SOURCE,
|
|
109
|
-
);
|
|
110
|
-
expect(out["x-api-key"]).toBe("sk-test-secret");
|
|
111
|
-
expect(out["authorization"]).toBe("Bearer sk-test-secret");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("empty headers in, empty headers out", () => {
|
|
115
|
-
expect(injectCredentials({}, SOURCE)).toEqual({});
|
|
116
|
-
});
|
|
117
|
-
});
|
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createAuthzExtension,
|
|
5
|
-
type AuthzCallResult,
|
|
6
|
-
type AuthzDecision,
|
|
7
|
-
} from "./authz-extension";
|
|
8
|
-
|
|
9
|
-
import type { ToolCall, ReactorState, TokenUsage } from "@intx/types/runtime";
|
|
10
|
-
|
|
11
|
-
function emptyUsage(): TokenUsage {
|
|
12
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function makeState(): ReactorState {
|
|
16
|
-
return {
|
|
17
|
-
sessionId: "test",
|
|
18
|
-
turns: [],
|
|
19
|
-
activeForks: [],
|
|
20
|
-
pendingOperations: [],
|
|
21
|
-
activeGates: [],
|
|
22
|
-
tokenUsage: emptyUsage(),
|
|
23
|
-
lastCycleUsage: null,
|
|
24
|
-
lastCycleSource: null,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function makeCall(name = "bash"): ToolCall {
|
|
29
|
-
return { id: "call-1", name, arguments: {} };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function getDecision(decisions: AuthzDecision[]): AuthzDecision {
|
|
33
|
-
const d = decisions[0];
|
|
34
|
-
if (d === undefined) throw new Error("no decisions recorded");
|
|
35
|
-
return d;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function allowResult(): AuthzCallResult {
|
|
39
|
-
return {
|
|
40
|
-
effect: "allow",
|
|
41
|
-
matchingGrants: [
|
|
42
|
-
{
|
|
43
|
-
id: "grant-1",
|
|
44
|
-
resource: "tool:bash",
|
|
45
|
-
action: "invoke",
|
|
46
|
-
effect: "allow",
|
|
47
|
-
origin: "creator",
|
|
48
|
-
specificity: 1009,
|
|
49
|
-
},
|
|
50
|
-
],
|
|
51
|
-
resolvedBy: {
|
|
52
|
-
id: "grant-1",
|
|
53
|
-
resource: "tool:bash",
|
|
54
|
-
action: "invoke",
|
|
55
|
-
effect: "allow",
|
|
56
|
-
origin: "creator",
|
|
57
|
-
specificity: 1009,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function denyResult(): AuthzCallResult {
|
|
63
|
-
return {
|
|
64
|
-
effect: "deny",
|
|
65
|
-
matchingGrants: [
|
|
66
|
-
{
|
|
67
|
-
id: "grant-2",
|
|
68
|
-
resource: "tool:bash",
|
|
69
|
-
action: "invoke",
|
|
70
|
-
effect: "deny",
|
|
71
|
-
origin: "system",
|
|
72
|
-
specificity: 1009,
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
resolvedBy: {
|
|
76
|
-
id: "grant-2",
|
|
77
|
-
resource: "tool:bash",
|
|
78
|
-
action: "invoke",
|
|
79
|
-
effect: "deny",
|
|
80
|
-
origin: "system",
|
|
81
|
-
specificity: 1009,
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
describe("createAuthzExtension", () => {
|
|
87
|
-
const signal = new AbortController().signal;
|
|
88
|
-
|
|
89
|
-
test("allow effect returns undefined and calls onDecision", async () => {
|
|
90
|
-
const decisions: AuthzDecision[] = [];
|
|
91
|
-
|
|
92
|
-
const ext = createAuthzExtension({
|
|
93
|
-
authorize: async () => allowResult(),
|
|
94
|
-
onDecision: (d) => decisions.push(d),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
98
|
-
|
|
99
|
-
expect(result).toBeUndefined();
|
|
100
|
-
const d = getDecision(decisions);
|
|
101
|
-
expect(d.callId).toBe("call-1");
|
|
102
|
-
expect(d.effect).toBe("allow");
|
|
103
|
-
expect(d.blocked).toBe(false);
|
|
104
|
-
expect(d.blockReason).toBeUndefined();
|
|
105
|
-
expect(d.tool).toBe("bash");
|
|
106
|
-
expect(d.resource).toBe("tool:bash");
|
|
107
|
-
expect(d.action).toBe("invoke");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("deny effect returns block reason and calls onDecision", async () => {
|
|
111
|
-
const decisions: AuthzDecision[] = [];
|
|
112
|
-
|
|
113
|
-
const ext = createAuthzExtension({
|
|
114
|
-
authorize: async () => denyResult(),
|
|
115
|
-
onDecision: (d) => decisions.push(d),
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
119
|
-
|
|
120
|
-
expect(result).toBe("Denied by policy: tool:bash/invoke");
|
|
121
|
-
const d = getDecision(decisions);
|
|
122
|
-
expect(d.effect).toBe("deny");
|
|
123
|
-
expect(d.blocked).toBe(true);
|
|
124
|
-
expect(d.blockReason).toBe("Denied by policy: tool:bash/invoke");
|
|
125
|
-
expect(d.resolvedBy).toBeDefined();
|
|
126
|
-
if (d.resolvedBy === null) throw new Error("expected resolvedBy");
|
|
127
|
-
expect(d.resolvedBy.id).toBe("grant-2");
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("ask effect blocks with approval message", async () => {
|
|
131
|
-
const decisions: AuthzDecision[] = [];
|
|
132
|
-
|
|
133
|
-
const ext = createAuthzExtension({
|
|
134
|
-
authorize: async () => ({
|
|
135
|
-
effect: "ask" as const,
|
|
136
|
-
matchingGrants: [],
|
|
137
|
-
resolvedBy: null,
|
|
138
|
-
}),
|
|
139
|
-
onDecision: (d) => decisions.push(d),
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
143
|
-
|
|
144
|
-
expect(result).toBe("Requires approval: tool:bash/invoke");
|
|
145
|
-
const d = getDecision(decisions);
|
|
146
|
-
expect(d.effect).toBe("ask");
|
|
147
|
-
expect(d.blocked).toBe(true);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("null effect (no matching grants) blocks fail-closed", async () => {
|
|
151
|
-
const decisions: AuthzDecision[] = [];
|
|
152
|
-
|
|
153
|
-
const ext = createAuthzExtension({
|
|
154
|
-
authorize: async () => ({
|
|
155
|
-
effect: null,
|
|
156
|
-
matchingGrants: [],
|
|
157
|
-
resolvedBy: null,
|
|
158
|
-
}),
|
|
159
|
-
onDecision: (d) => decisions.push(d),
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
163
|
-
|
|
164
|
-
expect(result).toBe("No matching grants for tool:bash/invoke");
|
|
165
|
-
const d = getDecision(decisions);
|
|
166
|
-
expect(d.effect).toBeNull();
|
|
167
|
-
expect(d.blocked).toBe(true);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("authorize throwing calls onDecision with error and rethrows", async () => {
|
|
171
|
-
const decisions: AuthzDecision[] = [];
|
|
172
|
-
|
|
173
|
-
const ext = createAuthzExtension({
|
|
174
|
-
authorize: async () => {
|
|
175
|
-
throw new Error("DB connection failed");
|
|
176
|
-
},
|
|
177
|
-
onDecision: (d) => decisions.push(d),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
let thrown: Error | undefined;
|
|
181
|
-
try {
|
|
182
|
-
await ext.beforeTool(makeCall(), makeState(), signal);
|
|
183
|
-
} catch (cause) {
|
|
184
|
-
thrown = cause instanceof Error ? cause : new Error(String(cause));
|
|
185
|
-
}
|
|
186
|
-
expect(thrown?.message).toBe("DB connection failed");
|
|
187
|
-
|
|
188
|
-
const d = getDecision(decisions);
|
|
189
|
-
expect(d.callId).toBe("call-1");
|
|
190
|
-
expect(d.blocked).toBe(true);
|
|
191
|
-
expect(d.error).toBe("DB connection failed");
|
|
192
|
-
expect(d.blockReason).toBe("Authorization failed: DB connection failed");
|
|
193
|
-
expect(d.effect).toBeNull();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("onDecision is optional", async () => {
|
|
197
|
-
const ext = createAuthzExtension({
|
|
198
|
-
authorize: async () => allowResult(),
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
202
|
-
expect(result).toBeUndefined();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("resource format uses tool:{name}", async () => {
|
|
206
|
-
let capturedResource = "";
|
|
207
|
-
|
|
208
|
-
const ext = createAuthzExtension({
|
|
209
|
-
authorize: async (resource) => {
|
|
210
|
-
capturedResource = resource;
|
|
211
|
-
return allowResult();
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
await ext.beforeTool(makeCall("stripe_charge"), makeState(), signal);
|
|
216
|
-
expect(capturedResource).toBe("tool:stripe_charge");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("matchingGrants are passed through to decision", async () => {
|
|
220
|
-
const decisions: AuthzDecision[] = [];
|
|
221
|
-
const expectedGrants = denyResult().matchingGrants;
|
|
222
|
-
|
|
223
|
-
const ext = createAuthzExtension({
|
|
224
|
-
authorize: async () => denyResult(),
|
|
225
|
-
onDecision: (d) => decisions.push(d),
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
await ext.beforeTool(makeCall(), makeState(), signal);
|
|
229
|
-
|
|
230
|
-
const d = getDecision(decisions);
|
|
231
|
-
expect(d.matchingGrants.length).toBe(expectedGrants.length);
|
|
232
|
-
const firstGrant = d.matchingGrants[0];
|
|
233
|
-
const expectedFirst = expectedGrants[0];
|
|
234
|
-
if (firstGrant === undefined || expectedFirst === undefined)
|
|
235
|
-
throw new Error("expected at least one grant");
|
|
236
|
-
expect(firstGrant.id).toBe(expectedFirst.id);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test("onDecision throwing does not affect allow decision", async () => {
|
|
240
|
-
const ext = createAuthzExtension({
|
|
241
|
-
authorize: async () => allowResult(),
|
|
242
|
-
onDecision: () => {
|
|
243
|
-
throw new Error("audit log failed");
|
|
244
|
-
},
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
const result = await ext.beforeTool(makeCall(), makeState(), signal);
|
|
248
|
-
expect(result).toBeUndefined();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("onDecision throwing does not mask authorize error", async () => {
|
|
252
|
-
const ext = createAuthzExtension({
|
|
253
|
-
authorize: async () => {
|
|
254
|
-
throw new Error("DB connection failed");
|
|
255
|
-
},
|
|
256
|
-
onDecision: () => {
|
|
257
|
-
throw new Error("audit log failed");
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
let thrown: Error | undefined;
|
|
262
|
-
try {
|
|
263
|
-
await ext.beforeTool(makeCall(), makeState(), signal);
|
|
264
|
-
} catch (cause) {
|
|
265
|
-
thrown = cause instanceof Error ? cause : new Error(String(cause));
|
|
266
|
-
}
|
|
267
|
-
expect(thrown?.message).toBe("DB connection failed");
|
|
268
|
-
});
|
|
269
|
-
});
|
package/src/authz-extension.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// Authz-based BeforeToolExtension.
|
|
2
|
-
//
|
|
3
|
-
// Creates an extension that authorizes tool calls against a policy before
|
|
4
|
-
// execution. The caller provides a pre-bound authorize function that
|
|
5
|
-
// encapsulates store, principal, tenant, and condition registry details.
|
|
6
|
-
//
|
|
7
|
-
// Effects:
|
|
8
|
-
// allow → tool proceeds
|
|
9
|
-
// deny → tool blocked
|
|
10
|
-
// ask → tool blocked (gate-based approval deferred to a future commit)
|
|
11
|
-
// null → tool blocked (fail-closed: no grants matched)
|
|
12
|
-
//
|
|
13
|
-
// The action is always "invoke" — all tool calls are invocations. If
|
|
14
|
-
// additional action granularity is needed later, the action becomes a
|
|
15
|
-
// parameter.
|
|
16
|
-
//
|
|
17
|
-
// Signal propagation into the authorize function is deferred — the caller
|
|
18
|
-
// can capture the signal in their closure if cancellation is needed.
|
|
19
|
-
//
|
|
20
|
-
// The onDecision callback must not throw. If it does, the exception is
|
|
21
|
-
// logged but swallowed so it cannot interfere with the authorization
|
|
22
|
-
// decision or mask the original error.
|
|
23
|
-
|
|
24
|
-
import type { BeforeToolExtension } from "@intx/types/runtime";
|
|
25
|
-
import type { Effect } from "@intx/types/authz";
|
|
26
|
-
|
|
27
|
-
export type AuthzMatchedGrant = {
|
|
28
|
-
id: string;
|
|
29
|
-
resource: string;
|
|
30
|
-
action: string;
|
|
31
|
-
effect: Effect;
|
|
32
|
-
origin: "system" | "role" | "creator" | "invoker";
|
|
33
|
-
specificity: number;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export type AuthzCallResult = {
|
|
37
|
-
effect: Effect | null;
|
|
38
|
-
matchingGrants: AuthzMatchedGrant[];
|
|
39
|
-
resolvedBy: AuthzMatchedGrant | null;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export type AuthzDecision = {
|
|
43
|
-
callId: string;
|
|
44
|
-
tool: string;
|
|
45
|
-
resource: string;
|
|
46
|
-
action: string;
|
|
47
|
-
effect: Effect | null;
|
|
48
|
-
resolvedBy: AuthzMatchedGrant | null;
|
|
49
|
-
matchingGrants: AuthzMatchedGrant[];
|
|
50
|
-
blocked: boolean;
|
|
51
|
-
blockReason: string | undefined;
|
|
52
|
-
error: string | undefined;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export type AuthzExtensionOptions = {
|
|
56
|
-
authorize: (resource: string, action: string) => Promise<AuthzCallResult>;
|
|
57
|
-
onDecision?: (decision: AuthzDecision) => void;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
type BlockEffect = "deny" | "ask" | null;
|
|
61
|
-
|
|
62
|
-
function formatBlockReason(
|
|
63
|
-
effect: BlockEffect,
|
|
64
|
-
resource: string,
|
|
65
|
-
action: string,
|
|
66
|
-
): string {
|
|
67
|
-
switch (effect) {
|
|
68
|
-
case "deny":
|
|
69
|
-
return `Denied by policy: ${resource}/${action}`;
|
|
70
|
-
case "ask":
|
|
71
|
-
return `Requires approval: ${resource}/${action}`;
|
|
72
|
-
case null:
|
|
73
|
-
return `No matching grants for ${resource}/${action}`;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function safeOnDecision(
|
|
78
|
-
callback: ((decision: AuthzDecision) => void) | undefined,
|
|
79
|
-
decision: AuthzDecision,
|
|
80
|
-
): void {
|
|
81
|
-
if (callback === undefined) return;
|
|
82
|
-
try {
|
|
83
|
-
callback(decision);
|
|
84
|
-
} catch {
|
|
85
|
-
// onDecision must not throw. If it does, swallow the exception so
|
|
86
|
-
// it cannot interfere with the authorization decision or mask the
|
|
87
|
-
// original error from authorize().
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function createAuthzExtension(
|
|
92
|
-
opts: AuthzExtensionOptions,
|
|
93
|
-
): BeforeToolExtension {
|
|
94
|
-
return {
|
|
95
|
-
async beforeTool(call) {
|
|
96
|
-
const resource = `tool:${call.name}`;
|
|
97
|
-
const action = "invoke";
|
|
98
|
-
|
|
99
|
-
let result: AuthzCallResult;
|
|
100
|
-
try {
|
|
101
|
-
result = await opts.authorize(resource, action);
|
|
102
|
-
} catch (cause) {
|
|
103
|
-
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
104
|
-
const decision: AuthzDecision = {
|
|
105
|
-
callId: call.id,
|
|
106
|
-
tool: call.name,
|
|
107
|
-
resource,
|
|
108
|
-
action,
|
|
109
|
-
effect: null,
|
|
110
|
-
resolvedBy: null,
|
|
111
|
-
matchingGrants: [],
|
|
112
|
-
blocked: true,
|
|
113
|
-
blockReason: `Authorization failed: ${msg}`,
|
|
114
|
-
error: msg,
|
|
115
|
-
};
|
|
116
|
-
safeOnDecision(opts.onDecision, decision);
|
|
117
|
-
throw cause;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const blocked = result.effect !== "allow";
|
|
121
|
-
const blockReason =
|
|
122
|
-
result.effect === "deny" ||
|
|
123
|
-
result.effect === "ask" ||
|
|
124
|
-
result.effect === null
|
|
125
|
-
? formatBlockReason(result.effect, resource, action)
|
|
126
|
-
: undefined;
|
|
127
|
-
|
|
128
|
-
const decision: AuthzDecision = {
|
|
129
|
-
callId: call.id,
|
|
130
|
-
tool: call.name,
|
|
131
|
-
resource,
|
|
132
|
-
action,
|
|
133
|
-
effect: result.effect,
|
|
134
|
-
resolvedBy: result.resolvedBy,
|
|
135
|
-
matchingGrants: result.matchingGrants,
|
|
136
|
-
blocked,
|
|
137
|
-
blockReason,
|
|
138
|
-
error: undefined,
|
|
139
|
-
};
|
|
140
|
-
safeOnDecision(opts.onDecision, decision);
|
|
141
|
-
|
|
142
|
-
return blockReason;
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
}
|