@intx/inference 0.1.2 → 0.3.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 +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +40 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +75 -0
- package/dist/assembly.js +133 -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 +46 -0
- package/dist/authz-extension.js +184 -0
- package/dist/correlation.d.ts +26 -0
- package/dist/correlation.js +39 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +228 -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 +28 -0
- package/dist/gates.js +103 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1407 -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 +37 -0
- package/dist/providers/anthropic.js +917 -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 +5 -0
- package/dist/providers/google-genai.js +1205 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.js +903 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +1233 -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 +132 -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 +22 -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/dist/assembly.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Reactor assembly helper.
|
|
2
|
+
//
|
|
3
|
+
// `createReactorAssembly` is the canonical way to construct a reactor when the
|
|
4
|
+
// caller wants the standard wiring: a default size-cap tool-result transform,
|
|
5
|
+
// authz as a before-tool extension, an audit collector that flushes at
|
|
6
|
+
// checkpoint and shutdown boundaries, and a `BlobReader` over the supplied
|
|
7
|
+
// context store. Future reactor consumers (the harness today; in-process agent
|
|
8
|
+
// runtimes tomorrow) should use this helper rather than calling `createReactor`
|
|
9
|
+
// directly so the wiring stays consistent across composition points.
|
|
10
|
+
import { getLogger } from "@intx/log";
|
|
11
|
+
import { createBlobReader, } from "@intx/types/runtime";
|
|
12
|
+
import { createAuditCollector } from "./audit-collector.js";
|
|
13
|
+
import { createAuthzExtension, } from "./authz-extension.js";
|
|
14
|
+
import { createReactor, } from "./reactor.js";
|
|
15
|
+
import { createSizeCapTransform } from "./transforms/index.js";
|
|
16
|
+
const logger = getLogger(["interchange", "assembly"]);
|
|
17
|
+
const DEFAULT_SIZE_CAP_MAX_CHARS = 10_000;
|
|
18
|
+
/**
|
|
19
|
+
* Build the standard reactor wiring. This is the canonical reactor-assembly
|
|
20
|
+
* path: any consumer that needs the default size-cap transform, authz, audit
|
|
21
|
+
* collection, and blob reader should call this helper instead of constructing
|
|
22
|
+
* a `ReactorConfig` by hand. Direct `createReactor` use is reserved for
|
|
23
|
+
* reactor-internal tests and any future consumer that genuinely needs a
|
|
24
|
+
* different composition.
|
|
25
|
+
*/
|
|
26
|
+
export function createReactorAssembly(config) {
|
|
27
|
+
const { sessionId, director, source, failOverToNextSource, resetToPreferredSource, toolRunner, contextStore, onEvent, authorize, toolDefinitions, auditStore, beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, contextTransforms, compactors, sizeCapMaxChars, afterCheckpoint: callerAfterCheckpoint, onShutdown: callerOnShutdown, deps, correlationValidator, inferenceRunner, gateTimeout, shutdownTimeoutMs, } = config;
|
|
28
|
+
// Audit collector is created up-front so the authz extension can route its
|
|
29
|
+
// decisions through `onDecision`. When no auditStore is supplied, no
|
|
30
|
+
// collector is created and authz runs without decision recording.
|
|
31
|
+
const auditCollector = auditStore !== undefined ? createAuditCollector(sessionId) : undefined;
|
|
32
|
+
// When an audit collector is present, intercept the reactor's event stream
|
|
33
|
+
// to feed it tool.start / tool.done events (the collector correlates these
|
|
34
|
+
// with authz decisions by callId). message.received is reactor-internal and
|
|
35
|
+
// is forwarded to the caller but not to the collector. Without a collector,
|
|
36
|
+
// the caller's onEvent is used directly.
|
|
37
|
+
const composedOnEvent = auditCollector !== undefined
|
|
38
|
+
? (event) => {
|
|
39
|
+
if (event.type !== "message.received") {
|
|
40
|
+
auditCollector.onEvent(event);
|
|
41
|
+
}
|
|
42
|
+
onEvent(event);
|
|
43
|
+
}
|
|
44
|
+
: onEvent;
|
|
45
|
+
// Authz is composed in front of any caller-supplied before-tool extensions
|
|
46
|
+
// so policy enforcement runs first. Without authz, the caller's list (if
|
|
47
|
+
// any) is passed through unchanged.
|
|
48
|
+
const authzExtension = authorize !== undefined
|
|
49
|
+
? createAuthzExtension({
|
|
50
|
+
authorize,
|
|
51
|
+
...(auditCollector !== undefined
|
|
52
|
+
? { onDecision: (d) => auditCollector.onDecision(d) }
|
|
53
|
+
: {}),
|
|
54
|
+
...(toolDefinitions !== undefined ? { toolDefinitions } : {}),
|
|
55
|
+
})
|
|
56
|
+
: undefined;
|
|
57
|
+
const composedBeforeToolExtensions = authzExtension !== undefined
|
|
58
|
+
? [authzExtension, ...(callerBeforeToolExtensions ?? [])]
|
|
59
|
+
: callerBeforeToolExtensions;
|
|
60
|
+
// The size-cap transform is always prepended so oversized payloads spill
|
|
61
|
+
// before any caller transform sees them. Caller transforms run after and
|
|
62
|
+
// can rely on the inline content already being bounded.
|
|
63
|
+
const sizeCapTransform = createSizeCapTransform({
|
|
64
|
+
maxChars: sizeCapMaxChars ?? DEFAULT_SIZE_CAP_MAX_CHARS,
|
|
65
|
+
contextStore,
|
|
66
|
+
});
|
|
67
|
+
const composedToolResultTransforms = [
|
|
68
|
+
sizeCapTransform,
|
|
69
|
+
...(callerToolResultTransforms ?? []),
|
|
70
|
+
];
|
|
71
|
+
// Audit flush wraps the caller's lifecycle hooks: the helper's flush runs
|
|
72
|
+
// first so the records produced by the just-completed cycle are persisted
|
|
73
|
+
// before the caller's hook observes the checkpoint or shutdown boundary.
|
|
74
|
+
async function flushAudit() {
|
|
75
|
+
if (auditCollector === undefined || auditStore === undefined)
|
|
76
|
+
return;
|
|
77
|
+
const records = auditCollector.flush();
|
|
78
|
+
if (records.length > 0) {
|
|
79
|
+
await auditStore.commitAudit(records);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const composedAfterCheckpoint = auditCollector !== undefined
|
|
83
|
+
? async () => {
|
|
84
|
+
await flushAudit();
|
|
85
|
+
if (callerAfterCheckpoint !== undefined) {
|
|
86
|
+
await callerAfterCheckpoint();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
: callerAfterCheckpoint;
|
|
90
|
+
const composedOnShutdown = auditCollector !== undefined
|
|
91
|
+
? async () => {
|
|
92
|
+
const inflight = auditCollector.pending();
|
|
93
|
+
if (inflight > 0) {
|
|
94
|
+
logger.warn `${inflight} audit records in flight at shutdown, these tool calls will not be recorded`;
|
|
95
|
+
}
|
|
96
|
+
await flushAudit();
|
|
97
|
+
if (callerOnShutdown !== undefined) {
|
|
98
|
+
await callerOnShutdown();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
: callerOnShutdown;
|
|
102
|
+
// exactOptionalPropertyTypes is on: only set optional keys when defined.
|
|
103
|
+
const reactorConfig = {
|
|
104
|
+
sessionId,
|
|
105
|
+
director,
|
|
106
|
+
source,
|
|
107
|
+
...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}),
|
|
108
|
+
...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}),
|
|
109
|
+
toolRunner,
|
|
110
|
+
contextStore,
|
|
111
|
+
onEvent: composedOnEvent,
|
|
112
|
+
deps,
|
|
113
|
+
toolResultTransforms: composedToolResultTransforms,
|
|
114
|
+
...(composedBeforeToolExtensions !== undefined
|
|
115
|
+
? { beforeToolExtensions: composedBeforeToolExtensions }
|
|
116
|
+
: {}),
|
|
117
|
+
...(contextTransforms !== undefined ? { contextTransforms } : {}),
|
|
118
|
+
...(compactors !== undefined ? { compactors } : {}),
|
|
119
|
+
...(composedAfterCheckpoint !== undefined
|
|
120
|
+
? { afterCheckpoint: composedAfterCheckpoint }
|
|
121
|
+
: {}),
|
|
122
|
+
...(composedOnShutdown !== undefined
|
|
123
|
+
? { onShutdown: composedOnShutdown }
|
|
124
|
+
: {}),
|
|
125
|
+
...(correlationValidator !== undefined ? { correlationValidator } : {}),
|
|
126
|
+
...(inferenceRunner !== undefined ? { inferenceRunner } : {}),
|
|
127
|
+
...(gateTimeout !== undefined ? { gateTimeout } : {}),
|
|
128
|
+
...(shutdownTimeoutMs !== undefined ? { shutdownTimeoutMs } : {}),
|
|
129
|
+
};
|
|
130
|
+
const reactor = createReactor(reactorConfig);
|
|
131
|
+
const blobReader = createBlobReader(contextStore);
|
|
132
|
+
return { reactor, blobReader, auditCollector };
|
|
133
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuditRecord } from "@intx/types/audit";
|
|
2
|
+
import type { InferenceEvent } from "@intx/types/runtime";
|
|
3
|
+
import type { AuthzDecision } from "./authz-extension.js";
|
|
4
|
+
export type AuditCollector = {
|
|
5
|
+
onEvent(event: InferenceEvent): void;
|
|
6
|
+
onDecision(decision: AuthzDecision): void;
|
|
7
|
+
flush(): AuditRecord[];
|
|
8
|
+
pending(): number;
|
|
9
|
+
};
|
|
10
|
+
export declare function createAuditCollector(sessionId: string): AuditCollector;
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
import { getLogger } from "@intx/log";
|
|
17
|
+
const logger = getLogger(["interchange", "audit-collector"]);
|
|
18
|
+
function coerceContent(content) {
|
|
19
|
+
if (typeof content === "string")
|
|
20
|
+
return content;
|
|
21
|
+
if (typeof content === "object" && content !== null) {
|
|
22
|
+
// content is a non-null object — compatible with Record<string, unknown>
|
|
23
|
+
// but TypeScript can't verify the index signature without a cast.
|
|
24
|
+
// 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
|
|
25
|
+
return content;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Unexpected tool result content type: ${typeof content}`);
|
|
28
|
+
}
|
|
29
|
+
function mapGrant(g) {
|
|
30
|
+
return {
|
|
31
|
+
id: g.id,
|
|
32
|
+
resource: g.resource,
|
|
33
|
+
action: g.action,
|
|
34
|
+
effect: g.effect,
|
|
35
|
+
origin: g.origin,
|
|
36
|
+
specificity: g.specificity,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function decisionToAuthz(d) {
|
|
40
|
+
return {
|
|
41
|
+
effect: d.effect,
|
|
42
|
+
resolvedBy: d.resolvedBy ? mapGrant(d.resolvedBy) : null,
|
|
43
|
+
matchingGrants: d.matchingGrants.map(mapGrant),
|
|
44
|
+
blocked: d.blocked,
|
|
45
|
+
...(d.blockReason !== undefined ? { blockReason: d.blockReason } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function createAuditCollector(sessionId) {
|
|
49
|
+
const decisions = new Map();
|
|
50
|
+
const pendingRecords = new Map();
|
|
51
|
+
const completed = [];
|
|
52
|
+
function onDecision(decision) {
|
|
53
|
+
decisions.set(decision.callId, decision);
|
|
54
|
+
}
|
|
55
|
+
function onEvent(event) {
|
|
56
|
+
if (event.type === "tool.start") {
|
|
57
|
+
const call = event.data.call;
|
|
58
|
+
const decision = decisions.get(call.id);
|
|
59
|
+
decisions.delete(call.id);
|
|
60
|
+
pendingRecords.set(call.id, {
|
|
61
|
+
callId: call.id,
|
|
62
|
+
tool: call.name,
|
|
63
|
+
arguments: call.arguments,
|
|
64
|
+
authz: decision ? decisionToAuthz(decision) : null,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (event.type === "tool.done") {
|
|
69
|
+
const result = event.data.result;
|
|
70
|
+
const pending = pendingRecords.get(result.callId);
|
|
71
|
+
if (pending) {
|
|
72
|
+
pendingRecords.delete(result.callId);
|
|
73
|
+
completed.push({
|
|
74
|
+
callId: pending.callId,
|
|
75
|
+
tool: pending.tool,
|
|
76
|
+
arguments: pending.arguments,
|
|
77
|
+
authz: pending.authz,
|
|
78
|
+
result: {
|
|
79
|
+
content: coerceContent(result.content),
|
|
80
|
+
isError: result.isError === true,
|
|
81
|
+
},
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
sessionId,
|
|
84
|
+
seq: event.seq,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Blocked call: no tool.start was emitted. Build the record from
|
|
89
|
+
// the buffered decision and the tool.done event.
|
|
90
|
+
const decision = decisions.get(result.callId);
|
|
91
|
+
if (decision === undefined) {
|
|
92
|
+
// Orphaned tool.done: no tool.start or authz decision was recorded.
|
|
93
|
+
// Emit a degraded record rather than crashing the session — the audit
|
|
94
|
+
// system is observational infrastructure and must not veto execution.
|
|
95
|
+
logger.warn `Orphaned tool.done for callId "${result.callId}": no tool.start or authz decision was recorded`;
|
|
96
|
+
completed.push({
|
|
97
|
+
callId: result.callId,
|
|
98
|
+
tool: "$orphaned",
|
|
99
|
+
arguments: {},
|
|
100
|
+
authz: null,
|
|
101
|
+
result: {
|
|
102
|
+
content: coerceContent(result.content),
|
|
103
|
+
isError: result.isError === true,
|
|
104
|
+
},
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
sessionId,
|
|
107
|
+
seq: event.seq,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
decisions.delete(result.callId);
|
|
112
|
+
completed.push({
|
|
113
|
+
callId: result.callId,
|
|
114
|
+
tool: decision.tool,
|
|
115
|
+
arguments: {},
|
|
116
|
+
authz: decisionToAuthz(decision),
|
|
117
|
+
result: {
|
|
118
|
+
content: coerceContent(result.content),
|
|
119
|
+
isError: result.isError === true,
|
|
120
|
+
},
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
sessionId,
|
|
123
|
+
seq: event.seq,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function flush() {
|
|
128
|
+
return completed.splice(0);
|
|
129
|
+
}
|
|
130
|
+
function pendingCount() {
|
|
131
|
+
return pendingRecords.size + decisions.size;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
onEvent,
|
|
135
|
+
onDecision,
|
|
136
|
+
flush,
|
|
137
|
+
pending: pendingCount,
|
|
138
|
+
};
|
|
139
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { InferenceSource } from "@intx/types/runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Sentinel for headers that carry the API key verbatim (no prefix).
|
|
4
|
+
* Used by providers like Anthropic (`x-api-key`) and Google
|
|
5
|
+
* (`x-goog-api-key`) that accept the raw credential.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CREDENTIAL_SENTINEL = "<inject:credential>";
|
|
8
|
+
/**
|
|
9
|
+
* Sentinel for headers that carry a Bearer-prefixed API key. Used by
|
|
10
|
+
* providers that follow the `Authorization: Bearer <token>` convention
|
|
11
|
+
* (OpenAI, OpenAI-compatible).
|
|
12
|
+
*/
|
|
13
|
+
export declare const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
|
|
14
|
+
/**
|
|
15
|
+
* Replace credential sentinels in a header map with material derived
|
|
16
|
+
* from the inference source. Returns a new object; the input is not
|
|
17
|
+
* mutated. Non-sentinel header values pass through unchanged.
|
|
18
|
+
*
|
|
19
|
+
* A header value that contains a sentinel as a substring but is not
|
|
20
|
+
* exactly equal to it is left alone -- partial replacement would be
|
|
21
|
+
* surprising, and no legitimate adapter constructs sentinel-bearing
|
|
22
|
+
* composite values.
|
|
23
|
+
*/
|
|
24
|
+
export declare function injectCredentials(headers: Record<string, string>, source: InferenceSource): Record<string, string>;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { InferenceSource } from "@intx/types/runtime";
|
|
2
|
-
|
|
3
1
|
// Sentinel placeholder strings adapters use in their built request
|
|
4
2
|
// headers to declare which credential the harness should fill at send
|
|
5
3
|
// time. The harness scans every header value and replaces exact-match
|
|
@@ -18,21 +16,18 @@ import type { InferenceSource } from "@intx/types/runtime";
|
|
|
18
16
|
// keyword prefix that would never appear in a legitimate header value:
|
|
19
17
|
// matching is exact, but defense-in-depth ensures a literal echo from
|
|
20
18
|
// an upstream system can't accidentally trigger replacement.
|
|
21
|
-
|
|
22
19
|
/**
|
|
23
20
|
* Sentinel for headers that carry the API key verbatim (no prefix).
|
|
24
21
|
* Used by providers like Anthropic (`x-api-key`) and Google
|
|
25
22
|
* (`x-goog-api-key`) that accept the raw credential.
|
|
26
23
|
*/
|
|
27
24
|
export const CREDENTIAL_SENTINEL = "<inject:credential>";
|
|
28
|
-
|
|
29
25
|
/**
|
|
30
26
|
* Sentinel for headers that carry a Bearer-prefixed API key. Used by
|
|
31
27
|
* providers that follow the `Authorization: Bearer <token>` convention
|
|
32
28
|
* (OpenAI, OpenAI-compatible).
|
|
33
29
|
*/
|
|
34
30
|
export const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
|
|
35
|
-
|
|
36
31
|
/**
|
|
37
32
|
* Replace credential sentinels in a header map with material derived
|
|
38
33
|
* from the inference source. Returns a new object; the input is not
|
|
@@ -43,19 +38,18 @@ export const BEARER_CREDENTIAL_SENTINEL = "<inject:bearer-credential>";
|
|
|
43
38
|
* surprising, and no legitimate adapter constructs sentinel-bearing
|
|
44
39
|
* composite values.
|
|
45
40
|
*/
|
|
46
|
-
export function injectCredentials(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
export function injectCredentials(headers, source) {
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
44
|
+
if (value === CREDENTIAL_SENTINEL) {
|
|
45
|
+
result[name] = source.apiKey;
|
|
46
|
+
}
|
|
47
|
+
else if (value === BEARER_CREDENTIAL_SENTINEL) {
|
|
48
|
+
result[name] = `Bearer ${source.apiKey}`;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
result[name] = value;
|
|
52
|
+
}
|
|
58
53
|
}
|
|
59
|
-
|
|
60
|
-
return result;
|
|
54
|
+
return result;
|
|
61
55
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { BeforeToolExtension, ToolDefinition } from "@intx/types/runtime";
|
|
2
|
+
import type { Effect } from "@intx/types/authz";
|
|
3
|
+
export type AuthzMatchedGrant = {
|
|
4
|
+
id: string;
|
|
5
|
+
resource: string;
|
|
6
|
+
action: string;
|
|
7
|
+
effect: Effect;
|
|
8
|
+
origin: "system" | "role" | "creator" | "invoker";
|
|
9
|
+
specificity: number;
|
|
10
|
+
};
|
|
11
|
+
export type AuthzCallResult = {
|
|
12
|
+
effect: Effect | null;
|
|
13
|
+
matchingGrants: AuthzMatchedGrant[];
|
|
14
|
+
resolvedBy: AuthzMatchedGrant | null;
|
|
15
|
+
};
|
|
16
|
+
export type AuthzDecision = {
|
|
17
|
+
callId: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
action: string;
|
|
21
|
+
effect: Effect | null;
|
|
22
|
+
resolvedBy: AuthzMatchedGrant | null;
|
|
23
|
+
matchingGrants: AuthzMatchedGrant[];
|
|
24
|
+
blocked: boolean;
|
|
25
|
+
blockReason: string | undefined;
|
|
26
|
+
error: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
export type AuthzExtensionOptions<Ctx = unknown> = {
|
|
29
|
+
authorize: (resource: string, action: string, context: Ctx) => Promise<AuthzCallResult>;
|
|
30
|
+
onDecision?: (decision: AuthzDecision) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Deadline applied to an approval suspension, in milliseconds from the
|
|
33
|
+
* moment the `ask` effect is hit. Defaults to `DEFAULT_APPROVAL_TIMEOUT_MS`.
|
|
34
|
+
*/
|
|
35
|
+
approvalTimeoutMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Tool definitions the extension can be asked to authorize, used to build the
|
|
38
|
+
* approver-facing snapshot at the `ask` branch. Presence is a contract: when
|
|
39
|
+
* supplied, every tool this extension authorizes must appear here, and an
|
|
40
|
+
* `ask` for a tool that does not is a wiring defect that throws. Omitted
|
|
41
|
+
* entirely, the extension produces no snapshot — a mode for callers that
|
|
42
|
+
* never register a suspension with the hub.
|
|
43
|
+
*/
|
|
44
|
+
toolDefinitions?: readonly ToolDefinition[];
|
|
45
|
+
};
|
|
46
|
+
export declare function createAuthzExtension<Ctx = unknown>(opts: AuthzExtensionOptions<Ctx>): BeforeToolExtension;
|
|
@@ -0,0 +1,184 @@
|
|
|
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 suspended (parked awaiting an external approval decision)
|
|
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
|
+
// Default deadline for an approval suspension when the caller does not supply
|
|
24
|
+
// one. Matches the reactor's DEFAULT_GATE_TIMEOUT_MS (one hour); the value is
|
|
25
|
+
// duplicated rather than imported to avoid a dependency from the pure-policy
|
|
26
|
+
// extension onto the reactor module.
|
|
27
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 3_600_000;
|
|
28
|
+
function formatBlockReason(effect, resource, action) {
|
|
29
|
+
switch (effect) {
|
|
30
|
+
case "deny":
|
|
31
|
+
return `Denied by policy: ${resource}/${action}`;
|
|
32
|
+
case null:
|
|
33
|
+
return `No matching grants for ${resource}/${action}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function safeOnDecision(callback, decision) {
|
|
37
|
+
if (callback === undefined)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
callback(decision);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// onDecision must not throw. If it does, swallow the exception so
|
|
44
|
+
// it cannot interfere with the authorization decision or mask the
|
|
45
|
+
// original error from authorize().
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function createAuthzExtension(opts) {
|
|
49
|
+
// The reactor does not know workflow concepts; per-call context is the
|
|
50
|
+
// caller's domain. The third arg is plumbing here -- if the caller
|
|
51
|
+
// needs to attach context (workflow step, tenant id, request id), they
|
|
52
|
+
// do so by closure on the authorize function. The empty object is the
|
|
53
|
+
// safe default at this layer.
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inference layer has no domain knowledge to construct a Ctx; callers that need a populated context use closure capture on the authorize function (see @intx/workflow's AuthorizeContext)
|
|
55
|
+
const emptyContext = Object.freeze({});
|
|
56
|
+
// One-shot bypass tokens, keyed on ToolCall.id. A token authorizes a single
|
|
57
|
+
// re-dispatch of an already-approved call to skip the `ask` gate it would
|
|
58
|
+
// otherwise re-hit. Held in memory only, within the resumed reactor cycle
|
|
59
|
+
// that grants and consumes it: a durable allow would outlive the cycle and
|
|
60
|
+
// defeat the one-shot intent, and a crash between grant and consume simply
|
|
61
|
+
// re-drives from the durable log and re-grants.
|
|
62
|
+
const approvedOnce = new Set();
|
|
63
|
+
// Name → definition lookup for building the approval snapshot at the `ask`
|
|
64
|
+
// branch. `undefined` (not merely empty) means the caller wired no tool
|
|
65
|
+
// definitions and wants no snapshot; a defined map means every authorizable
|
|
66
|
+
// tool must be present, so a lookup miss is a wiring defect that throws. The
|
|
67
|
+
// sentinel keeps those two contracts distinguishable at the lookup site.
|
|
68
|
+
const toolDefinitionsByName = opts.toolDefinitions !== undefined
|
|
69
|
+
? new Map(opts.toolDefinitions.map((def) => [def.name, def]))
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
grantOneShot(id) {
|
|
73
|
+
approvedOnce.add(id);
|
|
74
|
+
},
|
|
75
|
+
async beforeTool(call) {
|
|
76
|
+
const resource = `tool:${call.name}`;
|
|
77
|
+
const action = "invoke";
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = await opts.authorize(resource, action, emptyContext);
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
84
|
+
const decision = {
|
|
85
|
+
callId: call.id,
|
|
86
|
+
tool: call.name,
|
|
87
|
+
resource,
|
|
88
|
+
action,
|
|
89
|
+
effect: null,
|
|
90
|
+
resolvedBy: null,
|
|
91
|
+
matchingGrants: [],
|
|
92
|
+
blocked: true,
|
|
93
|
+
blockReason: `Authorization failed: ${msg}`,
|
|
94
|
+
error: msg,
|
|
95
|
+
};
|
|
96
|
+
safeOnDecision(opts.onDecision, decision);
|
|
97
|
+
throw cause;
|
|
98
|
+
}
|
|
99
|
+
// An `ask` effect suspends the call rather than blocking it, so it is
|
|
100
|
+
// neither cleanly blocked nor allowed: the decision records
|
|
101
|
+
// `blocked: false` with no block reason. Only `deny`/null (fail-closed)
|
|
102
|
+
// are blocks.
|
|
103
|
+
const blockReason = result.effect === "deny" || result.effect === null
|
|
104
|
+
? formatBlockReason(result.effect, resource, action)
|
|
105
|
+
: undefined;
|
|
106
|
+
const decision = {
|
|
107
|
+
callId: call.id,
|
|
108
|
+
tool: call.name,
|
|
109
|
+
resource,
|
|
110
|
+
action,
|
|
111
|
+
effect: result.effect,
|
|
112
|
+
resolvedBy: result.resolvedBy,
|
|
113
|
+
matchingGrants: result.matchingGrants,
|
|
114
|
+
blocked: blockReason !== undefined,
|
|
115
|
+
blockReason,
|
|
116
|
+
error: undefined,
|
|
117
|
+
};
|
|
118
|
+
safeOnDecision(opts.onDecision, decision);
|
|
119
|
+
// A one-shot token only authorizes bypassing an `ask` gate. If the
|
|
120
|
+
// resolved effect is anything else, the grant changed underneath the
|
|
121
|
+
// token: drop it and let the normal path decide, rather than silently
|
|
122
|
+
// allowing a call the policy no longer parks.
|
|
123
|
+
if (approvedOnce.has(call.id) && result.effect !== "ask") {
|
|
124
|
+
approvedOnce.delete(call.id);
|
|
125
|
+
}
|
|
126
|
+
if (blockReason !== undefined) {
|
|
127
|
+
return { type: "block", reason: blockReason };
|
|
128
|
+
}
|
|
129
|
+
if (result.effect === "ask") {
|
|
130
|
+
// A prior approval authorized this exact call to run once. Consume the
|
|
131
|
+
// token (delete-on-read) and allow it through instead of suspending,
|
|
132
|
+
// so a re-dispatched approved call does not re-park on its own gate.
|
|
133
|
+
if (approvedOnce.has(call.id)) {
|
|
134
|
+
approvedOnce.delete(call.id);
|
|
135
|
+
return { type: "allow" };
|
|
136
|
+
}
|
|
137
|
+
// Mint the correlationId once here so it is the single source of
|
|
138
|
+
// identity for both the gate and the persisted operation. The
|
|
139
|
+
// reactor persists the operation, so this id survives a restart.
|
|
140
|
+
const correlationId = crypto.randomUUID();
|
|
141
|
+
const timeoutAt = Date.now() + (opts.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS);
|
|
142
|
+
const gateId = `pending-${correlationId}`;
|
|
143
|
+
// Build the approver-facing snapshot when tool definitions are wired.
|
|
144
|
+
// A wired extension must have a definition for every tool it can
|
|
145
|
+
// authorize, so a miss is a wiring defect rather than a fallback. An
|
|
146
|
+
// unwired extension produces no snapshot: such callers never register
|
|
147
|
+
// the suspension with the hub, so the downstream required-snapshot
|
|
148
|
+
// validator never sees them.
|
|
149
|
+
let approvalSnapshot;
|
|
150
|
+
if (toolDefinitionsByName !== undefined) {
|
|
151
|
+
const def = toolDefinitionsByName.get(call.name);
|
|
152
|
+
if (def === undefined) {
|
|
153
|
+
throw new Error(`Tool "${call.name}" was authorized with effect "ask" but has ` +
|
|
154
|
+
`no definition in the resolved tool set; the approval ` +
|
|
155
|
+
`snapshot cannot be built. This is a wiring defect: every ` +
|
|
156
|
+
`tool the authz extension can authorize must be present in ` +
|
|
157
|
+
`toolDefinitions.`);
|
|
158
|
+
}
|
|
159
|
+
approvalSnapshot = {
|
|
160
|
+
name: call.name,
|
|
161
|
+
description: def.description,
|
|
162
|
+
inputSchema: def.inputSchema,
|
|
163
|
+
arguments: call.arguments,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const pendingOp = {
|
|
167
|
+
correlationId,
|
|
168
|
+
kind: "approval",
|
|
169
|
+
registeredAt: Date.now(),
|
|
170
|
+
gateId,
|
|
171
|
+
timeoutAt,
|
|
172
|
+
suspendedCall: call,
|
|
173
|
+
...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}),
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
type: "suspend",
|
|
177
|
+
gate: { type: "approval", gateId, correlationId, timeoutAt },
|
|
178
|
+
pendingOp,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { type: "allow" };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { InboundMessage, PendingOperation } from "@intx/types/runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Validates whether an inbound message is an authentic response to a
|
|
4
|
+
* registered pending operation. Consumers provide this at reactor construction
|
|
5
|
+
* time to enforce sender identity and signature checks.
|
|
6
|
+
*/
|
|
7
|
+
export interface CorrelationValidator {
|
|
8
|
+
/**
|
|
9
|
+
* Return true if `message` is a valid resolution for `pending`.
|
|
10
|
+
* False causes the message to be delivered as a regular uncorrelated event.
|
|
11
|
+
*/
|
|
12
|
+
validate(pending: PendingOperation, message: InboundMessage): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Tracks pending async operations. Each entry maps a correlation ID to the
|
|
16
|
+
* operation metadata and the gate that is waiting for it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createCorrelationRegistry(): {
|
|
19
|
+
register: (op: PendingOperation) => void;
|
|
20
|
+
lookup: (correlationId: string) => PendingOperation | undefined;
|
|
21
|
+
findByGateId: (gateId: string) => PendingOperation | undefined;
|
|
22
|
+
remove: (correlationId: string) => boolean;
|
|
23
|
+
all: () => PendingOperation[];
|
|
24
|
+
hasAny: () => boolean;
|
|
25
|
+
};
|
|
26
|
+
export type CorrelationRegistry = ReturnType<typeof createCorrelationRegistry>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Correlation registry and validator interface for the agent reactor.
|
|
2
|
+
//
|
|
3
|
+
// Correlation connects outbound async tool calls to inbound responses. The
|
|
4
|
+
// reactor owns the matching; the director does not participate.
|
|
5
|
+
//
|
|
6
|
+
// (INFERENCE.md § Correlation)
|
|
7
|
+
/**
|
|
8
|
+
* Tracks pending async operations. Each entry maps a correlation ID to the
|
|
9
|
+
* operation metadata and the gate that is waiting for it.
|
|
10
|
+
*/
|
|
11
|
+
export function createCorrelationRegistry() {
|
|
12
|
+
const operations = new Map();
|
|
13
|
+
function register(op) {
|
|
14
|
+
if (operations.has(op.correlationId)) {
|
|
15
|
+
throw new Error(`Correlation ID "${op.correlationId}" is already registered`);
|
|
16
|
+
}
|
|
17
|
+
operations.set(op.correlationId, op);
|
|
18
|
+
}
|
|
19
|
+
function lookup(correlationId) {
|
|
20
|
+
return operations.get(correlationId);
|
|
21
|
+
}
|
|
22
|
+
function findByGateId(gateId) {
|
|
23
|
+
for (const op of operations.values()) {
|
|
24
|
+
if (op.gateId === gateId)
|
|
25
|
+
return op;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function remove(correlationId) {
|
|
30
|
+
return operations.delete(correlationId);
|
|
31
|
+
}
|
|
32
|
+
function all() {
|
|
33
|
+
return Array.from(operations.values());
|
|
34
|
+
}
|
|
35
|
+
function hasAny() {
|
|
36
|
+
return operations.size > 0;
|
|
37
|
+
}
|
|
38
|
+
return { register, lookup, findByGateId, remove, all, hasAny };
|
|
39
|
+
}
|