@gethelio/proxy 0.3.0 → 0.5.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/dist/cli.js +1967 -239
- package/dist/dashboard-assets/assets/index-DZKoV0Vx.css +1 -0
- package/dist/dashboard-assets/assets/{index-CAgnN6wV.js → index-DgywE2WQ.js} +30 -30
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +440 -11
- package/dist/index.js +1932 -237
- package/package.json +1 -1
- package/dist/dashboard-assets/assets/index-DG3h7Cvn.css +0 -1
package/dist/index.js
CHANGED
|
@@ -105,11 +105,23 @@ var annotationsMatchSchema = z.object({
|
|
|
105
105
|
idempotentHint: z.boolean().optional(),
|
|
106
106
|
openWorldHint: z.boolean().optional()
|
|
107
107
|
}).strict();
|
|
108
|
+
var metadataConditionSchema = z.union([
|
|
109
|
+
z.string(),
|
|
110
|
+
z.object({
|
|
111
|
+
eq: z.string().optional(),
|
|
112
|
+
neq: z.string().optional(),
|
|
113
|
+
contains: z.string().optional(),
|
|
114
|
+
regex: z.string().optional()
|
|
115
|
+
}).strict().refine((obj) => Object.keys(obj).length > 0, {
|
|
116
|
+
message: "At least one metadata condition operator is required"
|
|
117
|
+
})
|
|
118
|
+
]);
|
|
108
119
|
var matchSchema = z.object({
|
|
109
120
|
tool: z.string().optional(),
|
|
110
121
|
annotations: annotationsMatchSchema.optional(),
|
|
111
122
|
input: z.record(z.string(), inputConditionSchema).optional(),
|
|
112
|
-
environment: z.string().optional()
|
|
123
|
+
environment: z.string().optional(),
|
|
124
|
+
metadata: z.record(z.string(), metadataConditionSchema).optional()
|
|
113
125
|
}).strict();
|
|
114
126
|
var policyActionSchema = z.enum([
|
|
115
127
|
"allow",
|
|
@@ -133,12 +145,12 @@ var spendLimitSchema = z.object({
|
|
|
133
145
|
limit: z.number(),
|
|
134
146
|
currency: z.string(),
|
|
135
147
|
window: durationSchema,
|
|
136
|
-
key: z.enum(["tool", "agent", "session"]).optional()
|
|
148
|
+
key: z.enum(["tool", "agent", "session", "sender_id"]).optional()
|
|
137
149
|
}).strict();
|
|
138
150
|
var limitsSchema = z.object({
|
|
139
151
|
max_calls: z.number().int().positive().optional(),
|
|
140
152
|
window: durationSchema.optional(),
|
|
141
|
-
key: z.enum(["tool", "agent", "session"]).optional(),
|
|
153
|
+
key: z.enum(["tool", "agent", "session", "sender_id"]).optional(),
|
|
142
154
|
max_spend: spendLimitSchema.optional()
|
|
143
155
|
}).strict();
|
|
144
156
|
var feedbackSchema = z.object({
|
|
@@ -156,11 +168,43 @@ var policyRuleSchema = z.object({
|
|
|
156
168
|
limits: limitsSchema.optional(),
|
|
157
169
|
feedback: feedbackSchema.optional()
|
|
158
170
|
}).strict();
|
|
171
|
+
var installMatchSchema = z.object({
|
|
172
|
+
name: z.string().optional(),
|
|
173
|
+
// glob, picomatch (same engine as match.tool)
|
|
174
|
+
source: z.string().optional(),
|
|
175
|
+
// exact ecosystem match (npm | pip | …)
|
|
176
|
+
metadata: z.record(z.string(), metadataConditionSchema).optional()
|
|
177
|
+
}).strict();
|
|
178
|
+
var installRuleSchema = z.object({
|
|
179
|
+
name: z.string().optional(),
|
|
180
|
+
match: installMatchSchema,
|
|
181
|
+
action: z.enum(["deny_install", "allow"]),
|
|
182
|
+
feedback: feedbackSchema.optional()
|
|
183
|
+
}).strict();
|
|
184
|
+
var installSchema = z.object({
|
|
185
|
+
default: z.enum(["allow", "deny"]).default("allow"),
|
|
186
|
+
rules: z.array(installRuleSchema).default([])
|
|
187
|
+
}).strict();
|
|
159
188
|
var policiesSchema = z.object({
|
|
160
189
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
161
190
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
162
191
|
dry_run: z.boolean().default(false),
|
|
163
192
|
rules: z.array(policyRuleSchema).default([]),
|
|
193
|
+
/** Install-time policy (issue #13 — deny_install). Optional; absent ⇒ observational. */
|
|
194
|
+
install: installSchema.optional(),
|
|
195
|
+
/**
|
|
196
|
+
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
197
|
+
* description) has drifted from the baseline Helio captured on first
|
|
198
|
+
* sight.
|
|
199
|
+
* - "block": deny the call until the proxy is restarted (re-baselines)
|
|
200
|
+
* or the upstream reverts. Conservative default when omitted.
|
|
201
|
+
* - "require_approval": escalate the call through the approval channel.
|
|
202
|
+
* - "log": audit the drift; rules evaluate against both baseline and
|
|
203
|
+
* current annotations and the stricter decision wins.
|
|
204
|
+
* Kept optional (like hot_reload) so PoliciesConfig literal fixtures
|
|
205
|
+
* don't need the field; undefined is treated as "block".
|
|
206
|
+
*/
|
|
207
|
+
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
164
208
|
/**
|
|
165
209
|
* Whether `helio start` should watch the config file for changes and
|
|
166
210
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -208,7 +252,14 @@ var auditSchema = z.object({
|
|
|
208
252
|
var sdkSchema = z.object({
|
|
209
253
|
enabled: z.boolean().default(false),
|
|
210
254
|
port: z.number().int().min(1).max(65535).default(3200),
|
|
211
|
-
host: z.string().default("127.0.0.1")
|
|
255
|
+
host: z.string().default("127.0.0.1"),
|
|
256
|
+
/**
|
|
257
|
+
* How long a sideband `/evaluate` decision waits for its `/audit` before the
|
|
258
|
+
* proxy finalizes it as `evaluation_expired` (issue #12, D4). Bounds the
|
|
259
|
+
* pending-evaluation registry; an adapter crash cannot silently drop a
|
|
260
|
+
* decided-allowed call from the trail.
|
|
261
|
+
*/
|
|
262
|
+
evaluation_ttl: durationSchema.default("10m")
|
|
212
263
|
});
|
|
213
264
|
var helioConfigBaseSchema = z.object({
|
|
214
265
|
version: z.literal("1"),
|
|
@@ -223,14 +274,14 @@ var helioConfigBaseSchema = z.object({
|
|
|
223
274
|
});
|
|
224
275
|
var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
225
276
|
const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
|
|
226
|
-
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
277
|
+
const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval");
|
|
227
278
|
const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
|
|
228
279
|
if (requiresSecret) {
|
|
229
280
|
if (!hasSecret) {
|
|
230
281
|
ctx.addIssue({
|
|
231
282
|
code: "custom",
|
|
232
283
|
path: ["dashboard", "api_secret"],
|
|
233
|
-
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
284
|
+
message: 'dashboard.api_secret is required when any rule uses require_approval or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
|
|
234
285
|
});
|
|
235
286
|
}
|
|
236
287
|
}
|
|
@@ -271,6 +322,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
271
322
|
message: `Rule sets match.environment="${rule.match.environment}" but top-level \`environment\` is not configured. Set top-level environment to enable env-scoped rules.`
|
|
272
323
|
});
|
|
273
324
|
}
|
|
325
|
+
if (!cfg.sdk.enabled) {
|
|
326
|
+
if (rule.limits?.key === "sender_id") {
|
|
327
|
+
ctx.addIssue({
|
|
328
|
+
code: "custom",
|
|
329
|
+
path: ["policies", "rules", ruleIndex, "limits", "key"],
|
|
330
|
+
message: 'limits.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (rule.limits?.max_spend?.key === "sender_id") {
|
|
334
|
+
ctx.addIssue({
|
|
335
|
+
code: "custom",
|
|
336
|
+
path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
|
|
337
|
+
message: 'limits.max_spend.key "sender_id" requires the SDK sideband (sdk.enabled: true) \u2014 sender_id is supplied by hook adapters and is absent on the MCP path.'
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
274
341
|
if (rule.action === "rate_limit") {
|
|
275
342
|
if (rule.limits?.max_calls === void 0) {
|
|
276
343
|
ctx.addIssue({
|
|
@@ -412,6 +479,7 @@ var PolicyParseError = class extends Error {
|
|
|
412
479
|
|
|
413
480
|
// src/policy/parser.ts
|
|
414
481
|
var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
|
|
482
|
+
var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
|
|
415
483
|
function compilePolicies(config) {
|
|
416
484
|
const warnings = [];
|
|
417
485
|
const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
|
|
@@ -419,10 +487,37 @@ function compilePolicies(config) {
|
|
|
419
487
|
defaultAction: config.default,
|
|
420
488
|
flagDestructive: config.flag_destructive,
|
|
421
489
|
...config.dry_run && { dryRun: true },
|
|
422
|
-
|
|
490
|
+
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
491
|
+
rules,
|
|
492
|
+
...config.install && { install: compileInstallPolicy(config.install) }
|
|
423
493
|
};
|
|
424
494
|
return { policy, warnings };
|
|
425
495
|
}
|
|
496
|
+
function compileInstallPolicy(install) {
|
|
497
|
+
return {
|
|
498
|
+
defaultAction: install.default,
|
|
499
|
+
rules: install.rules.map((rule, index) => {
|
|
500
|
+
const name = rule.match.name !== void 0 ? compileToolMatcher(rule.match.name, index, rule.name) : void 0;
|
|
501
|
+
const metadata = rule.match.metadata !== void 0 ? flattenMetadataConditions(rule.match.metadata, index, rule.name) : void 0;
|
|
502
|
+
return {
|
|
503
|
+
index,
|
|
504
|
+
...rule.name !== void 0 && { name: rule.name },
|
|
505
|
+
match: {
|
|
506
|
+
...name !== void 0 && { name },
|
|
507
|
+
...rule.match.source !== void 0 && { source: rule.match.source },
|
|
508
|
+
...metadata !== void 0 && { metadata }
|
|
509
|
+
},
|
|
510
|
+
action: rule.action,
|
|
511
|
+
...rule.feedback !== void 0 && {
|
|
512
|
+
feedback: {
|
|
513
|
+
message: rule.feedback.message,
|
|
514
|
+
...rule.feedback.suggestion !== void 0 && { suggestion: rule.feedback.suggestion }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
})
|
|
519
|
+
};
|
|
520
|
+
}
|
|
426
521
|
function compileRule(rule, index, warnings) {
|
|
427
522
|
const match = compileMatch(rule.match, index, rule.name);
|
|
428
523
|
const approval = compileApproval(rule.approval);
|
|
@@ -455,7 +550,10 @@ function compileMatch(match, ruleIndex, ruleName) {
|
|
|
455
550
|
...match.input !== void 0 && {
|
|
456
551
|
input: flattenInputConditions(match.input, ruleIndex, ruleName)
|
|
457
552
|
},
|
|
458
|
-
...match.environment !== void 0 && { environment: match.environment }
|
|
553
|
+
...match.environment !== void 0 && { environment: match.environment },
|
|
554
|
+
...match.metadata !== void 0 && {
|
|
555
|
+
metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
|
|
556
|
+
}
|
|
459
557
|
};
|
|
460
558
|
}
|
|
461
559
|
function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
@@ -507,6 +605,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
507
605
|
}
|
|
508
606
|
return conditions;
|
|
509
607
|
}
|
|
608
|
+
function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
|
|
609
|
+
const conditions = [];
|
|
610
|
+
for (const [key, raw] of Object.entries(metadata)) {
|
|
611
|
+
if (typeof raw === "string") {
|
|
612
|
+
conditions.push({ key, operator: "eq", value: raw });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
for (const op of METADATA_OPERATORS) {
|
|
616
|
+
const value = raw[op];
|
|
617
|
+
if (value === void 0) continue;
|
|
618
|
+
if (op === "regex") {
|
|
619
|
+
if (!safeRegex(value)) {
|
|
620
|
+
throw new PolicyParseError(
|
|
621
|
+
`catastrophic regex "${value}" for metadata key "${key}": pattern is vulnerable to ReDoS and has been rejected. Rewrite with bounded quantifiers (e.g. {1,100}) or split into simpler rules.`,
|
|
622
|
+
ruleIndex,
|
|
623
|
+
ruleName
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
let compiledRegex;
|
|
627
|
+
try {
|
|
628
|
+
compiledRegex = new RegExp(value);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
631
|
+
throw new PolicyParseError(
|
|
632
|
+
`invalid regex "${value}" for metadata key "${key}": ${msg}`,
|
|
633
|
+
ruleIndex,
|
|
634
|
+
ruleName
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
conditions.push({ key, operator: op, value, regex: compiledRegex });
|
|
638
|
+
} else {
|
|
639
|
+
conditions.push({ key, operator: op, value });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return conditions;
|
|
644
|
+
}
|
|
510
645
|
function compileApproval(approval) {
|
|
511
646
|
if (!approval) return void 0;
|
|
512
647
|
return {
|
|
@@ -2141,12 +2276,31 @@ function matchEnvironment(required, ctx) {
|
|
|
2141
2276
|
if (ctx.environment === void 0) return false;
|
|
2142
2277
|
return ctx.environment === required;
|
|
2143
2278
|
}
|
|
2279
|
+
function matchMetadata(conditions, ctx) {
|
|
2280
|
+
if (conditions.length === 0) return true;
|
|
2281
|
+
if (ctx.metadata === void 0) return false;
|
|
2282
|
+
for (const condition of conditions) {
|
|
2283
|
+
const value = ctx.metadata[condition.key];
|
|
2284
|
+
const matched = evaluateCondition(
|
|
2285
|
+
{
|
|
2286
|
+
path: condition.key,
|
|
2287
|
+
operator: condition.operator,
|
|
2288
|
+
value: condition.value,
|
|
2289
|
+
regex: condition.regex
|
|
2290
|
+
},
|
|
2291
|
+
value
|
|
2292
|
+
);
|
|
2293
|
+
if (!matched) return false;
|
|
2294
|
+
}
|
|
2295
|
+
return true;
|
|
2296
|
+
}
|
|
2144
2297
|
function matchRule(rule, ctx) {
|
|
2145
2298
|
const { match } = rule;
|
|
2146
2299
|
if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
|
|
2147
2300
|
if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
|
|
2148
2301
|
if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
|
|
2149
2302
|
if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
|
|
2303
|
+
if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
|
|
2150
2304
|
return true;
|
|
2151
2305
|
}
|
|
2152
2306
|
|
|
@@ -2169,61 +2323,6 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2169
2323
|
};
|
|
2170
2324
|
}
|
|
2171
2325
|
|
|
2172
|
-
// src/policy/annotation-cache.ts
|
|
2173
|
-
var ToolAnnotationCache = class {
|
|
2174
|
-
cache = /* @__PURE__ */ new Map();
|
|
2175
|
-
/** Number of tools currently cached. */
|
|
2176
|
-
get size() {
|
|
2177
|
-
return this.cache.size;
|
|
2178
|
-
}
|
|
2179
|
-
/**
|
|
2180
|
-
* Update the cache from a tools/list JSON-RPC response body.
|
|
2181
|
-
*
|
|
2182
|
-
* Performs a full replacement — tools that existed in the previous cache
|
|
2183
|
-
* but are absent from the new response are removed. This correctly handles
|
|
2184
|
-
* tool list changes (additions, removals, annotation updates).
|
|
2185
|
-
*
|
|
2186
|
-
* @returns `true` if the response body was a valid tools/list response and
|
|
2187
|
-
* the cache was updated, `false` if the body shape was unexpected.
|
|
2188
|
-
*/
|
|
2189
|
-
update(responseBody) {
|
|
2190
|
-
const tools = extractTools(responseBody);
|
|
2191
|
-
if (!tools) return false;
|
|
2192
|
-
this.cache.clear();
|
|
2193
|
-
for (const tool of tools) {
|
|
2194
|
-
if (typeof tool !== "object" || tool === null) continue;
|
|
2195
|
-
const t = tool;
|
|
2196
|
-
const name = t["name"];
|
|
2197
|
-
if (typeof name !== "string") continue;
|
|
2198
|
-
const annotations = t["annotations"];
|
|
2199
|
-
if (annotations && typeof annotations === "object") {
|
|
2200
|
-
this.cache.set(name, annotations);
|
|
2201
|
-
} else {
|
|
2202
|
-
this.cache.set(name, void 0);
|
|
2203
|
-
}
|
|
2204
|
-
}
|
|
2205
|
-
return true;
|
|
2206
|
-
}
|
|
2207
|
-
/** Get cached annotations for a tool. Returns `undefined` if the tool is not in the cache. */
|
|
2208
|
-
get(toolName) {
|
|
2209
|
-
return this.cache.get(toolName);
|
|
2210
|
-
}
|
|
2211
|
-
/** Check whether a tool exists in the cache (regardless of whether it has annotations). */
|
|
2212
|
-
has(toolName) {
|
|
2213
|
-
return this.cache.has(toolName);
|
|
2214
|
-
}
|
|
2215
|
-
};
|
|
2216
|
-
function extractTools(body) {
|
|
2217
|
-
if (typeof body !== "object" || body === null) return null;
|
|
2218
|
-
const b = body;
|
|
2219
|
-
const result = b["result"];
|
|
2220
|
-
if (typeof result !== "object" || result === null) return null;
|
|
2221
|
-
const r = result;
|
|
2222
|
-
const tools = r["tools"];
|
|
2223
|
-
if (!Array.isArray(tools)) return null;
|
|
2224
|
-
return tools;
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
2326
|
// src/evidence/grounding.ts
|
|
2228
2327
|
function checkEvidence(store, sessionId, requirements) {
|
|
2229
2328
|
if (requirements.length === 0) {
|
|
@@ -2267,6 +2366,368 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
|
2267
2366
|
};
|
|
2268
2367
|
}
|
|
2269
2368
|
|
|
2369
|
+
// src/policy/decision-pipeline.ts
|
|
2370
|
+
function decide(input) {
|
|
2371
|
+
const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
|
|
2372
|
+
const annotations = input.baselineAnnotations;
|
|
2373
|
+
const driftEvent = input.driftEvent;
|
|
2374
|
+
const driftMode = policy.onToolDrift ?? "block";
|
|
2375
|
+
const metadata = buildMetadataView(input.metadata, input.agentId);
|
|
2376
|
+
let decision = evaluatePolicy(policy, {
|
|
2377
|
+
toolName,
|
|
2378
|
+
annotations,
|
|
2379
|
+
toolArguments,
|
|
2380
|
+
environment,
|
|
2381
|
+
metadata
|
|
2382
|
+
});
|
|
2383
|
+
if (driftEvent && driftMode === "log") {
|
|
2384
|
+
const currentDecision = evaluatePolicy(policy, {
|
|
2385
|
+
toolName,
|
|
2386
|
+
annotations: input.currentAnnotations,
|
|
2387
|
+
toolArguments,
|
|
2388
|
+
environment,
|
|
2389
|
+
metadata
|
|
2390
|
+
});
|
|
2391
|
+
decision = stricterDecision(decision, currentDecision);
|
|
2392
|
+
}
|
|
2393
|
+
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2394
|
+
const currentDestructive = driftEvent && driftMode === "log" ? input.currentAnnotations?.destructiveHint ?? true : false;
|
|
2395
|
+
const isDestructive = baselineDestructive || currentDestructive;
|
|
2396
|
+
let flaggedDestructive = false;
|
|
2397
|
+
if (isDestructive && !decision.matchedRule && policy.flagDestructive) {
|
|
2398
|
+
flaggedDestructive = true;
|
|
2399
|
+
if (policy.flagDestructive === "log") {
|
|
2400
|
+
console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
|
|
2401
|
+
} else {
|
|
2402
|
+
decision = {
|
|
2403
|
+
action: "require_approval",
|
|
2404
|
+
matchedRule: void 0,
|
|
2405
|
+
reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
let driftBlocked = false;
|
|
2410
|
+
if (driftEvent && driftMode !== "log") {
|
|
2411
|
+
driftBlocked = driftMode === "block";
|
|
2412
|
+
decision = {
|
|
2413
|
+
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2414
|
+
matchedRule: void 0,
|
|
2415
|
+
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
const originalAction = decision.action;
|
|
2419
|
+
let evidenceResult;
|
|
2420
|
+
let dependencyResult;
|
|
2421
|
+
let evidenceBlocked = false;
|
|
2422
|
+
let sessionBlocked = false;
|
|
2423
|
+
const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
|
|
2424
|
+
if (requiresGroundedSession && !sessionId) {
|
|
2425
|
+
sessionBlocked = true;
|
|
2426
|
+
evidenceBlocked = true;
|
|
2427
|
+
decision = {
|
|
2428
|
+
action: "deny",
|
|
2429
|
+
matchedRule: decision.matchedRule,
|
|
2430
|
+
reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
2434
|
+
const rule = decision.matchedRule;
|
|
2435
|
+
if (rule.evidence?.requires.length) {
|
|
2436
|
+
evidenceResult = checkEvidence(evidenceStore, sessionId, rule.evidence.requires);
|
|
2437
|
+
if (!evidenceResult.satisfied) {
|
|
2438
|
+
evidenceBlocked = true;
|
|
2439
|
+
const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
|
|
2440
|
+
decision = {
|
|
2441
|
+
action: "deny",
|
|
2442
|
+
matchedRule: rule,
|
|
2443
|
+
reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
if (!evidenceBlocked && rule.requires?.length) {
|
|
2448
|
+
dependencyResult = checkDependencies(evidenceStore, sessionId, rule.requires, {
|
|
2449
|
+
requireSuccess: rule.requiresSuccess ?? true
|
|
2450
|
+
});
|
|
2451
|
+
if (!dependencyResult.satisfied) {
|
|
2452
|
+
evidenceBlocked = true;
|
|
2453
|
+
decision = {
|
|
2454
|
+
action: "deny",
|
|
2455
|
+
matchedRule: rule,
|
|
2456
|
+
reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
const isPerRuleDryRun = originalAction === "dry_run";
|
|
2462
|
+
const isGlobalDryRun = policy.dryRun === true;
|
|
2463
|
+
const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
|
|
2464
|
+
return {
|
|
2465
|
+
decision,
|
|
2466
|
+
originalAction,
|
|
2467
|
+
driftEvent,
|
|
2468
|
+
driftMode,
|
|
2469
|
+
driftBlocked,
|
|
2470
|
+
flaggedDestructive,
|
|
2471
|
+
evidenceResult,
|
|
2472
|
+
dependencyResult,
|
|
2473
|
+
evidenceBlocked,
|
|
2474
|
+
sessionBlocked,
|
|
2475
|
+
isDryRun
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
var ACTION_SEVERITY = {
|
|
2479
|
+
deny: 5,
|
|
2480
|
+
require_approval: 4,
|
|
2481
|
+
dry_run: 3,
|
|
2482
|
+
spend_limit: 2,
|
|
2483
|
+
rate_limit: 1,
|
|
2484
|
+
allow: 0
|
|
2485
|
+
};
|
|
2486
|
+
function stricterDecision(a, b) {
|
|
2487
|
+
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
2488
|
+
}
|
|
2489
|
+
function buildMetadataView(metadata, agentId) {
|
|
2490
|
+
if (agentId === void 0) return metadata;
|
|
2491
|
+
return { ...metadata ?? {}, agent_id: agentId };
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// src/util/canonical-json.ts
|
|
2495
|
+
function canonicalize(value) {
|
|
2496
|
+
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2497
|
+
return encoded ?? "";
|
|
2498
|
+
}
|
|
2499
|
+
function sortKeysDeep(value) {
|
|
2500
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2501
|
+
if (value !== null && typeof value === "object") {
|
|
2502
|
+
const source = value;
|
|
2503
|
+
const out = {};
|
|
2504
|
+
for (const key of Object.keys(source).sort()) {
|
|
2505
|
+
Object.defineProperty(out, key, {
|
|
2506
|
+
value: sortKeysDeep(source[key]),
|
|
2507
|
+
enumerable: true,
|
|
2508
|
+
writable: true,
|
|
2509
|
+
configurable: true
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
return out;
|
|
2513
|
+
}
|
|
2514
|
+
return value;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// src/policy/annotation-cache.ts
|
|
2518
|
+
var ASPECT_FIELDS = [
|
|
2519
|
+
"annotations",
|
|
2520
|
+
"inputSchema",
|
|
2521
|
+
"description",
|
|
2522
|
+
"outputSchema",
|
|
2523
|
+
"title"
|
|
2524
|
+
];
|
|
2525
|
+
var ToolAnnotationCache = class {
|
|
2526
|
+
baselines = /* @__PURE__ */ new Map();
|
|
2527
|
+
present = /* @__PURE__ */ new Set();
|
|
2528
|
+
currentAnnotations = /* @__PURE__ */ new Map();
|
|
2529
|
+
driftedTools = /* @__PURE__ */ new Map();
|
|
2530
|
+
/** Number of tools present in the most recent tools/list. */
|
|
2531
|
+
get size() {
|
|
2532
|
+
return this.present.size;
|
|
2533
|
+
}
|
|
2534
|
+
/** Diff a tools/list JSON-RPC response body against the baselines. */
|
|
2535
|
+
update(responseBody) {
|
|
2536
|
+
const tools = extractTools(responseBody);
|
|
2537
|
+
if (!tools) return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2538
|
+
const baselined = [];
|
|
2539
|
+
const drifted = [];
|
|
2540
|
+
const reverted = [];
|
|
2541
|
+
const present = /* @__PURE__ */ new Set();
|
|
2542
|
+
const currentAnnotations = /* @__PURE__ */ new Map();
|
|
2543
|
+
const entries = [];
|
|
2544
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
2545
|
+
for (const tool of tools) {
|
|
2546
|
+
if (typeof tool !== "object" || tool === null) continue;
|
|
2547
|
+
const t = tool;
|
|
2548
|
+
const name = t["name"];
|
|
2549
|
+
if (typeof name !== "string") continue;
|
|
2550
|
+
entries.push({ name, definition: t });
|
|
2551
|
+
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
|
2552
|
+
}
|
|
2553
|
+
const duplicateNames = /* @__PURE__ */ new Set();
|
|
2554
|
+
for (const { name, definition: t } of entries) {
|
|
2555
|
+
const isDuplicate = (nameCounts.get(name) ?? 0) > 1;
|
|
2556
|
+
if (isDuplicate) {
|
|
2557
|
+
present.add(name);
|
|
2558
|
+
currentAnnotations.set(name, void 0);
|
|
2559
|
+
if (duplicateNames.has(name)) continue;
|
|
2560
|
+
duplicateNames.add(name);
|
|
2561
|
+
const baseline2 = this.baselines.get(name);
|
|
2562
|
+
const allDefinitions = entries.filter((e) => e.name === name).map((e) => e.definition);
|
|
2563
|
+
const changes2 = [
|
|
2564
|
+
{
|
|
2565
|
+
aspect: "duplicate",
|
|
2566
|
+
baseline: baseline2?.definition,
|
|
2567
|
+
current: allDefinitions
|
|
2568
|
+
}
|
|
2569
|
+
];
|
|
2570
|
+
const event2 = { toolName: name, changes: changes2 };
|
|
2571
|
+
const existing2 = this.driftedTools.get(name);
|
|
2572
|
+
const isNewDrift2 = !existing2 || canonicalize(existing2.changes) !== canonicalize(changes2);
|
|
2573
|
+
this.driftedTools.set(name, event2);
|
|
2574
|
+
if (isNewDrift2) drifted.push(event2);
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
present.add(name);
|
|
2578
|
+
const annotations = extractAnnotations(t);
|
|
2579
|
+
currentAnnotations.set(name, annotations);
|
|
2580
|
+
const definitionKey = canonicalize(t);
|
|
2581
|
+
const baseline = this.baselines.get(name);
|
|
2582
|
+
if (!baseline) {
|
|
2583
|
+
this.baselines.set(name, { definition: t, definitionKey, annotations });
|
|
2584
|
+
baselined.push(name);
|
|
2585
|
+
if (this.driftedTools.has(name)) {
|
|
2586
|
+
this.driftedTools.delete(name);
|
|
2587
|
+
reverted.push(name);
|
|
2588
|
+
}
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
if (definitionKey === baseline.definitionKey) {
|
|
2592
|
+
if (this.driftedTools.has(name)) {
|
|
2593
|
+
this.driftedTools.delete(name);
|
|
2594
|
+
reverted.push(name);
|
|
2595
|
+
}
|
|
2596
|
+
continue;
|
|
2597
|
+
}
|
|
2598
|
+
const changes = [];
|
|
2599
|
+
for (const field of ASPECT_FIELDS) {
|
|
2600
|
+
const baselineValue = baseline.definition[field];
|
|
2601
|
+
const currentValue = t[field];
|
|
2602
|
+
if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
|
|
2603
|
+
changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (changes.length === 0) {
|
|
2607
|
+
changes.push({ aspect: "other", baseline: baseline.definition, current: t });
|
|
2608
|
+
}
|
|
2609
|
+
const event = { toolName: name, changes };
|
|
2610
|
+
const existing = this.driftedTools.get(name);
|
|
2611
|
+
const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
|
|
2612
|
+
this.driftedTools.set(name, event);
|
|
2613
|
+
if (isNewDrift) drifted.push(event);
|
|
2614
|
+
}
|
|
2615
|
+
this.present = present;
|
|
2616
|
+
this.currentAnnotations = currentAnnotations;
|
|
2617
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Incrementally merge a single tool definition into the cache (issue #12, D6).
|
|
2621
|
+
*
|
|
2622
|
+
* Unlike {@link update}, this touches only the named tool: it adds to (never
|
|
2623
|
+
* rebuilds) the `present` set and `currentAnnotations` map. The sideband
|
|
2624
|
+
* governance path feeds adapter-origin tools one definition at a time (each
|
|
2625
|
+
* `/evaluate` carries at most one), so routing them through the whole-list
|
|
2626
|
+
* `update()` would wipe every other tool's current-annotation snapshot on
|
|
2627
|
+
* each call and silently degrade the stricter-of-both log-mode drift
|
|
2628
|
+
* evaluation. The MCP whole-list path is unaffected — it keeps calling
|
|
2629
|
+
* `update()`. Each origin owns its own cache instance, so the accumulate
|
|
2630
|
+
* semantics here never mix with update()'s replace semantics.
|
|
2631
|
+
*
|
|
2632
|
+
* `toolDefinition` must already be in MCP shape (`inputSchema`/`outputSchema`
|
|
2633
|
+
* camelCase); the governance service maps the wire `tool` object before
|
|
2634
|
+
* calling. Returns the same result shape as `update()` (for one tool).
|
|
2635
|
+
*/
|
|
2636
|
+
updateSingle(toolDefinition) {
|
|
2637
|
+
if (typeof toolDefinition !== "object" || toolDefinition === null) {
|
|
2638
|
+
return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2639
|
+
}
|
|
2640
|
+
const t = toolDefinition;
|
|
2641
|
+
const name = t["name"];
|
|
2642
|
+
if (typeof name !== "string") {
|
|
2643
|
+
return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2644
|
+
}
|
|
2645
|
+
const baselined = [];
|
|
2646
|
+
const drifted = [];
|
|
2647
|
+
const reverted = [];
|
|
2648
|
+
this.present.add(name);
|
|
2649
|
+
const annotations = extractAnnotations(t);
|
|
2650
|
+
this.currentAnnotations.set(name, annotations);
|
|
2651
|
+
const definitionKey = canonicalize(t);
|
|
2652
|
+
const baseline = this.baselines.get(name);
|
|
2653
|
+
if (!baseline) {
|
|
2654
|
+
this.baselines.set(name, { definition: t, definitionKey, annotations });
|
|
2655
|
+
baselined.push(name);
|
|
2656
|
+
if (this.driftedTools.has(name)) {
|
|
2657
|
+
this.driftedTools.delete(name);
|
|
2658
|
+
reverted.push(name);
|
|
2659
|
+
}
|
|
2660
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2661
|
+
}
|
|
2662
|
+
if (definitionKey === baseline.definitionKey) {
|
|
2663
|
+
if (this.driftedTools.has(name)) {
|
|
2664
|
+
this.driftedTools.delete(name);
|
|
2665
|
+
reverted.push(name);
|
|
2666
|
+
}
|
|
2667
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2668
|
+
}
|
|
2669
|
+
const changes = [];
|
|
2670
|
+
for (const field of ASPECT_FIELDS) {
|
|
2671
|
+
const baselineValue = baseline.definition[field];
|
|
2672
|
+
const currentValue = t[field];
|
|
2673
|
+
if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
|
|
2674
|
+
changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
if (changes.length === 0) {
|
|
2678
|
+
changes.push({ aspect: "other", baseline: baseline.definition, current: t });
|
|
2679
|
+
}
|
|
2680
|
+
const event = { toolName: name, changes };
|
|
2681
|
+
const existing = this.driftedTools.get(name);
|
|
2682
|
+
const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
|
|
2683
|
+
this.driftedTools.set(name, event);
|
|
2684
|
+
if (isNewDrift) drifted.push(event);
|
|
2685
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Get the **baseline** annotations for a tool — the definition first seen,
|
|
2689
|
+
* not the latest upstream claim. Returns `undefined` if the tool has no
|
|
2690
|
+
* annotations or was never seen.
|
|
2691
|
+
*/
|
|
2692
|
+
get(toolName) {
|
|
2693
|
+
return this.baselines.get(toolName)?.annotations;
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* Get the annotations from the most recent tools/list. Used for the
|
|
2697
|
+
* stricter-of-both evaluation of drifted tools in on_tool_drift: log mode.
|
|
2698
|
+
* Returns `undefined` for tools absent from the latest list.
|
|
2699
|
+
*/
|
|
2700
|
+
getCurrent(toolName) {
|
|
2701
|
+
return this.currentAnnotations.get(toolName);
|
|
2702
|
+
}
|
|
2703
|
+
/** Whether the tool was present in the most recent tools/list. */
|
|
2704
|
+
has(toolName) {
|
|
2705
|
+
return this.present.has(toolName);
|
|
2706
|
+
}
|
|
2707
|
+
/** Whether the tool's current definition differs from its baseline. */
|
|
2708
|
+
isDrifted(toolName) {
|
|
2709
|
+
return this.driftedTools.has(toolName);
|
|
2710
|
+
}
|
|
2711
|
+
/** The active drift event for a tool, if any. */
|
|
2712
|
+
getDrift(toolName) {
|
|
2713
|
+
return this.driftedTools.get(toolName);
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
function extractAnnotations(tool) {
|
|
2717
|
+
const annotations = tool["annotations"];
|
|
2718
|
+
return annotations && typeof annotations === "object" ? annotations : void 0;
|
|
2719
|
+
}
|
|
2720
|
+
function extractTools(body) {
|
|
2721
|
+
if (typeof body !== "object" || body === null) return null;
|
|
2722
|
+
const b = body;
|
|
2723
|
+
const result = b["result"];
|
|
2724
|
+
if (typeof result !== "object" || result === null) return null;
|
|
2725
|
+
const r = result;
|
|
2726
|
+
const tools = r["tools"];
|
|
2727
|
+
if (!Array.isArray(tools)) return null;
|
|
2728
|
+
return tools;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2270
2731
|
// src/feedback/self-repair.ts
|
|
2271
2732
|
function ruleInfo(rule) {
|
|
2272
2733
|
return {
|
|
@@ -2414,6 +2875,19 @@ function buildRateLimitedFeedback(decision, result) {
|
|
|
2414
2875
|
retry_allowed: true
|
|
2415
2876
|
};
|
|
2416
2877
|
}
|
|
2878
|
+
function buildToolDriftFeedback(drift, action) {
|
|
2879
|
+
const aspects = drift.changes.map((change) => change.aspect);
|
|
2880
|
+
return {
|
|
2881
|
+
blocked: true,
|
|
2882
|
+
reason: "tool_definition_drift",
|
|
2883
|
+
rule: null,
|
|
2884
|
+
ruleIndex: null,
|
|
2885
|
+
action,
|
|
2886
|
+
drifted_aspects: aspects,
|
|
2887
|
+
suggestion: `The definition of "${drift.toolName}" changed upstream (${aspects.join(", ")}) after Helio baselined it. An operator must review the change; restarting the proxy re-baselines, or the upstream can revert the change.`,
|
|
2888
|
+
retry_allowed: false
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2417
2891
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
2418
2892
|
const { rule, ruleIndex } = ruleInfo(decision.matchedRule);
|
|
2419
2893
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -2465,6 +2939,7 @@ var GovernedForwarder = class {
|
|
|
2465
2939
|
spendLimiter;
|
|
2466
2940
|
annotationCache = new ToolAnnotationCache();
|
|
2467
2941
|
agentKeyWarned = false;
|
|
2942
|
+
senderKeyWarned = false;
|
|
2468
2943
|
constructor(inner, policy, options) {
|
|
2469
2944
|
this.inner = inner;
|
|
2470
2945
|
this.policy = policy;
|
|
@@ -2550,8 +3025,8 @@ var GovernedForwarder = class {
|
|
|
2550
3025
|
reason: classifyPrimeFailure(result.response)
|
|
2551
3026
|
};
|
|
2552
3027
|
}
|
|
2553
|
-
const
|
|
2554
|
-
if (!updated) {
|
|
3028
|
+
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
3029
|
+
if (!update.updated) {
|
|
2555
3030
|
return {
|
|
2556
3031
|
success: false,
|
|
2557
3032
|
toolsCached: this.annotationCache.size,
|
|
@@ -2573,10 +3048,65 @@ var GovernedForwarder = class {
|
|
|
2573
3048
|
}
|
|
2574
3049
|
const result = await this.inner.forward(request);
|
|
2575
3050
|
if (request.method === "tools/list") {
|
|
2576
|
-
this.
|
|
3051
|
+
this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
|
|
2577
3052
|
}
|
|
2578
3053
|
return result;
|
|
2579
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Apply a tools/list response to the definition cache and surface any
|
|
3057
|
+
* drift: console warning + immediate audit record per event. Single entry
|
|
3058
|
+
* point for both runtime tools/list responses and startup priming, so the
|
|
3059
|
+
* cache is updated exactly once per response.
|
|
3060
|
+
*/
|
|
3061
|
+
applyToolDefinitionUpdate(responseBody, sessionId) {
|
|
3062
|
+
const update = this.annotationCache.update(responseBody);
|
|
3063
|
+
if (!update.updated) return update;
|
|
3064
|
+
for (const drift of update.drifted) {
|
|
3065
|
+
const aspects = drift.changes.map((change) => change.aspect).join(", ");
|
|
3066
|
+
console.error(
|
|
3067
|
+
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
3068
|
+
);
|
|
3069
|
+
this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
|
|
3070
|
+
}
|
|
3071
|
+
for (const toolName of update.reverted) {
|
|
3072
|
+
console.error(
|
|
3073
|
+
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
3074
|
+
);
|
|
3075
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
|
|
3076
|
+
}
|
|
3077
|
+
return update;
|
|
3078
|
+
}
|
|
3079
|
+
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
3080
|
+
writeDriftAuditRecord(drift, sessionId, decision) {
|
|
3081
|
+
if (!this.auditWriter) return;
|
|
3082
|
+
this.auditWriter.pushImmediate({
|
|
3083
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3084
|
+
session_id: sessionId ?? null,
|
|
3085
|
+
agent_id: null,
|
|
3086
|
+
environment: this.environment ?? null,
|
|
3087
|
+
tool_name: drift.toolName,
|
|
3088
|
+
tool_input: {},
|
|
3089
|
+
policy_decision: decision,
|
|
3090
|
+
block_reason: null,
|
|
3091
|
+
matched_rule: null,
|
|
3092
|
+
matched_rule_index: null,
|
|
3093
|
+
evidence_chain: decision === "tool_drift" ? { tool_drift: { changes: drift.changes } } : null,
|
|
3094
|
+
approval_status: null,
|
|
3095
|
+
approved_by: null,
|
|
3096
|
+
upstream_response: null,
|
|
3097
|
+
upstream_error: null,
|
|
3098
|
+
upstream_http_status: null,
|
|
3099
|
+
upstream_latency_ms: null,
|
|
3100
|
+
total_duration_ms: 0,
|
|
3101
|
+
approval_wait_ms: 0,
|
|
3102
|
+
proxy_compute_ms: 0,
|
|
3103
|
+
flagged_destructive: false,
|
|
3104
|
+
dry_run: false,
|
|
3105
|
+
record_kind: "drift_event",
|
|
3106
|
+
origin: "mcp",
|
|
3107
|
+
metadata: null
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
2580
3110
|
async handleToolsCall(request) {
|
|
2581
3111
|
const startTime = performance.now();
|
|
2582
3112
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2586,77 +3116,28 @@ var GovernedForwarder = class {
|
|
|
2586
3116
|
return this.inner.forward(request);
|
|
2587
3117
|
}
|
|
2588
3118
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
2589
|
-
const
|
|
2590
|
-
|
|
3119
|
+
const {
|
|
3120
|
+
decision,
|
|
3121
|
+
driftEvent,
|
|
3122
|
+
driftMode,
|
|
3123
|
+
driftBlocked,
|
|
3124
|
+
flaggedDestructive,
|
|
3125
|
+
evidenceResult,
|
|
3126
|
+
dependencyResult,
|
|
3127
|
+
evidenceBlocked,
|
|
3128
|
+
sessionBlocked,
|
|
3129
|
+
isDryRun
|
|
3130
|
+
} = decide({
|
|
2591
3131
|
toolName,
|
|
2592
|
-
annotations,
|
|
2593
3132
|
toolArguments,
|
|
2594
|
-
|
|
3133
|
+
sessionId: request.sessionId,
|
|
3134
|
+
policy: this.policy,
|
|
3135
|
+
environment: this.environment,
|
|
3136
|
+
evidenceStore: this.evidenceStore,
|
|
3137
|
+
baselineAnnotations: this.annotationCache.get(toolName),
|
|
3138
|
+
currentAnnotations: this.annotationCache.getCurrent(toolName),
|
|
3139
|
+
driftEvent: this.annotationCache.getDrift(toolName)
|
|
2595
3140
|
});
|
|
2596
|
-
const isDestructive = annotations?.destructiveHint ?? true;
|
|
2597
|
-
let flaggedDestructive = false;
|
|
2598
|
-
if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
|
|
2599
|
-
flaggedDestructive = true;
|
|
2600
|
-
if (this.policy.flagDestructive === "log") {
|
|
2601
|
-
console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
|
|
2602
|
-
} else {
|
|
2603
|
-
decision = {
|
|
2604
|
-
action: "require_approval",
|
|
2605
|
-
matchedRule: void 0,
|
|
2606
|
-
reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
|
|
2607
|
-
};
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
const originalAction = decision.action;
|
|
2611
|
-
let evidenceResult;
|
|
2612
|
-
let dependencyResult;
|
|
2613
|
-
let evidenceBlocked = false;
|
|
2614
|
-
let sessionBlocked = false;
|
|
2615
|
-
const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
|
|
2616
|
-
if (requiresGroundedSession && !request.sessionId) {
|
|
2617
|
-
sessionBlocked = true;
|
|
2618
|
-
evidenceBlocked = true;
|
|
2619
|
-
decision = {
|
|
2620
|
-
action: "deny",
|
|
2621
|
-
matchedRule: decision.matchedRule,
|
|
2622
|
-
reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
|
|
2623
|
-
};
|
|
2624
|
-
}
|
|
2625
|
-
if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
|
|
2626
|
-
const rule = decision.matchedRule;
|
|
2627
|
-
if (rule.evidence?.requires.length) {
|
|
2628
|
-
evidenceResult = checkEvidence(
|
|
2629
|
-
this.evidenceStore,
|
|
2630
|
-
request.sessionId,
|
|
2631
|
-
rule.evidence.requires
|
|
2632
|
-
);
|
|
2633
|
-
if (!evidenceResult.satisfied) {
|
|
2634
|
-
evidenceBlocked = true;
|
|
2635
|
-
const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
|
|
2636
|
-
decision = {
|
|
2637
|
-
action: "deny",
|
|
2638
|
-
matchedRule: rule,
|
|
2639
|
-
reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
|
|
2640
|
-
};
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
if (!evidenceBlocked && rule.requires?.length) {
|
|
2644
|
-
dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
|
|
2645
|
-
requireSuccess: rule.requiresSuccess ?? true
|
|
2646
|
-
});
|
|
2647
|
-
if (!dependencyResult.satisfied) {
|
|
2648
|
-
evidenceBlocked = true;
|
|
2649
|
-
decision = {
|
|
2650
|
-
action: "deny",
|
|
2651
|
-
matchedRule: rule,
|
|
2652
|
-
reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
|
|
2653
|
-
};
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
}
|
|
2657
|
-
const isPerRuleDryRun = originalAction === "dry_run";
|
|
2658
|
-
const isGlobalDryRun = this.policy.dryRun === true;
|
|
2659
|
-
const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
|
|
2660
3141
|
let result;
|
|
2661
3142
|
let approvalOutcome;
|
|
2662
3143
|
let approvalWaitMs = 0;
|
|
@@ -2670,6 +3151,8 @@ var GovernedForwarder = class {
|
|
|
2670
3151
|
result = this.makeSessionRequiredBlockResult(request, decision);
|
|
2671
3152
|
} else if (evidenceBlocked) {
|
|
2672
3153
|
result = this.makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult);
|
|
3154
|
+
} else if (driftBlocked && driftEvent) {
|
|
3155
|
+
result = this.makeDriftBlockResult(request, driftEvent);
|
|
2673
3156
|
} else if (decision.action === "allow") {
|
|
2674
3157
|
result = await this.inner.forward(request);
|
|
2675
3158
|
} else if (decision.action === "deny") {
|
|
@@ -2742,7 +3225,8 @@ var GovernedForwarder = class {
|
|
|
2742
3225
|
rateLimitResult,
|
|
2743
3226
|
spendLimitResult,
|
|
2744
3227
|
isDryRun,
|
|
2745
|
-
forwardingError
|
|
3228
|
+
forwardingError,
|
|
3229
|
+
driftEvent ? { event: driftEvent, mode: driftMode } : void 0
|
|
2746
3230
|
);
|
|
2747
3231
|
return result;
|
|
2748
3232
|
}
|
|
@@ -2950,6 +3434,14 @@ var GovernedForwarder = class {
|
|
|
2950
3434
|
);
|
|
2951
3435
|
}
|
|
2952
3436
|
return `tool:${toolName}`;
|
|
3437
|
+
case "sender_id":
|
|
3438
|
+
if (!this.senderKeyWarned) {
|
|
3439
|
+
this.senderKeyWarned = true;
|
|
3440
|
+
console.error(
|
|
3441
|
+
'[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
return `tool:${toolName}`;
|
|
2953
3445
|
case "tool":
|
|
2954
3446
|
default:
|
|
2955
3447
|
return `tool:${toolName}`;
|
|
@@ -2959,7 +3451,7 @@ var GovernedForwarder = class {
|
|
|
2959
3451
|
wasForwardedUpstream(decision, approvalOutcome, rateLimitResult, spendLimitResult) {
|
|
2960
3452
|
return decision.action === "allow" || approvalOutcome?.status === "approved" || approvalOutcome?.status === "break_glass" || approvalOutcome?.status === "timeout" && this.approvalRouter?.defaultOnTimeout === "allow" || rateLimitResult?.allowed === true || spendLimitResult?.allowed === true;
|
|
2961
3453
|
}
|
|
2962
|
-
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError) {
|
|
3454
|
+
writeAuditRecord(request, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, rateLimitResult, spendLimitResult, isDryRun, forwardingError, drift) {
|
|
2963
3455
|
if (!this.auditWriter) return;
|
|
2964
3456
|
const wasForwarded = this.wasForwardedUpstream(
|
|
2965
3457
|
decision,
|
|
@@ -3019,10 +3511,19 @@ var GovernedForwarder = class {
|
|
|
3019
3511
|
}
|
|
3020
3512
|
};
|
|
3021
3513
|
}
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3514
|
+
if (drift) {
|
|
3515
|
+
evidenceChain = {
|
|
3516
|
+
...evidenceChain ?? {},
|
|
3517
|
+
tool_drift: {
|
|
3518
|
+
mode: drift.mode,
|
|
3519
|
+
changes: drift.event.changes
|
|
3520
|
+
}
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
const blockReason = extractBlockReason(result);
|
|
3524
|
+
const record = {
|
|
3525
|
+
timestamp,
|
|
3526
|
+
session_id: request.sessionId ?? null,
|
|
3026
3527
|
agent_id: null,
|
|
3027
3528
|
environment: this.environment ?? null,
|
|
3028
3529
|
tool_name: toolName,
|
|
@@ -3042,7 +3543,10 @@ var GovernedForwarder = class {
|
|
|
3042
3543
|
approval_wait_ms: approvalWaitMs,
|
|
3043
3544
|
proxy_compute_ms: proxyComputeMs,
|
|
3044
3545
|
flagged_destructive: flaggedDestructive,
|
|
3045
|
-
dry_run: isDryRun ?? false
|
|
3546
|
+
dry_run: isDryRun ?? false,
|
|
3547
|
+
record_kind: "tool_call",
|
|
3548
|
+
origin: "mcp",
|
|
3549
|
+
metadata: null
|
|
3046
3550
|
};
|
|
3047
3551
|
const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
|
|
3048
3552
|
if (isEnforcementDecision) {
|
|
@@ -3051,6 +3555,15 @@ var GovernedForwarder = class {
|
|
|
3051
3555
|
this.auditWriter.push(record);
|
|
3052
3556
|
}
|
|
3053
3557
|
}
|
|
3558
|
+
makeDriftBlockResult(request, drift) {
|
|
3559
|
+
const feedback = buildToolDriftFeedback(drift, "deny");
|
|
3560
|
+
return makeErrorResult(
|
|
3561
|
+
request,
|
|
3562
|
+
POLICY_DENIED,
|
|
3563
|
+
`Tool definition drift: "${drift.toolName}" changed after baseline`,
|
|
3564
|
+
{ ...feedback }
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3054
3567
|
makeDenyResult(request, decision) {
|
|
3055
3568
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
3056
3569
|
const message = decision.matchedRule?.feedback?.message ?? `Policy denied: ${decision.reason}`;
|
|
@@ -3271,6 +3784,46 @@ var RateLimiter = class {
|
|
|
3271
3784
|
resetAtMs
|
|
3272
3785
|
};
|
|
3273
3786
|
}
|
|
3787
|
+
/**
|
|
3788
|
+
* Unconditionally record a call against the rate limit.
|
|
3789
|
+
*
|
|
3790
|
+
* Unlike check(), this always appends the timestamp — even when the bucket
|
|
3791
|
+
* is already at/over the limit — because the call it represents has already
|
|
3792
|
+
* executed. The sideband splits decision from execution: /evaluate peeks
|
|
3793
|
+
* (non-destructive), and /audit calls record() once the external call ran,
|
|
3794
|
+
* so refusing to record at the limit (as check() does) would let real calls
|
|
3795
|
+
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
3796
|
+
*
|
|
3797
|
+
* Warnings fire only while the post-append count stays within the limit —
|
|
3798
|
+
* exact parity with check(), which never warns on its over-limit path — so a
|
|
3799
|
+
* burst of over-limit audits cannot flood the dashboard's limit_warning feed.
|
|
3800
|
+
*/
|
|
3801
|
+
record(params) {
|
|
3802
|
+
const { key, maxCalls, windowMs } = params;
|
|
3803
|
+
const now = this.now();
|
|
3804
|
+
const windowStart = now - windowMs;
|
|
3805
|
+
let bucket = this.buckets.get(key);
|
|
3806
|
+
if (!bucket) {
|
|
3807
|
+
bucket = { timestamps: [], maxCalls, windowMs };
|
|
3808
|
+
this.buckets.set(key, bucket);
|
|
3809
|
+
}
|
|
3810
|
+
bucket.maxCalls = maxCalls;
|
|
3811
|
+
bucket.windowMs = windowMs;
|
|
3812
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
3813
|
+
bucket.timestamps.push(now);
|
|
3814
|
+
const current = bucket.timestamps.length;
|
|
3815
|
+
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
3816
|
+
if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
|
|
3817
|
+
this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
3818
|
+
}
|
|
3819
|
+
return {
|
|
3820
|
+
allowed: current <= maxCalls,
|
|
3821
|
+
current,
|
|
3822
|
+
limit: maxCalls,
|
|
3823
|
+
windowMs,
|
|
3824
|
+
resetAtMs
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3274
3827
|
/**
|
|
3275
3828
|
* Check the rate limit without recording the call (non-destructive).
|
|
3276
3829
|
*
|
|
@@ -3485,6 +4038,57 @@ var SpendLimiter = class {
|
|
|
3485
4038
|
resetAtMs
|
|
3486
4039
|
};
|
|
3487
4040
|
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Unconditionally record a spend against the limit.
|
|
4043
|
+
*
|
|
4044
|
+
* Unlike check(), this always appends the amount — even when it pushes the
|
|
4045
|
+
* window past the limit — because the spend it represents has already been
|
|
4046
|
+
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
4047
|
+
* the external call ran (issue #12, D3).
|
|
4048
|
+
*
|
|
4049
|
+
* Throws on a negative or non-finite amount: such amounts are rejected at
|
|
4050
|
+
* /evaluate, so one reaching record() is a logic bug we surface loudly rather
|
|
4051
|
+
* than silently corrupt the sliding-window sum. Warnings fire only while the
|
|
4052
|
+
* post-append spend stays within the limit (parity with check()).
|
|
4053
|
+
*/
|
|
4054
|
+
record(params) {
|
|
4055
|
+
const { key, amount, limit, windowMs } = params;
|
|
4056
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
4057
|
+
throw new RangeError(
|
|
4058
|
+
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
4059
|
+
);
|
|
4060
|
+
}
|
|
4061
|
+
const now = this.now();
|
|
4062
|
+
const windowStart = now - windowMs;
|
|
4063
|
+
let bucket = this.buckets.get(key);
|
|
4064
|
+
if (!bucket) {
|
|
4065
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
4066
|
+
this.buckets.set(key, bucket);
|
|
4067
|
+
}
|
|
4068
|
+
bucket.limit = limit;
|
|
4069
|
+
bucket.windowMs = windowMs;
|
|
4070
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4071
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
4072
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4073
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
4074
|
+
if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
|
|
4075
|
+
this.onWarning({
|
|
4076
|
+
key,
|
|
4077
|
+
current_spend: currentSpend,
|
|
4078
|
+
limit,
|
|
4079
|
+
currency: bucket.currency,
|
|
4080
|
+
window_ms: windowMs,
|
|
4081
|
+
reset_at_ms: resetAtMs
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
return {
|
|
4085
|
+
allowed: currentSpend <= limit,
|
|
4086
|
+
currentSpend,
|
|
4087
|
+
limit,
|
|
4088
|
+
windowMs,
|
|
4089
|
+
resetAtMs
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
3488
4092
|
/**
|
|
3489
4093
|
* Check the spend limit without recording the spend (non-destructive).
|
|
3490
4094
|
*
|
|
@@ -3930,8 +4534,9 @@ var EvidenceStore = class _EvidenceStore {
|
|
|
3930
4534
|
};
|
|
3931
4535
|
|
|
3932
4536
|
// src/evidence/api.ts
|
|
3933
|
-
import { Hono as
|
|
3934
|
-
import {
|
|
4537
|
+
import { Hono as Hono5 } from "hono";
|
|
4538
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4539
|
+
import { z as z5 } from "zod";
|
|
3935
4540
|
|
|
3936
4541
|
// src/auth/bearer.ts
|
|
3937
4542
|
import { createHash, timingSafeEqual } from "crypto";
|
|
@@ -3943,22 +4548,176 @@ function verifyBearer(authHeader, expected) {
|
|
|
3943
4548
|
return timingSafeEqual(actualDigest, expectedDigest);
|
|
3944
4549
|
}
|
|
3945
4550
|
|
|
4551
|
+
// src/sideband/governance-api.ts
|
|
4552
|
+
import { Hono as Hono4 } from "hono";
|
|
4553
|
+
import { z as z4 } from "zod";
|
|
4554
|
+
import { createHash as createHash2 } from "crypto";
|
|
4555
|
+
var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
|
|
4556
|
+
var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
|
|
4557
|
+
var toolDefinitionSchema = z4.object({
|
|
4558
|
+
name: z4.string().min(1),
|
|
4559
|
+
description: z4.string().optional(),
|
|
4560
|
+
input_schema: z4.unknown().optional(),
|
|
4561
|
+
output_schema: z4.unknown().optional(),
|
|
4562
|
+
title: z4.string().optional(),
|
|
4563
|
+
annotations: z4.record(z4.string(), z4.unknown()).optional()
|
|
4564
|
+
});
|
|
4565
|
+
var evaluateBody = z4.object({
|
|
4566
|
+
origin: originSchema,
|
|
4567
|
+
adapter_version: z4.string().max(64).optional(),
|
|
4568
|
+
agent_id: z4.string().nullish(),
|
|
4569
|
+
session_id: z4.string().nullish(),
|
|
4570
|
+
tool: toolDefinitionSchema,
|
|
4571
|
+
arguments: z4.record(z4.string(), z4.unknown()).optional(),
|
|
4572
|
+
metadata: metadataSchema
|
|
4573
|
+
});
|
|
4574
|
+
var installScanBody = z4.object({
|
|
4575
|
+
origin: originSchema,
|
|
4576
|
+
agent_id: z4.string().nullish(),
|
|
4577
|
+
session_id: z4.string().nullish(),
|
|
4578
|
+
package: z4.object({
|
|
4579
|
+
name: z4.string().min(1),
|
|
4580
|
+
version: z4.string().optional(),
|
|
4581
|
+
source: z4.string().max(64).optional(),
|
|
4582
|
+
spec: z4.string().optional(),
|
|
4583
|
+
url: z4.string().optional()
|
|
4584
|
+
}),
|
|
4585
|
+
metadata: metadataSchema
|
|
4586
|
+
});
|
|
4587
|
+
var auditBody = z4.object({
|
|
4588
|
+
evaluation_id: z4.string().min(1),
|
|
4589
|
+
status: z4.enum(["success", "error", "not_executed"]),
|
|
4590
|
+
error: z4.string().optional(),
|
|
4591
|
+
duration_ms: z4.number().optional(),
|
|
4592
|
+
result: z4.unknown().optional(),
|
|
4593
|
+
actual_amount: z4.number().optional()
|
|
4594
|
+
});
|
|
4595
|
+
var resolveBody = z4.object({
|
|
4596
|
+
resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
|
|
4597
|
+
resolved_by: z4.string().optional(),
|
|
4598
|
+
reason: z4.string().optional(),
|
|
4599
|
+
scope: z4.enum(["once", "always"]).optional()
|
|
4600
|
+
});
|
|
4601
|
+
var MAX_METADATA_BYTES = 4 * 1024;
|
|
4602
|
+
function createGovernanceApp(service) {
|
|
4603
|
+
const app = new Hono4();
|
|
4604
|
+
const unavailable = () => ({ error: "governance_unavailable" });
|
|
4605
|
+
app.post("/evaluate", async (c) => {
|
|
4606
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4607
|
+
const parsed = await parseJson(c);
|
|
4608
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4609
|
+
const result = evaluateBody.safeParse(parsed.body);
|
|
4610
|
+
if (!result.success) {
|
|
4611
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4612
|
+
}
|
|
4613
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
4614
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
4615
|
+
}
|
|
4616
|
+
const r = service.evaluate({
|
|
4617
|
+
origin: result.data.origin,
|
|
4618
|
+
adapter_version: result.data.adapter_version,
|
|
4619
|
+
agent_id: result.data.agent_id ?? null,
|
|
4620
|
+
session_id: result.data.session_id ?? null,
|
|
4621
|
+
tool: result.data.tool,
|
|
4622
|
+
arguments: result.data.arguments,
|
|
4623
|
+
metadata: result.data.metadata ?? null
|
|
4624
|
+
});
|
|
4625
|
+
return c.json(r.body, asStatus(r.status));
|
|
4626
|
+
});
|
|
4627
|
+
app.post("/audit", async (c) => {
|
|
4628
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4629
|
+
const parsed = await parseJson(c);
|
|
4630
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4631
|
+
const result = auditBody.safeParse(parsed.body);
|
|
4632
|
+
if (!result.success) {
|
|
4633
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4634
|
+
}
|
|
4635
|
+
const hash = auditPayloadHash(result.data);
|
|
4636
|
+
const r = service.audit(result.data, hash);
|
|
4637
|
+
return c.json(r.body, asStatus(r.status));
|
|
4638
|
+
});
|
|
4639
|
+
app.post("/install-scan", async (c) => {
|
|
4640
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4641
|
+
const parsed = await parseJson(c);
|
|
4642
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4643
|
+
const result = installScanBody.safeParse(parsed.body);
|
|
4644
|
+
if (!result.success) {
|
|
4645
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4646
|
+
}
|
|
4647
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
4648
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
4649
|
+
}
|
|
4650
|
+
const r = service.installScan({
|
|
4651
|
+
origin: result.data.origin,
|
|
4652
|
+
agent_id: result.data.agent_id ?? null,
|
|
4653
|
+
session_id: result.data.session_id ?? null,
|
|
4654
|
+
package: result.data.package,
|
|
4655
|
+
metadata: result.data.metadata ?? null
|
|
4656
|
+
});
|
|
4657
|
+
return c.json(r.body, asStatus(r.status));
|
|
4658
|
+
});
|
|
4659
|
+
app.post("/approval/:id/resolve", async (c) => {
|
|
4660
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4661
|
+
const parsed = await parseJson(c);
|
|
4662
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4663
|
+
const result = resolveBody.safeParse(parsed.body);
|
|
4664
|
+
if (!result.success) {
|
|
4665
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4666
|
+
}
|
|
4667
|
+
if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
|
|
4668
|
+
return c.json({ error: "resolved_by is required for approved/denied" }, 400);
|
|
4669
|
+
}
|
|
4670
|
+
const r = service.resolveApproval(c.req.param("id"), result.data);
|
|
4671
|
+
return c.json(r.body, asStatus(r.status));
|
|
4672
|
+
});
|
|
4673
|
+
return app;
|
|
4674
|
+
}
|
|
4675
|
+
function isGovernancePath(path) {
|
|
4676
|
+
return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
|
|
4677
|
+
}
|
|
4678
|
+
async function parseJson(c) {
|
|
4679
|
+
try {
|
|
4680
|
+
return { body: await c.req.json() };
|
|
4681
|
+
} catch {
|
|
4682
|
+
return { error: { error: "Invalid JSON" } };
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
function metadataTooLarge(metadata) {
|
|
4686
|
+
if (metadata == null) return false;
|
|
4687
|
+
return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
|
|
4688
|
+
}
|
|
4689
|
+
function auditPayloadHash(data) {
|
|
4690
|
+
const semantic = {
|
|
4691
|
+
status: data.status,
|
|
4692
|
+
error: data.error ?? null,
|
|
4693
|
+
duration_ms: data.duration_ms ?? null,
|
|
4694
|
+
result: data.result ?? null,
|
|
4695
|
+
actual_amount: data.actual_amount ?? null
|
|
4696
|
+
};
|
|
4697
|
+
return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
|
|
4698
|
+
}
|
|
4699
|
+
function asStatus(status) {
|
|
4700
|
+
return status;
|
|
4701
|
+
}
|
|
4702
|
+
|
|
3946
4703
|
// src/evidence/api.ts
|
|
3947
|
-
var
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4704
|
+
var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
4705
|
+
var postEvidenceBody = z5.object({
|
|
4706
|
+
session_id: z5.string().min(1),
|
|
4707
|
+
tool_name: z5.string().min(1),
|
|
4708
|
+
evidence_key: z5.string().min(1),
|
|
4709
|
+
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
4710
|
+
ttl_seconds: z5.number().int().positive().optional()
|
|
3953
4711
|
});
|
|
3954
|
-
var postContextBody =
|
|
3955
|
-
session_id:
|
|
3956
|
-
key:
|
|
3957
|
-
value:
|
|
4712
|
+
var postContextBody = z5.object({
|
|
4713
|
+
session_id: z5.string().min(1),
|
|
4714
|
+
key: z5.string().min(1),
|
|
4715
|
+
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
3958
4716
|
});
|
|
3959
4717
|
function createSidebandApp(store, options = {}) {
|
|
3960
|
-
const app = new
|
|
3961
|
-
const
|
|
4718
|
+
const app = new Hono5();
|
|
4719
|
+
const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
|
|
4720
|
+
const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
|
|
3962
4721
|
app.use("*", async (c, next) => {
|
|
3963
4722
|
const origin = c.req.header("origin");
|
|
3964
4723
|
if (origin) {
|
|
@@ -3969,20 +4728,26 @@ function createSidebandApp(store, options = {}) {
|
|
|
3969
4728
|
}
|
|
3970
4729
|
await next();
|
|
3971
4730
|
});
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
}
|
|
4731
|
+
app.use(
|
|
4732
|
+
"*",
|
|
4733
|
+
bodyLimit({
|
|
4734
|
+
maxSize: SIDEBAND_BODY_LIMIT_BYTES,
|
|
4735
|
+
onError: (c) => c.json({ error: "request_body_too_large" }, 413)
|
|
4736
|
+
})
|
|
4737
|
+
);
|
|
4738
|
+
app.use("*", async (c, next) => {
|
|
4739
|
+
if (c.req.path === "/healthz") {
|
|
3982
4740
|
await next();
|
|
3983
|
-
|
|
3984
|
-
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
|
|
4744
|
+
if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
|
|
4745
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
4746
|
+
}
|
|
4747
|
+
await next();
|
|
4748
|
+
});
|
|
3985
4749
|
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
4750
|
+
app.route("/", createGovernanceApp(options.governance));
|
|
3986
4751
|
app.post("/evidence", async (c) => {
|
|
3987
4752
|
let body;
|
|
3988
4753
|
try {
|
|
@@ -4045,9 +4810,805 @@ function createSidebandApp(store, options = {}) {
|
|
|
4045
4810
|
return app;
|
|
4046
4811
|
}
|
|
4047
4812
|
|
|
4813
|
+
// src/sideband/governance-service.ts
|
|
4814
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
4815
|
+
|
|
4816
|
+
// src/sideband/errors.ts
|
|
4817
|
+
var GovernanceConfigError = class extends Error {
|
|
4818
|
+
constructor(message) {
|
|
4819
|
+
super(message);
|
|
4820
|
+
this.name = "GovernanceConfigError";
|
|
4821
|
+
}
|
|
4822
|
+
};
|
|
4823
|
+
|
|
4824
|
+
// src/sideband/governance-service.ts
|
|
4825
|
+
var MAX_ORIGINS = 32;
|
|
4826
|
+
var MAX_TOOLS_PER_ORIGIN = 1024;
|
|
4827
|
+
var MAX_TOOL_INPUT_BYTES = 64 * 1024;
|
|
4828
|
+
var MAX_PENDING_COUNT = 1e4;
|
|
4829
|
+
var MAX_PENDING_BYTES = 64 * 1024 * 1024;
|
|
4830
|
+
var MAX_SENDER_KEYS = 5e4;
|
|
4831
|
+
var SWEEP_INTERVAL_MS2 = 3e4;
|
|
4832
|
+
var GovernanceService = class {
|
|
4833
|
+
policy;
|
|
4834
|
+
environment;
|
|
4835
|
+
evidenceStore;
|
|
4836
|
+
approvalRouter;
|
|
4837
|
+
rateLimiter;
|
|
4838
|
+
spendLimiter;
|
|
4839
|
+
auditWriter;
|
|
4840
|
+
approvalTimeoutMs;
|
|
4841
|
+
ttlMs;
|
|
4842
|
+
now;
|
|
4843
|
+
maxPending;
|
|
4844
|
+
maxPendingBytes;
|
|
4845
|
+
maxSenderKeys;
|
|
4846
|
+
/** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
|
|
4847
|
+
senderKeys = /* @__PURE__ */ new Set();
|
|
4848
|
+
pending = /* @__PURE__ */ new Map();
|
|
4849
|
+
tombstones = /* @__PURE__ */ new Map();
|
|
4850
|
+
caches = /* @__PURE__ */ new Map();
|
|
4851
|
+
/** Native approval ticket id → its pending evaluation id, for on-access
|
|
4852
|
+
* deadline enforcement on the resolve path. */
|
|
4853
|
+
ticketToEvaluation = /* @__PURE__ */ new Map();
|
|
4854
|
+
pendingBytes = 0;
|
|
4855
|
+
sweepTimer = null;
|
|
4856
|
+
closed = false;
|
|
4857
|
+
constructor(options) {
|
|
4858
|
+
this.policy = options.policy;
|
|
4859
|
+
this.environment = options.environment;
|
|
4860
|
+
this.evidenceStore = options.evidenceStore;
|
|
4861
|
+
this.approvalRouter = options.approvalRouter;
|
|
4862
|
+
this.rateLimiter = options.rateLimiter;
|
|
4863
|
+
this.spendLimiter = options.spendLimiter;
|
|
4864
|
+
this.auditWriter = options.auditWriter;
|
|
4865
|
+
this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
|
|
4866
|
+
this.ttlMs = options.ttlMs ?? 6e5;
|
|
4867
|
+
this.now = options.now ?? Date.now;
|
|
4868
|
+
this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
|
|
4869
|
+
this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
|
|
4870
|
+
this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
|
|
4871
|
+
this.assertApprovalRouter(this.policy);
|
|
4872
|
+
const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
|
|
4873
|
+
if (sweepMs > 0) {
|
|
4874
|
+
this.sweepTimer = setInterval(() => {
|
|
4875
|
+
this.sweep();
|
|
4876
|
+
}, sweepMs);
|
|
4877
|
+
this.sweepTimer.unref();
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
/** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
|
|
4881
|
+
updatePolicy(policy) {
|
|
4882
|
+
this.assertApprovalRouter(policy);
|
|
4883
|
+
this.policy = policy;
|
|
4884
|
+
}
|
|
4885
|
+
// -------------------------------------------------------------------------
|
|
4886
|
+
// POST /evaluate
|
|
4887
|
+
// -------------------------------------------------------------------------
|
|
4888
|
+
evaluate(req) {
|
|
4889
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
4890
|
+
if (reserved) {
|
|
4891
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
4892
|
+
}
|
|
4893
|
+
const inputBytes = byteLength(req.arguments ?? {});
|
|
4894
|
+
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
4895
|
+
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
4896
|
+
}
|
|
4897
|
+
const entryBytes = inputBytes + byteLength(req.metadata ?? {});
|
|
4898
|
+
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
4899
|
+
return { status: 400, body: { error: "origin_limit_exceeded" } };
|
|
4900
|
+
}
|
|
4901
|
+
if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
|
|
4902
|
+
return { status: 503, body: { error: "evaluation_backlog_full" } };
|
|
4903
|
+
}
|
|
4904
|
+
const cache = this.cacheFor(req.origin);
|
|
4905
|
+
const toolName = req.tool.name;
|
|
4906
|
+
const hasDefinition = definitionProvided(req.tool);
|
|
4907
|
+
if (hasDefinition) {
|
|
4908
|
+
if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
|
|
4909
|
+
return { status: 400, body: { error: "tool_baseline_limit" } };
|
|
4910
|
+
}
|
|
4911
|
+
cache.updateSingle(toMcpToolDef(req.tool));
|
|
4912
|
+
}
|
|
4913
|
+
const pipeline = decide({
|
|
4914
|
+
toolName,
|
|
4915
|
+
toolArguments: req.arguments,
|
|
4916
|
+
sessionId: req.session_id ?? void 0,
|
|
4917
|
+
policy: this.policy,
|
|
4918
|
+
environment: this.environment,
|
|
4919
|
+
evidenceStore: this.evidenceStore,
|
|
4920
|
+
baselineAnnotations: cache.get(toolName),
|
|
4921
|
+
currentAnnotations: cache.getCurrent(toolName),
|
|
4922
|
+
driftEvent: cache.getDrift(toolName),
|
|
4923
|
+
metadata: req.metadata ?? void 0,
|
|
4924
|
+
agentId: req.agent_id ?? void 0
|
|
4925
|
+
});
|
|
4926
|
+
const { decision } = pipeline;
|
|
4927
|
+
const evaluationId = randomUUID2();
|
|
4928
|
+
const timestampIso = new Date(this.now()).toISOString();
|
|
4929
|
+
let wire;
|
|
4930
|
+
let limitPlan;
|
|
4931
|
+
let limitsBlock;
|
|
4932
|
+
const senderId = senderIdOf(req.metadata);
|
|
4933
|
+
if (pipeline.isDryRun) {
|
|
4934
|
+
wire = "dry_run";
|
|
4935
|
+
} else if (decision.action === "deny") {
|
|
4936
|
+
wire = "deny";
|
|
4937
|
+
} else if (decision.action === "require_approval") {
|
|
4938
|
+
wire = "require_approval";
|
|
4939
|
+
} else if (decision.action === "rate_limit") {
|
|
4940
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
4941
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
4942
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
4943
|
+
}
|
|
4944
|
+
limitPlan = planned?.plan;
|
|
4945
|
+
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
4946
|
+
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
4947
|
+
} else if (decision.action === "spend_limit") {
|
|
4948
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
4949
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
4950
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
4951
|
+
}
|
|
4952
|
+
limitPlan = planned?.plan;
|
|
4953
|
+
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
4954
|
+
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
4955
|
+
} else {
|
|
4956
|
+
wire = "allow";
|
|
4957
|
+
}
|
|
4958
|
+
const matchedRuleName = decision.matchedRule?.name ?? null;
|
|
4959
|
+
const matchedRuleIndex = decision.matchedRule?.index ?? null;
|
|
4960
|
+
const responseBody = {
|
|
4961
|
+
evaluation_id: evaluationId,
|
|
4962
|
+
decision: wire,
|
|
4963
|
+
reason: decision.reason,
|
|
4964
|
+
matched_rule: matchedRuleName,
|
|
4965
|
+
matched_rule_index: matchedRuleIndex
|
|
4966
|
+
};
|
|
4967
|
+
if (isBlocking(wire)) {
|
|
4968
|
+
responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
|
|
4969
|
+
}
|
|
4970
|
+
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
4971
|
+
if (wire === "dry_run") {
|
|
4972
|
+
responseBody["dry_run"] = {
|
|
4973
|
+
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
|
|
4974
|
+
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
4975
|
+
limits_ok: true
|
|
4976
|
+
};
|
|
4977
|
+
}
|
|
4978
|
+
if (pipeline.driftEvent) {
|
|
4979
|
+
responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
|
|
4980
|
+
}
|
|
4981
|
+
if (isTerminalAtEvaluate(wire)) {
|
|
4982
|
+
const auditId = this.writeAudit({
|
|
4983
|
+
timestampIso,
|
|
4984
|
+
origin: req.origin,
|
|
4985
|
+
agentId: req.agent_id,
|
|
4986
|
+
sessionId: req.session_id,
|
|
4987
|
+
toolName,
|
|
4988
|
+
toolInput: req.arguments ?? {},
|
|
4989
|
+
metadata: req.metadata,
|
|
4990
|
+
action: decision.action,
|
|
4991
|
+
wire,
|
|
4992
|
+
matchedRuleName,
|
|
4993
|
+
matchedRuleIndex,
|
|
4994
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
4995
|
+
dryRun: wire === "dry_run",
|
|
4996
|
+
recordKind: "tool_call",
|
|
4997
|
+
limitsChain: limitsBlock
|
|
4998
|
+
});
|
|
4999
|
+
this.tombstones.set(evaluationId, {
|
|
5000
|
+
auditRecordId: auditId,
|
|
5001
|
+
payloadHash: null,
|
|
5002
|
+
finalizedBy: "evaluate",
|
|
5003
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5004
|
+
});
|
|
5005
|
+
return { status: 200, body: responseBody };
|
|
5006
|
+
}
|
|
5007
|
+
let approvalTicketId;
|
|
5008
|
+
let ticketTimeoutAtMs;
|
|
5009
|
+
if (wire === "require_approval") {
|
|
5010
|
+
const router = this.approvalRouter;
|
|
5011
|
+
if (!router) {
|
|
5012
|
+
throw new GovernanceConfigError(
|
|
5013
|
+
"[helio] invariant violation: require_approval decision without an approvalRouter"
|
|
5014
|
+
);
|
|
5015
|
+
}
|
|
5016
|
+
const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
|
|
5017
|
+
const ticket = router.createNativeTicket({
|
|
5018
|
+
tool_name: toolName,
|
|
5019
|
+
tool_input: req.arguments ?? {},
|
|
5020
|
+
matched_rule: decision.matchedRule,
|
|
5021
|
+
session_id: req.session_id,
|
|
5022
|
+
origin: req.origin,
|
|
5023
|
+
timeout_ms: timeoutMs
|
|
5024
|
+
});
|
|
5025
|
+
approvalTicketId = ticket.id;
|
|
5026
|
+
ticketTimeoutAtMs = this.now() + timeoutMs;
|
|
5027
|
+
responseBody["approval"] = {
|
|
5028
|
+
id: ticket.id,
|
|
5029
|
+
timeout_ms: timeoutMs,
|
|
5030
|
+
resolve_path: `/approval/${ticket.id}/resolve`
|
|
5031
|
+
};
|
|
5032
|
+
}
|
|
5033
|
+
const entry = {
|
|
5034
|
+
evaluationId,
|
|
5035
|
+
origin: req.origin,
|
|
5036
|
+
agentId: req.agent_id,
|
|
5037
|
+
sessionId: req.session_id,
|
|
5038
|
+
toolName,
|
|
5039
|
+
toolInput: req.arguments ?? {},
|
|
5040
|
+
metadata: req.metadata,
|
|
5041
|
+
action: decision.action,
|
|
5042
|
+
matchedRuleName,
|
|
5043
|
+
matchedRuleIndex,
|
|
5044
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5045
|
+
limitPlan,
|
|
5046
|
+
approvalTicketId,
|
|
5047
|
+
timestampIso,
|
|
5048
|
+
createdAtMs: this.now(),
|
|
5049
|
+
evaluationExpiresAtMs: this.now() + this.ttlMs,
|
|
5050
|
+
ticketTimeoutAtMs,
|
|
5051
|
+
bytes: entryBytes
|
|
5052
|
+
};
|
|
5053
|
+
this.pending.set(evaluationId, entry);
|
|
5054
|
+
this.pendingBytes += entryBytes;
|
|
5055
|
+
if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
|
|
5056
|
+
return { status: 200, body: responseBody };
|
|
5057
|
+
}
|
|
5058
|
+
// -------------------------------------------------------------------------
|
|
5059
|
+
// POST /audit
|
|
5060
|
+
// -------------------------------------------------------------------------
|
|
5061
|
+
audit(req, payloadHash) {
|
|
5062
|
+
const id = req.evaluation_id;
|
|
5063
|
+
const tomb = this.tombstones.get(id);
|
|
5064
|
+
if (tomb) {
|
|
5065
|
+
if (tomb.finalizedBy === "expired") {
|
|
5066
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5067
|
+
}
|
|
5068
|
+
if (tomb.finalizedBy === "evaluate") {
|
|
5069
|
+
return {
|
|
5070
|
+
status: 200,
|
|
5071
|
+
body: {
|
|
5072
|
+
ok: true,
|
|
5073
|
+
audit_record_id: tomb.auditRecordId,
|
|
5074
|
+
already_finalized: true,
|
|
5075
|
+
finalized_by: "evaluate"
|
|
5076
|
+
}
|
|
5077
|
+
};
|
|
5078
|
+
}
|
|
5079
|
+
if (tomb.payloadHash === payloadHash) {
|
|
5080
|
+
return {
|
|
5081
|
+
status: 200,
|
|
5082
|
+
body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
|
|
5083
|
+
};
|
|
5084
|
+
}
|
|
5085
|
+
return { status: 409, body: { error: "evaluation_conflict" } };
|
|
5086
|
+
}
|
|
5087
|
+
const entry = this.pending.get(id);
|
|
5088
|
+
if (!entry) {
|
|
5089
|
+
return { status: 404, body: { error: "evaluation_unknown" } };
|
|
5090
|
+
}
|
|
5091
|
+
if (this.enforceDeadlines(entry) === "expired") {
|
|
5092
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5093
|
+
}
|
|
5094
|
+
let approvalStatus = null;
|
|
5095
|
+
let approvedBy = null;
|
|
5096
|
+
if (entry.approvalTicketId) {
|
|
5097
|
+
const ticket = this.getTicketStatus(entry.approvalTicketId);
|
|
5098
|
+
const status = ticket?.status;
|
|
5099
|
+
if (!status || status === "pending") {
|
|
5100
|
+
return { status: 409, body: { error: "approval_unresolved" } };
|
|
5101
|
+
}
|
|
5102
|
+
approvalStatus = status;
|
|
5103
|
+
approvedBy = ticket.resolved_by ?? null;
|
|
5104
|
+
}
|
|
5105
|
+
if (req.actual_amount !== void 0) {
|
|
5106
|
+
if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
|
|
5107
|
+
return { status: 400, body: { error: "invalid_actual_amount" } };
|
|
5108
|
+
}
|
|
5109
|
+
if (entry.limitPlan?.kind !== "spend") {
|
|
5110
|
+
return { status: 400, body: { error: "no_spend_rule" } };
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
const callHappened = req.status === "success" || req.status === "error";
|
|
5114
|
+
let limitsChain;
|
|
5115
|
+
if (callHappened && entry.limitPlan) {
|
|
5116
|
+
limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
|
|
5117
|
+
}
|
|
5118
|
+
if (callHappened && this.evidenceStore && entry.sessionId) {
|
|
5119
|
+
this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
|
|
5120
|
+
}
|
|
5121
|
+
const auditId = this.writeAudit({
|
|
5122
|
+
timestampIso: entry.timestampIso,
|
|
5123
|
+
origin: entry.origin,
|
|
5124
|
+
agentId: entry.agentId,
|
|
5125
|
+
sessionId: entry.sessionId,
|
|
5126
|
+
toolName: entry.toolName,
|
|
5127
|
+
toolInput: entry.toolInput,
|
|
5128
|
+
metadata: entry.metadata,
|
|
5129
|
+
action: entry.action,
|
|
5130
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5131
|
+
matchedRuleName: entry.matchedRuleName,
|
|
5132
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
5133
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
5134
|
+
dryRun: false,
|
|
5135
|
+
recordKind: "tool_call",
|
|
5136
|
+
limitsChain,
|
|
5137
|
+
approvalStatus,
|
|
5138
|
+
approvedBy,
|
|
5139
|
+
upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
|
|
5140
|
+
upstreamResponse: req.result ?? null,
|
|
5141
|
+
upstreamLatencyMs: req.duration_ms ?? null
|
|
5142
|
+
});
|
|
5143
|
+
this.discardPending(entry);
|
|
5144
|
+
this.tombstones.set(id, {
|
|
5145
|
+
auditRecordId: auditId,
|
|
5146
|
+
payloadHash,
|
|
5147
|
+
finalizedBy: "audit",
|
|
5148
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5149
|
+
});
|
|
5150
|
+
return { status: 201, body: { ok: true, audit_record_id: auditId } };
|
|
5151
|
+
}
|
|
5152
|
+
// -------------------------------------------------------------------------
|
|
5153
|
+
// POST /install-scan — evaluates install-time policy (issue #13)
|
|
5154
|
+
// -------------------------------------------------------------------------
|
|
5155
|
+
installScan(req) {
|
|
5156
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
5157
|
+
if (reserved) {
|
|
5158
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
5159
|
+
}
|
|
5160
|
+
const evaluationId = randomUUID2();
|
|
5161
|
+
const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
|
|
5162
|
+
const verdict = this.evaluateInstall(req);
|
|
5163
|
+
const denied = verdict.decision === "deny";
|
|
5164
|
+
const auditId = this.writeAudit({
|
|
5165
|
+
timestampIso: new Date(this.now()).toISOString(),
|
|
5166
|
+
origin: req.origin,
|
|
5167
|
+
agentId: req.agent_id,
|
|
5168
|
+
sessionId: req.session_id,
|
|
5169
|
+
toolName,
|
|
5170
|
+
toolInput: { ...req.package },
|
|
5171
|
+
metadata: req.metadata,
|
|
5172
|
+
// policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
|
|
5173
|
+
// blocked install as a block, not an allow. The install context lives in
|
|
5174
|
+
// record_kind + block_reason.
|
|
5175
|
+
action: denied ? "deny" : "allow",
|
|
5176
|
+
wire: denied ? "deny" : "allow",
|
|
5177
|
+
matchedRuleName: verdict.matchedRule?.name ?? null,
|
|
5178
|
+
matchedRuleIndex: verdict.matchedRule?.index ?? null,
|
|
5179
|
+
flaggedDestructive: false,
|
|
5180
|
+
dryRun: false,
|
|
5181
|
+
recordKind: "install_scan"
|
|
5182
|
+
});
|
|
5183
|
+
this.tombstones.set(evaluationId, {
|
|
5184
|
+
auditRecordId: auditId,
|
|
5185
|
+
payloadHash: null,
|
|
5186
|
+
finalizedBy: "evaluate",
|
|
5187
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5188
|
+
});
|
|
5189
|
+
const body = {
|
|
5190
|
+
evaluation_id: evaluationId,
|
|
5191
|
+
decision: verdict.decision,
|
|
5192
|
+
reason: verdict.reason,
|
|
5193
|
+
matched_rule: verdict.matchedRule?.name ?? null,
|
|
5194
|
+
matched_rule_index: verdict.matchedRule?.index ?? null
|
|
5195
|
+
};
|
|
5196
|
+
if (denied) {
|
|
5197
|
+
body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
|
|
5198
|
+
}
|
|
5199
|
+
return { status: 200, body };
|
|
5200
|
+
}
|
|
5201
|
+
/** First-match-wins evaluation of the compiled install policy (issue #13). */
|
|
5202
|
+
evaluateInstall(req) {
|
|
5203
|
+
const install = this.policy.install;
|
|
5204
|
+
if (!install) {
|
|
5205
|
+
return { decision: "allow", reason: "no install-time rules defined" };
|
|
5206
|
+
}
|
|
5207
|
+
const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
|
|
5208
|
+
for (const rule of install.rules) {
|
|
5209
|
+
if (matchInstallRule(rule, req.package, metadataView)) {
|
|
5210
|
+
const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
|
|
5211
|
+
return {
|
|
5212
|
+
decision: rule.action === "deny_install" ? "deny" : "allow",
|
|
5213
|
+
matchedRule: rule,
|
|
5214
|
+
reason: `Matched ${label} \u2192 ${rule.action}`
|
|
5215
|
+
};
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
return {
|
|
5219
|
+
decision: install.defaultAction,
|
|
5220
|
+
reason: `No matching install rule; default ${install.defaultAction}`
|
|
5221
|
+
};
|
|
5222
|
+
}
|
|
5223
|
+
// -------------------------------------------------------------------------
|
|
5224
|
+
// POST /approval/:id/resolve
|
|
5225
|
+
// -------------------------------------------------------------------------
|
|
5226
|
+
resolveApproval(ticketId, req) {
|
|
5227
|
+
if (!this.approvalRouter) {
|
|
5228
|
+
return { status: 503, body: { error: "governance_unavailable" } };
|
|
5229
|
+
}
|
|
5230
|
+
const ticket = this.getTicketStatus(ticketId);
|
|
5231
|
+
if (!ticket) {
|
|
5232
|
+
return { status: 404, body: { error: "ticket_not_found" } };
|
|
5233
|
+
}
|
|
5234
|
+
if (!ticket.channel_name.startsWith("native:")) {
|
|
5235
|
+
return { status: 409, body: { error: "not_a_native_ticket" } };
|
|
5236
|
+
}
|
|
5237
|
+
const evaluationId = this.ticketToEvaluation.get(ticketId);
|
|
5238
|
+
const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
|
|
5239
|
+
if (entry) this.enforceDeadlines(entry);
|
|
5240
|
+
const current = this.getTicketStatus(ticketId);
|
|
5241
|
+
if (!current || current.status !== "pending") {
|
|
5242
|
+
return { status: 409, body: { error: "already_resolved", status: current?.status } };
|
|
5243
|
+
}
|
|
5244
|
+
const resolved = this.approvalRouter.resolveNativeTicket(
|
|
5245
|
+
ticketId,
|
|
5246
|
+
req.resolution,
|
|
5247
|
+
req.resolved_by,
|
|
5248
|
+
{ denial_reason: req.resolution === "denied" ? req.reason : void 0 }
|
|
5249
|
+
);
|
|
5250
|
+
if (!resolved) {
|
|
5251
|
+
return { status: 409, body: { error: "already_resolved" } };
|
|
5252
|
+
}
|
|
5253
|
+
return { status: 200, body: { ok: true } };
|
|
5254
|
+
}
|
|
5255
|
+
// -------------------------------------------------------------------------
|
|
5256
|
+
// Sweep — GC backstop for callers that never return
|
|
5257
|
+
// -------------------------------------------------------------------------
|
|
5258
|
+
sweep() {
|
|
5259
|
+
for (const entry of [...this.pending.values()]) {
|
|
5260
|
+
this.enforceDeadlines(entry);
|
|
5261
|
+
}
|
|
5262
|
+
const now = this.now();
|
|
5263
|
+
for (const [id, tomb] of this.tombstones) {
|
|
5264
|
+
if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
|
|
5265
|
+
}
|
|
5266
|
+
this.pruneSenderKeys();
|
|
5267
|
+
}
|
|
5268
|
+
/**
|
|
5269
|
+
* Reserve a cardinality slot for a sender-keyed limit (issue #13).
|
|
5270
|
+
*
|
|
5271
|
+
* Only `sender:*` keys are gated — tool/session families are bounded by upstream
|
|
5272
|
+
* cardinality, and the MCP path never reaches here, so structural traffic cannot
|
|
5273
|
+
* be starved. A key already backed by live state (registry or a live limiter
|
|
5274
|
+
* bucket) costs no new slot. At capacity we lazily prune dead keys before failing
|
|
5275
|
+
* closed, so an emptied bucket frees its slot without waiting for the sweep.
|
|
5276
|
+
*/
|
|
5277
|
+
reserveSenderKey(key) {
|
|
5278
|
+
if (!key.startsWith("sender:")) return true;
|
|
5279
|
+
if (this.senderKeys.has(key)) return true;
|
|
5280
|
+
if (this.hasLiveBucket(key)) {
|
|
5281
|
+
this.senderKeys.add(key);
|
|
5282
|
+
return true;
|
|
5283
|
+
}
|
|
5284
|
+
if (this.senderKeys.size >= this.maxSenderKeys) {
|
|
5285
|
+
this.pruneSenderKeys();
|
|
5286
|
+
if (this.senderKeys.size >= this.maxSenderKeys) return false;
|
|
5287
|
+
}
|
|
5288
|
+
this.senderKeys.add(key);
|
|
5289
|
+
return true;
|
|
5290
|
+
}
|
|
5291
|
+
/** Drop registry keys with no pending evaluation AND no live limiter bucket. */
|
|
5292
|
+
pruneSenderKeys() {
|
|
5293
|
+
if (this.senderKeys.size === 0) return;
|
|
5294
|
+
const inUse = /* @__PURE__ */ new Set();
|
|
5295
|
+
for (const entry of this.pending.values()) {
|
|
5296
|
+
if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
|
|
5297
|
+
inUse.add(entry.limitPlan.key);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
for (const key of this.senderKeys) {
|
|
5301
|
+
if (inUse.has(key)) continue;
|
|
5302
|
+
if (this.hasLiveBucket(key)) continue;
|
|
5303
|
+
this.senderKeys.delete(key);
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
/**
|
|
5307
|
+
* Whether either limiter still holds a live bucket for `key`. Uses the public
|
|
5308
|
+
* `getKeyState()` — never the limiters' private maps — and its lazy eviction of
|
|
5309
|
+
* an emptied bucket IS the prune-on-touch mechanism.
|
|
5310
|
+
*/
|
|
5311
|
+
hasLiveBucket(key) {
|
|
5312
|
+
return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
|
|
5313
|
+
}
|
|
5314
|
+
close() {
|
|
5315
|
+
if (this.closed) return;
|
|
5316
|
+
this.closed = true;
|
|
5317
|
+
if (this.sweepTimer) {
|
|
5318
|
+
clearInterval(this.sweepTimer);
|
|
5319
|
+
this.sweepTimer = null;
|
|
5320
|
+
}
|
|
5321
|
+
this.pending.clear();
|
|
5322
|
+
this.tombstones.clear();
|
|
5323
|
+
this.caches.clear();
|
|
5324
|
+
this.senderKeys.clear();
|
|
5325
|
+
this.pendingBytes = 0;
|
|
5326
|
+
}
|
|
5327
|
+
// -------------------------------------------------------------------------
|
|
5328
|
+
// Internals
|
|
5329
|
+
// -------------------------------------------------------------------------
|
|
5330
|
+
/** Apply crossed deadlines to one pending entry. Returns its post-state. */
|
|
5331
|
+
enforceDeadlines(entry) {
|
|
5332
|
+
const now = this.now();
|
|
5333
|
+
if (now >= entry.evaluationExpiresAtMs) {
|
|
5334
|
+
if (entry.approvalTicketId) {
|
|
5335
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
5336
|
+
}
|
|
5337
|
+
const auditId = this.writeAudit({
|
|
5338
|
+
timestampIso: entry.timestampIso,
|
|
5339
|
+
origin: entry.origin,
|
|
5340
|
+
agentId: entry.agentId,
|
|
5341
|
+
sessionId: entry.sessionId,
|
|
5342
|
+
toolName: entry.toolName,
|
|
5343
|
+
toolInput: entry.toolInput,
|
|
5344
|
+
metadata: entry.metadata,
|
|
5345
|
+
action: entry.action,
|
|
5346
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5347
|
+
matchedRuleName: entry.matchedRuleName,
|
|
5348
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
5349
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
5350
|
+
dryRun: false,
|
|
5351
|
+
recordKind: "evaluation_expired",
|
|
5352
|
+
sidebandUnreported: true
|
|
5353
|
+
});
|
|
5354
|
+
this.discardPending(entry);
|
|
5355
|
+
this.tombstones.set(entry.evaluationId, {
|
|
5356
|
+
auditRecordId: auditId,
|
|
5357
|
+
payloadHash: null,
|
|
5358
|
+
finalizedBy: "expired",
|
|
5359
|
+
expiresAtMs: now + this.ttlMs
|
|
5360
|
+
});
|
|
5361
|
+
console.error(
|
|
5362
|
+
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
5363
|
+
);
|
|
5364
|
+
return "expired";
|
|
5365
|
+
}
|
|
5366
|
+
if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
|
|
5367
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
5368
|
+
}
|
|
5369
|
+
return "active";
|
|
5370
|
+
}
|
|
5371
|
+
cacheFor(origin) {
|
|
5372
|
+
let cache = this.caches.get(origin);
|
|
5373
|
+
if (!cache) {
|
|
5374
|
+
cache = new ToolAnnotationCache();
|
|
5375
|
+
this.caches.set(origin, cache);
|
|
5376
|
+
}
|
|
5377
|
+
return cache;
|
|
5378
|
+
}
|
|
5379
|
+
discardPending(entry) {
|
|
5380
|
+
if (this.pending.delete(entry.evaluationId)) {
|
|
5381
|
+
this.pendingBytes -= entry.bytes;
|
|
5382
|
+
}
|
|
5383
|
+
if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
|
|
5384
|
+
}
|
|
5385
|
+
getTicketStatus(ticketId) {
|
|
5386
|
+
return this.approvalRouter?.getTicket(ticketId);
|
|
5387
|
+
}
|
|
5388
|
+
planRate(decision, toolName, sessionId, senderId) {
|
|
5389
|
+
const limits = decision.matchedRule?.limits;
|
|
5390
|
+
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
5391
|
+
return { allowed: true };
|
|
5392
|
+
}
|
|
5393
|
+
const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
|
|
5394
|
+
const peek = this.rateLimiter.peek({
|
|
5395
|
+
key,
|
|
5396
|
+
maxCalls: limits.maxCalls,
|
|
5397
|
+
windowMs: limits.windowMs
|
|
5398
|
+
});
|
|
5399
|
+
return {
|
|
5400
|
+
plan: { kind: "rate", key, limits },
|
|
5401
|
+
block: {
|
|
5402
|
+
current: peek.current,
|
|
5403
|
+
limit: peek.limit,
|
|
5404
|
+
window_ms: peek.windowMs,
|
|
5405
|
+
reset_at_ms: peek.resetAtMs
|
|
5406
|
+
},
|
|
5407
|
+
allowed: peek.allowed
|
|
5408
|
+
};
|
|
5409
|
+
}
|
|
5410
|
+
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
5411
|
+
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
5412
|
+
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
5413
|
+
const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
|
|
5414
|
+
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
5415
|
+
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
5416
|
+
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
5417
|
+
}
|
|
5418
|
+
const peek = this.spendLimiter.peek({
|
|
5419
|
+
key,
|
|
5420
|
+
amount: rawAmount,
|
|
5421
|
+
limit: maxSpend.limit,
|
|
5422
|
+
windowMs: maxSpend.windowMs
|
|
5423
|
+
});
|
|
5424
|
+
return {
|
|
5425
|
+
plan: {
|
|
5426
|
+
kind: "spend",
|
|
5427
|
+
key,
|
|
5428
|
+
limits: decision.matchedRule.limits,
|
|
5429
|
+
amount: rawAmount,
|
|
5430
|
+
currency: maxSpend.currency
|
|
5431
|
+
},
|
|
5432
|
+
block: {
|
|
5433
|
+
current_spend: peek.currentSpend,
|
|
5434
|
+
limit: peek.limit,
|
|
5435
|
+
currency: maxSpend.currency,
|
|
5436
|
+
window_ms: peek.windowMs,
|
|
5437
|
+
reset_at_ms: peek.resetAtMs
|
|
5438
|
+
},
|
|
5439
|
+
allowed: peek.allowed
|
|
5440
|
+
};
|
|
5441
|
+
}
|
|
5442
|
+
/** Commit a limit plan at /audit time and return the evidence_chain block. */
|
|
5443
|
+
commitLimit(plan, actualAmount) {
|
|
5444
|
+
if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
|
|
5445
|
+
const r = this.rateLimiter.record({
|
|
5446
|
+
key: plan.key,
|
|
5447
|
+
maxCalls: plan.limits.maxCalls,
|
|
5448
|
+
windowMs: plan.limits.windowMs
|
|
5449
|
+
});
|
|
5450
|
+
return {
|
|
5451
|
+
rate_limit: {
|
|
5452
|
+
allowed: r.allowed,
|
|
5453
|
+
current: r.current,
|
|
5454
|
+
limit: r.limit,
|
|
5455
|
+
window_ms: r.windowMs,
|
|
5456
|
+
reset_at_ms: r.resetAtMs
|
|
5457
|
+
}
|
|
5458
|
+
};
|
|
5459
|
+
}
|
|
5460
|
+
if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
|
|
5461
|
+
const amount = actualAmount ?? plan.amount ?? 0;
|
|
5462
|
+
const r = this.spendLimiter.record({
|
|
5463
|
+
key: plan.key,
|
|
5464
|
+
amount,
|
|
5465
|
+
limit: plan.limits.maxSpend.limit,
|
|
5466
|
+
windowMs: plan.limits.maxSpend.windowMs
|
|
5467
|
+
});
|
|
5468
|
+
this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
|
|
5469
|
+
return {
|
|
5470
|
+
spend_limit: {
|
|
5471
|
+
allowed: r.allowed,
|
|
5472
|
+
current_spend: r.currentSpend,
|
|
5473
|
+
limit: r.limit,
|
|
5474
|
+
window_ms: r.windowMs,
|
|
5475
|
+
reset_at_ms: r.resetAtMs
|
|
5476
|
+
}
|
|
5477
|
+
};
|
|
5478
|
+
}
|
|
5479
|
+
return void 0;
|
|
5480
|
+
}
|
|
5481
|
+
writeAudit(args) {
|
|
5482
|
+
const id = randomUUID2();
|
|
5483
|
+
if (!this.auditWriter) return id;
|
|
5484
|
+
const blockReason = deriveBlockReason(args);
|
|
5485
|
+
let evidenceChain = args.limitsChain ?? null;
|
|
5486
|
+
if (args.sidebandUnreported) {
|
|
5487
|
+
evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
|
|
5488
|
+
}
|
|
5489
|
+
const record = {
|
|
5490
|
+
timestamp: args.timestampIso,
|
|
5491
|
+
session_id: args.sessionId,
|
|
5492
|
+
agent_id: args.agentId,
|
|
5493
|
+
environment: this.environment ?? null,
|
|
5494
|
+
tool_name: args.toolName,
|
|
5495
|
+
tool_input: args.toolInput,
|
|
5496
|
+
policy_decision: args.action,
|
|
5497
|
+
block_reason: blockReason,
|
|
5498
|
+
matched_rule: args.matchedRuleName,
|
|
5499
|
+
matched_rule_index: args.matchedRuleIndex,
|
|
5500
|
+
evidence_chain: evidenceChain,
|
|
5501
|
+
approval_status: args.approvalStatus ?? null,
|
|
5502
|
+
approved_by: args.approvedBy ?? null,
|
|
5503
|
+
upstream_response: args.upstreamResponse ?? null,
|
|
5504
|
+
upstream_error: args.upstreamError ?? null,
|
|
5505
|
+
upstream_http_status: null,
|
|
5506
|
+
upstream_latency_ms: args.upstreamLatencyMs ?? null,
|
|
5507
|
+
total_duration_ms: 0,
|
|
5508
|
+
approval_wait_ms: 0,
|
|
5509
|
+
proxy_compute_ms: 0,
|
|
5510
|
+
flagged_destructive: args.flaggedDestructive,
|
|
5511
|
+
dry_run: args.dryRun,
|
|
5512
|
+
record_kind: args.recordKind,
|
|
5513
|
+
origin: args.origin,
|
|
5514
|
+
metadata: args.metadata
|
|
5515
|
+
};
|
|
5516
|
+
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
5517
|
+
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
5518
|
+
else this.auditWriter.push(record, id);
|
|
5519
|
+
return id;
|
|
5520
|
+
}
|
|
5521
|
+
assertApprovalRouter(policy) {
|
|
5522
|
+
if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
|
|
5523
|
+
throw new GovernanceConfigError(
|
|
5524
|
+
"[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
|
|
5525
|
+
);
|
|
5526
|
+
}
|
|
5527
|
+
};
|
|
5528
|
+
function deriveBlockReason(args) {
|
|
5529
|
+
if (args.recordKind === "evaluation_expired") return null;
|
|
5530
|
+
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
5531
|
+
if (args.dryRun) return null;
|
|
5532
|
+
if (args.approvalStatus === "denied") return "approval_denied";
|
|
5533
|
+
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
5534
|
+
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
5535
|
+
switch (args.wire) {
|
|
5536
|
+
case "deny":
|
|
5537
|
+
return "policy_denied";
|
|
5538
|
+
case "rate_limited":
|
|
5539
|
+
return "rate_limited";
|
|
5540
|
+
case "spend_limited":
|
|
5541
|
+
return "spend_limited";
|
|
5542
|
+
default:
|
|
5543
|
+
return null;
|
|
5544
|
+
}
|
|
5545
|
+
}
|
|
5546
|
+
function buildFeedback(rule, reason) {
|
|
5547
|
+
const message = rule?.feedback?.message ?? reason;
|
|
5548
|
+
const suggestion = rule?.feedback?.suggestion;
|
|
5549
|
+
return suggestion ? { message, suggestion } : { message };
|
|
5550
|
+
}
|
|
5551
|
+
function isBlocking(wire) {
|
|
5552
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
|
|
5553
|
+
}
|
|
5554
|
+
function isTerminalAtEvaluate(wire) {
|
|
5555
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
|
|
5556
|
+
}
|
|
5557
|
+
function policyCanRequireApproval(policy) {
|
|
5558
|
+
if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
|
|
5559
|
+
return true;
|
|
5560
|
+
}
|
|
5561
|
+
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
5562
|
+
}
|
|
5563
|
+
function buildLimitKey(keyType, toolName, sessionId, senderId) {
|
|
5564
|
+
switch (keyType) {
|
|
5565
|
+
case "session":
|
|
5566
|
+
return `session:${sessionId ?? "unknown"}`;
|
|
5567
|
+
case "sender_id":
|
|
5568
|
+
return `sender:${senderId ?? "unknown"}`;
|
|
5569
|
+
case "agent":
|
|
5570
|
+
case "tool":
|
|
5571
|
+
default:
|
|
5572
|
+
return `tool:${toolName}`;
|
|
5573
|
+
}
|
|
5574
|
+
}
|
|
5575
|
+
function senderIdOf(metadata) {
|
|
5576
|
+
const v = metadata?.["sender_id"];
|
|
5577
|
+
return typeof v === "string" ? v : null;
|
|
5578
|
+
}
|
|
5579
|
+
function matchInstallRule(rule, pkg2, metadataView) {
|
|
5580
|
+
if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
|
|
5581
|
+
if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
|
|
5582
|
+
if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
|
|
5583
|
+
return false;
|
|
5584
|
+
}
|
|
5585
|
+
return true;
|
|
5586
|
+
}
|
|
5587
|
+
function reservedMetadataKey(metadata) {
|
|
5588
|
+
if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
|
|
5589
|
+
return "agent_id";
|
|
5590
|
+
}
|
|
5591
|
+
return null;
|
|
5592
|
+
}
|
|
5593
|
+
function definitionProvided(tool) {
|
|
5594
|
+
return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
|
|
5595
|
+
}
|
|
5596
|
+
function toMcpToolDef(tool) {
|
|
5597
|
+
const def = { name: tool.name };
|
|
5598
|
+
if (tool.description !== void 0) def["description"] = tool.description;
|
|
5599
|
+
if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
|
|
5600
|
+
if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
|
|
5601
|
+
if (tool.title !== void 0) def["title"] = tool.title;
|
|
5602
|
+
if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
|
|
5603
|
+
return def;
|
|
5604
|
+
}
|
|
5605
|
+
function byteLength(value) {
|
|
5606
|
+
return Buffer.byteLength(canonicalize(value), "utf8");
|
|
5607
|
+
}
|
|
5608
|
+
|
|
4048
5609
|
// src/audit/store.ts
|
|
4049
5610
|
import Database from "better-sqlite3";
|
|
4050
|
-
import { randomUUID as
|
|
5611
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4051
5612
|
import { chmodSync } from "fs";
|
|
4052
5613
|
|
|
4053
5614
|
// src/upstream/response-summary.ts
|
|
@@ -4110,6 +5671,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
4110
5671
|
}
|
|
4111
5672
|
|
|
4112
5673
|
// src/audit/store.ts
|
|
5674
|
+
var DRIFT_EVENT_DECISIONS_SQL = "('tool_drift', 'tool_drift_reverted')";
|
|
4113
5675
|
var CREATE_TABLE_DDL = `
|
|
4114
5676
|
CREATE TABLE IF NOT EXISTS audit_records (
|
|
4115
5677
|
id TEXT PRIMARY KEY,
|
|
@@ -4135,6 +5697,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
4135
5697
|
proxy_compute_ms REAL NOT NULL,
|
|
4136
5698
|
flagged_destructive INTEGER NOT NULL DEFAULT 0,
|
|
4137
5699
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
5700
|
+
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5701
|
+
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5702
|
+
metadata TEXT,
|
|
4138
5703
|
created_at TEXT NOT NULL
|
|
4139
5704
|
);
|
|
4140
5705
|
`;
|
|
@@ -4145,6 +5710,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
|
|
|
4145
5710
|
CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
|
|
4146
5711
|
CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
|
|
4147
5712
|
CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
|
|
5713
|
+
CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
|
|
5714
|
+
CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
4148
5715
|
`;
|
|
4149
5716
|
var INSERT_SQL = `
|
|
4150
5717
|
INSERT INTO audit_records (
|
|
@@ -4153,14 +5720,14 @@ INSERT INTO audit_records (
|
|
|
4153
5720
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
4154
5721
|
upstream_http_status,
|
|
4155
5722
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
4156
|
-
flagged_destructive, dry_run, created_at
|
|
5723
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
4157
5724
|
) VALUES (
|
|
4158
5725
|
@id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
|
|
4159
5726
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
4160
5727
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
4161
5728
|
@upstream_http_status,
|
|
4162
5729
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
4163
|
-
@flagged_destructive, @dry_run, @created_at
|
|
5730
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
4164
5731
|
)
|
|
4165
5732
|
`;
|
|
4166
5733
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -4170,7 +5737,10 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
4170
5737
|
"total_duration_ms",
|
|
4171
5738
|
"approval_wait_ms",
|
|
4172
5739
|
"proxy_compute_ms",
|
|
4173
|
-
"upstream_http_status"
|
|
5740
|
+
"upstream_http_status",
|
|
5741
|
+
"record_kind",
|
|
5742
|
+
"origin",
|
|
5743
|
+
"metadata"
|
|
4174
5744
|
];
|
|
4175
5745
|
function deserializeRow(row) {
|
|
4176
5746
|
return {
|
|
@@ -4197,6 +5767,9 @@ function deserializeRow(row) {
|
|
|
4197
5767
|
proxy_compute_ms: row.proxy_compute_ms,
|
|
4198
5768
|
flagged_destructive: row.flagged_destructive === 1,
|
|
4199
5769
|
dry_run: row.dry_run === 1,
|
|
5770
|
+
record_kind: row.record_kind,
|
|
5771
|
+
origin: row.origin,
|
|
5772
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
4200
5773
|
created_at: row.created_at
|
|
4201
5774
|
};
|
|
4202
5775
|
}
|
|
@@ -4218,6 +5791,22 @@ function buildWhereClause(filters) {
|
|
|
4218
5791
|
if (filters.blocked !== void 0) {
|
|
4219
5792
|
conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
|
|
4220
5793
|
}
|
|
5794
|
+
if (filters.record_kind !== void 0) {
|
|
5795
|
+
conditions.push("record_kind = ?");
|
|
5796
|
+
params.push(filters.record_kind);
|
|
5797
|
+
}
|
|
5798
|
+
if (filters.origin !== void 0) {
|
|
5799
|
+
conditions.push("origin LIKE ?");
|
|
5800
|
+
params.push(`%${filters.origin}%`);
|
|
5801
|
+
}
|
|
5802
|
+
if (filters.channel_id !== void 0) {
|
|
5803
|
+
conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
|
|
5804
|
+
params.push(`%${filters.channel_id}%`);
|
|
5805
|
+
}
|
|
5806
|
+
if (filters.sender_id !== void 0) {
|
|
5807
|
+
conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
|
|
5808
|
+
params.push(`%${filters.sender_id}%`);
|
|
5809
|
+
}
|
|
4221
5810
|
if (filters.session_id !== void 0) {
|
|
4222
5811
|
conditions.push("session_id = ?");
|
|
4223
5812
|
params.push(filters.session_id);
|
|
@@ -4318,7 +5907,7 @@ var AuditStore = class {
|
|
|
4318
5907
|
* @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
|
|
4319
5908
|
*/
|
|
4320
5909
|
insert(record, createdAt, id) {
|
|
4321
|
-
const resolvedId = id ??
|
|
5910
|
+
const resolvedId = id ?? randomUUID3();
|
|
4322
5911
|
const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4323
5912
|
this.insertStmt.run({
|
|
4324
5913
|
id: resolvedId,
|
|
@@ -4344,6 +5933,9 @@ var AuditStore = class {
|
|
|
4344
5933
|
proxy_compute_ms: record.proxy_compute_ms,
|
|
4345
5934
|
flagged_destructive: record.flagged_destructive ? 1 : 0,
|
|
4346
5935
|
dry_run: record.dry_run ? 1 : 0,
|
|
5936
|
+
record_kind: record.record_kind,
|
|
5937
|
+
origin: record.origin,
|
|
5938
|
+
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
4347
5939
|
created_at: now
|
|
4348
5940
|
});
|
|
4349
5941
|
return resolvedId;
|
|
@@ -4413,7 +6005,7 @@ var AuditStore = class {
|
|
|
4413
6005
|
const totals = this.db.prepare(
|
|
4414
6006
|
`SELECT
|
|
4415
6007
|
COUNT(*) as total,
|
|
4416
|
-
COALESCE(SUM(CASE WHEN block_reason IS NULL THEN 1 ELSE 0 END), 0) as allowed_total,
|
|
6008
|
+
COALESCE(SUM(CASE WHEN block_reason IS NULL AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL} THEN 1 ELSE 0 END), 0) as allowed_total,
|
|
4417
6009
|
COALESCE(SUM(CASE WHEN block_reason IS NOT NULL THEN 1 ELSE 0 END), 0) as blocked_total,
|
|
4418
6010
|
COALESCE(SUM(CASE WHEN dry_run = 1 THEN 1 ELSE 0 END), 0) as dry_run_total,
|
|
4419
6011
|
COALESCE(SUM(CASE WHEN dry_run = 0 THEN 1 ELSE 0 END), 0) as applied_total
|
|
@@ -4432,9 +6024,10 @@ var AuditStore = class {
|
|
|
4432
6024
|
GROUP BY block_reason
|
|
4433
6025
|
ORDER BY count DESC`
|
|
4434
6026
|
).all(...params);
|
|
6027
|
+
const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${DRIFT_EVENT_DECISIONS_SQL}`;
|
|
4435
6028
|
const top_tools = this.db.prepare(
|
|
4436
6029
|
`SELECT tool_name, COUNT(*) as count
|
|
4437
|
-
FROM audit_records ${
|
|
6030
|
+
FROM audit_records ${toolsClause}
|
|
4438
6031
|
GROUP BY tool_name
|
|
4439
6032
|
ORDER BY count DESC
|
|
4440
6033
|
LIMIT 10`
|
|
@@ -4489,7 +6082,7 @@ var AuditStore = class {
|
|
|
4489
6082
|
};
|
|
4490
6083
|
|
|
4491
6084
|
// src/audit/writer.ts
|
|
4492
|
-
import { randomUUID as
|
|
6085
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
4493
6086
|
var AuditWriter = class {
|
|
4494
6087
|
store;
|
|
4495
6088
|
bufferSize;
|
|
@@ -4524,9 +6117,8 @@ var AuditWriter = class {
|
|
|
4524
6117
|
* is scheduled. This keeps request-path latency bounded even under bursty
|
|
4525
6118
|
* write load.
|
|
4526
6119
|
*/
|
|
4527
|
-
push(record) {
|
|
6120
|
+
push(record, id = randomUUID4()) {
|
|
4528
6121
|
if (this.closed) return;
|
|
4529
|
-
const id = randomUUID3();
|
|
4530
6122
|
this.buffer.push({ id, record });
|
|
4531
6123
|
this.onPush?.(record, id);
|
|
4532
6124
|
if (this.buffer.length >= this.bufferSize) {
|
|
@@ -4541,9 +6133,8 @@ var AuditWriter = class {
|
|
|
4541
6133
|
* A fatal-process crash still invokes the crash-drain hook, which calls
|
|
4542
6134
|
* `flush()` synchronously before exit.
|
|
4543
6135
|
*/
|
|
4544
|
-
pushImmediate(record) {
|
|
6136
|
+
pushImmediate(record, id = randomUUID4()) {
|
|
4545
6137
|
if (this.closed) return;
|
|
4546
|
-
const id = randomUUID3();
|
|
4547
6138
|
this.buffer.push({ id, record });
|
|
4548
6139
|
this.onPush?.(record, id);
|
|
4549
6140
|
this.scheduleFlushSoon();
|
|
@@ -4599,7 +6190,7 @@ var AuditWriter = class {
|
|
|
4599
6190
|
};
|
|
4600
6191
|
|
|
4601
6192
|
// src/approval/queue.ts
|
|
4602
|
-
import { randomUUID as
|
|
6193
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
4603
6194
|
var ApprovalQueue = class {
|
|
4604
6195
|
tickets = /* @__PURE__ */ new Map();
|
|
4605
6196
|
now;
|
|
@@ -4629,7 +6220,7 @@ var ApprovalQueue = class {
|
|
|
4629
6220
|
if (this.closed) throw new Error("ApprovalQueue is closed");
|
|
4630
6221
|
const now = this.now();
|
|
4631
6222
|
const ticket = {
|
|
4632
|
-
id:
|
|
6223
|
+
id: randomUUID5(),
|
|
4633
6224
|
tool_name: params.tool_name,
|
|
4634
6225
|
tool_input: params.tool_input,
|
|
4635
6226
|
matched_rule: params.matched_rule,
|
|
@@ -4708,6 +6299,7 @@ var ApprovalQueue = class {
|
|
|
4708
6299
|
};
|
|
4709
6300
|
|
|
4710
6301
|
// src/approval/router.ts
|
|
6302
|
+
var NATIVE_CHANNEL_PREFIX = "native:";
|
|
4711
6303
|
var ApprovalRouter = class {
|
|
4712
6304
|
defaultTimeoutMs;
|
|
4713
6305
|
defaultOnTimeout;
|
|
@@ -4820,6 +6412,59 @@ var ApprovalRouter = class {
|
|
|
4820
6412
|
});
|
|
4821
6413
|
return outcome;
|
|
4822
6414
|
}
|
|
6415
|
+
/**
|
|
6416
|
+
* Create a native (adapter-owned) approval ticket without holding a Promise.
|
|
6417
|
+
*
|
|
6418
|
+
* Used by the sideband governance path (issue #12, D10): when `/evaluate`
|
|
6419
|
+
* yields `require_approval`, the adapter runs the approval in its own UI
|
|
6420
|
+
* (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
|
|
6421
|
+
* timeout/escalation timers, or notify a channel — doing so would
|
|
6422
|
+
* double-notify. We still create the queue ticket and fire `onSubmit` so the
|
|
6423
|
+
* dashboard's `approval_requested` SSE event flows and the ticket is visible.
|
|
6424
|
+
*
|
|
6425
|
+
* The ticket's `channel_name` is `native:<origin>`, which marks it as
|
|
6426
|
+
* adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
|
|
6427
|
+
* be resolved through the adapter), and {@link resolveNativeTicket} is the
|
|
6428
|
+
* resolution path.
|
|
6429
|
+
*/
|
|
6430
|
+
createNativeTicket(params) {
|
|
6431
|
+
const rule = params.matched_rule;
|
|
6432
|
+
const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
|
|
6433
|
+
const ticket = this.queue.add({
|
|
6434
|
+
tool_name: params.tool_name,
|
|
6435
|
+
tool_input: params.tool_input,
|
|
6436
|
+
matched_rule: rule?.name ?? null,
|
|
6437
|
+
rule_index: rule?.index ?? null,
|
|
6438
|
+
channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
|
|
6439
|
+
session_id: params.session_id,
|
|
6440
|
+
timeout_ms: timeoutMs
|
|
6441
|
+
});
|
|
6442
|
+
this.onSubmit?.(ticket);
|
|
6443
|
+
return ticket;
|
|
6444
|
+
}
|
|
6445
|
+
/**
|
|
6446
|
+
* Resolve a native ticket created by {@link createNativeTicket}.
|
|
6447
|
+
*
|
|
6448
|
+
* Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
|
|
6449
|
+
* SSE), with no held Promise to settle. Refuses tickets that have a pending
|
|
6450
|
+
* router Promise (those are MCP-path tickets; resolving them here would leave
|
|
6451
|
+
* the held request hanging) and tickets that are not `native:`-prefixed.
|
|
6452
|
+
*
|
|
6453
|
+
* @returns `true` if resolved, `false` if not found, already resolved, not a
|
|
6454
|
+
* native ticket, or router-managed.
|
|
6455
|
+
*/
|
|
6456
|
+
resolveNativeTicket(ticketId, status, resolvedBy, options) {
|
|
6457
|
+
if (this.pending.has(ticketId)) return false;
|
|
6458
|
+
const ticket = this.queue.get(ticketId);
|
|
6459
|
+
if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
|
|
6460
|
+
const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
|
|
6461
|
+
denial_reason: options?.denial_reason
|
|
6462
|
+
});
|
|
6463
|
+
if (!resolved) return false;
|
|
6464
|
+
const updated = this.queue.get(ticketId);
|
|
6465
|
+
if (updated) this.onResolve?.(updated);
|
|
6466
|
+
return true;
|
|
6467
|
+
}
|
|
4823
6468
|
/**
|
|
4824
6469
|
* Approve a pending ticket. Resolves the held Promise so the governed
|
|
4825
6470
|
* forwarder can forward the request upstream.
|
|
@@ -4864,6 +6509,10 @@ var ApprovalRouter = class {
|
|
|
4864
6509
|
ticketId
|
|
4865
6510
|
});
|
|
4866
6511
|
}
|
|
6512
|
+
/** Look up a ticket by id (delegates to the queue). */
|
|
6513
|
+
getTicket(ticketId) {
|
|
6514
|
+
return this.queue.get(ticketId);
|
|
6515
|
+
}
|
|
4867
6516
|
/** Clean up all pending timers and resolve all pending promises. */
|
|
4868
6517
|
close() {
|
|
4869
6518
|
this.closed = true;
|
|
@@ -5126,8 +6775,8 @@ function createChannels(channels) {
|
|
|
5126
6775
|
|
|
5127
6776
|
// src/approval/slack-actions.ts
|
|
5128
6777
|
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
5129
|
-
import { Hono as
|
|
5130
|
-
import { z as
|
|
6778
|
+
import { Hono as Hono6 } from "hono";
|
|
6779
|
+
import { z as z6 } from "zod";
|
|
5131
6780
|
var MAX_TIMESTAMP_AGE_S = 300;
|
|
5132
6781
|
var REJECTION_LOG_WINDOW_MS = 6e4;
|
|
5133
6782
|
var REJECTION_LOG_SAMPLE_EVERY = 25;
|
|
@@ -5193,12 +6842,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
|
|
|
5193
6842
|
}
|
|
5194
6843
|
return false;
|
|
5195
6844
|
}
|
|
5196
|
-
var slackActionPayloadSchema =
|
|
5197
|
-
type:
|
|
5198
|
-
user:
|
|
5199
|
-
actions:
|
|
5200
|
-
channel:
|
|
5201
|
-
message:
|
|
6845
|
+
var slackActionPayloadSchema = z6.object({
|
|
6846
|
+
type: z6.string(),
|
|
6847
|
+
user: z6.object({ id: z6.string(), username: z6.string() }),
|
|
6848
|
+
actions: z6.array(z6.object({ action_id: z6.string() })),
|
|
6849
|
+
channel: z6.object({ id: z6.string() }),
|
|
6850
|
+
message: z6.object({ ts: z6.string() })
|
|
5202
6851
|
});
|
|
5203
6852
|
function parseActionPayload(rawBody) {
|
|
5204
6853
|
try {
|
|
@@ -5228,7 +6877,7 @@ Ticket \`${ticketId}\``
|
|
|
5228
6877
|
}
|
|
5229
6878
|
function createSlackActionApp(options) {
|
|
5230
6879
|
const { router, channels } = options;
|
|
5231
|
-
const app = new
|
|
6880
|
+
const app = new Hono6();
|
|
5232
6881
|
const rejectionLogBuckets = /* @__PURE__ */ new Map();
|
|
5233
6882
|
const rejectUnauthorized = (c, reason, context) => {
|
|
5234
6883
|
logRejectedSlackCallback(rejectionLogBuckets, {
|
|
@@ -5327,18 +6976,18 @@ function createSlackActionApp(options) {
|
|
|
5327
6976
|
}
|
|
5328
6977
|
|
|
5329
6978
|
// src/approval/api.ts
|
|
5330
|
-
import { Hono as
|
|
5331
|
-
import { z as
|
|
5332
|
-
var approveBody =
|
|
5333
|
-
approved_by:
|
|
6979
|
+
import { Hono as Hono7 } from "hono";
|
|
6980
|
+
import { z as z7 } from "zod";
|
|
6981
|
+
var approveBody = z7.object({
|
|
6982
|
+
approved_by: z7.string().min(1)
|
|
5334
6983
|
});
|
|
5335
|
-
var denyBody =
|
|
5336
|
-
denied_by:
|
|
5337
|
-
reason:
|
|
6984
|
+
var denyBody = z7.object({
|
|
6985
|
+
denied_by: z7.string().min(1),
|
|
6986
|
+
reason: z7.string().optional()
|
|
5338
6987
|
});
|
|
5339
|
-
var breakGlassBody =
|
|
5340
|
-
approved_by:
|
|
5341
|
-
reason:
|
|
6988
|
+
var breakGlassBody = z7.object({
|
|
6989
|
+
approved_by: z7.string().min(1),
|
|
6990
|
+
reason: z7.string().min(1)
|
|
5342
6991
|
});
|
|
5343
6992
|
var APPROVAL_STATUSES = [
|
|
5344
6993
|
"pending",
|
|
@@ -5347,25 +6996,26 @@ var APPROVAL_STATUSES = [
|
|
|
5347
6996
|
"timeout",
|
|
5348
6997
|
"break_glass",
|
|
5349
6998
|
"client_disconnected",
|
|
5350
|
-
"shutdown_cancelled"
|
|
6999
|
+
"shutdown_cancelled",
|
|
7000
|
+
"cancelled"
|
|
5351
7001
|
];
|
|
5352
7002
|
var approvalStatusSet = new Set(APPROVAL_STATUSES);
|
|
5353
|
-
var listApprovalsQuery =
|
|
5354
|
-
status:
|
|
7003
|
+
var listApprovalsQuery = z7.object({
|
|
7004
|
+
status: z7.preprocess(
|
|
5355
7005
|
(value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
|
|
5356
|
-
|
|
7006
|
+
z7.enum(APPROVAL_STATUSES).optional()
|
|
5357
7007
|
),
|
|
5358
|
-
limit:
|
|
7008
|
+
limit: z7.preprocess(
|
|
5359
7009
|
(value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
|
|
5360
|
-
|
|
7010
|
+
z7.number().int()
|
|
5361
7011
|
),
|
|
5362
|
-
offset:
|
|
7012
|
+
offset: z7.preprocess(
|
|
5363
7013
|
(value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
5364
|
-
|
|
7014
|
+
z7.number().int()
|
|
5365
7015
|
)
|
|
5366
7016
|
});
|
|
5367
7017
|
function createApprovalApp(router, queue, options) {
|
|
5368
|
-
const app = new
|
|
7018
|
+
const app = new Hono7();
|
|
5369
7019
|
const apiSecret = options?.apiSecret;
|
|
5370
7020
|
if (apiSecret) {
|
|
5371
7021
|
app.use("*", async (c, next) => {
|
|
@@ -5411,6 +7061,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5411
7061
|
if (!ticket) {
|
|
5412
7062
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5413
7063
|
}
|
|
7064
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7065
|
+
return c.json(
|
|
7066
|
+
{
|
|
7067
|
+
error: "native_ticket",
|
|
7068
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7069
|
+
},
|
|
7070
|
+
409
|
|
7071
|
+
);
|
|
7072
|
+
}
|
|
5414
7073
|
if (ticket.status !== "pending") {
|
|
5415
7074
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5416
7075
|
}
|
|
@@ -5436,6 +7095,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5436
7095
|
if (!ticket) {
|
|
5437
7096
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5438
7097
|
}
|
|
7098
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7099
|
+
return c.json(
|
|
7100
|
+
{
|
|
7101
|
+
error: "native_ticket",
|
|
7102
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7103
|
+
},
|
|
7104
|
+
409
|
|
7105
|
+
);
|
|
7106
|
+
}
|
|
5439
7107
|
if (ticket.status !== "pending") {
|
|
5440
7108
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5441
7109
|
}
|
|
@@ -5461,6 +7129,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5461
7129
|
if (!ticket) {
|
|
5462
7130
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5463
7131
|
}
|
|
7132
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7133
|
+
return c.json(
|
|
7134
|
+
{
|
|
7135
|
+
error: "native_ticket",
|
|
7136
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7137
|
+
},
|
|
7138
|
+
409
|
|
7139
|
+
);
|
|
7140
|
+
}
|
|
5464
7141
|
if (ticket.status !== "pending") {
|
|
5465
7142
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5466
7143
|
}
|
|
@@ -5476,9 +7153,9 @@ function createApprovalApp(router, queue, options) {
|
|
|
5476
7153
|
// src/dashboard/api.ts
|
|
5477
7154
|
import { readFileSync } from "fs";
|
|
5478
7155
|
import { join } from "path";
|
|
5479
|
-
import { randomUUID as
|
|
5480
|
-
import { Hono as
|
|
5481
|
-
import { z as
|
|
7156
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
7157
|
+
import { Hono as Hono8 } from "hono";
|
|
7158
|
+
import { z as z8 } from "zod";
|
|
5482
7159
|
import { cors } from "hono/cors";
|
|
5483
7160
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5484
7161
|
import { streamSSE } from "hono/streaming";
|
|
@@ -5539,7 +7216,7 @@ function recordsToCsv(records) {
|
|
|
5539
7216
|
}
|
|
5540
7217
|
|
|
5541
7218
|
// src/dashboard/session.ts
|
|
5542
|
-
import { createHash as
|
|
7219
|
+
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
5543
7220
|
var DashboardSessionStore = class {
|
|
5544
7221
|
secret;
|
|
5545
7222
|
ttlMs;
|
|
@@ -5620,8 +7297,8 @@ var DashboardSessionStore = class {
|
|
|
5620
7297
|
const id = token.slice(0, dot);
|
|
5621
7298
|
const signature = token.slice(dot + 1);
|
|
5622
7299
|
const expected = this.sign(id);
|
|
5623
|
-
const actualDigest =
|
|
5624
|
-
const expectedDigest =
|
|
7300
|
+
const actualDigest = createHash3("sha256").update(signature).digest();
|
|
7301
|
+
const expectedDigest = createHash3("sha256").update(expected).digest();
|
|
5625
7302
|
if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
|
|
5626
7303
|
return id;
|
|
5627
7304
|
}
|
|
@@ -5631,29 +7308,29 @@ var DashboardSessionStore = class {
|
|
|
5631
7308
|
};
|
|
5632
7309
|
|
|
5633
7310
|
// src/dashboard/api.ts
|
|
5634
|
-
var optionalQueryString =
|
|
7311
|
+
var optionalQueryString = z8.preprocess(
|
|
5635
7312
|
(value) => typeof value === "string" && value.length > 0 ? value : void 0,
|
|
5636
|
-
|
|
7313
|
+
z8.string().optional()
|
|
5637
7314
|
);
|
|
5638
|
-
var optionalQueryInt =
|
|
7315
|
+
var optionalQueryInt = z8.preprocess((value) => {
|
|
5639
7316
|
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
5640
7317
|
const parsed = Number.parseInt(value, 10);
|
|
5641
7318
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
5642
|
-
},
|
|
5643
|
-
var queryBoolean =
|
|
7319
|
+
}, z8.number().int().optional());
|
|
7320
|
+
var queryBoolean = z8.preprocess(
|
|
5644
7321
|
(value) => value === "true" ? true : value === "false" ? false : void 0,
|
|
5645
|
-
|
|
7322
|
+
z8.boolean().optional()
|
|
5646
7323
|
);
|
|
5647
|
-
var clampedQueryInt = (fallback, min, max) =>
|
|
7324
|
+
var clampedQueryInt = (fallback, min, max) => z8.preprocess(
|
|
5648
7325
|
(value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
|
|
5649
|
-
|
|
7326
|
+
z8.number().int()
|
|
5650
7327
|
);
|
|
5651
|
-
var feedQuerySchema =
|
|
7328
|
+
var feedQuerySchema = z8.object({
|
|
5652
7329
|
limit: clampedQueryInt(50, 1, 200),
|
|
5653
7330
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
5654
7331
|
});
|
|
5655
|
-
var auditExportQuerySchema =
|
|
5656
|
-
format:
|
|
7332
|
+
var auditExportQuerySchema = z8.object({
|
|
7333
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
5657
7334
|
limit: clampedQueryInt(1e4, 1, 1e4),
|
|
5658
7335
|
tool: optionalQueryString,
|
|
5659
7336
|
decision: optionalQueryString,
|
|
@@ -5665,9 +7342,13 @@ var auditExportQuerySchema = z7.object({
|
|
|
5665
7342
|
from: optionalQueryString,
|
|
5666
7343
|
to: optionalQueryString,
|
|
5667
7344
|
upstream_status_min: optionalQueryInt,
|
|
5668
|
-
upstream_status_max: optionalQueryInt
|
|
7345
|
+
upstream_status_max: optionalQueryInt,
|
|
7346
|
+
origin: optionalQueryString,
|
|
7347
|
+
record_kind: optionalQueryString,
|
|
7348
|
+
channel_id: optionalQueryString,
|
|
7349
|
+
sender_id: optionalQueryString
|
|
5669
7350
|
});
|
|
5670
|
-
var auditQuerySchema =
|
|
7351
|
+
var auditQuerySchema = z8.object({
|
|
5671
7352
|
limit: clampedQueryInt(50, 1, 1e3),
|
|
5672
7353
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
|
|
5673
7354
|
tool: optionalQueryString,
|
|
@@ -5681,14 +7362,18 @@ var auditQuerySchema = z7.object({
|
|
|
5681
7362
|
destructive: queryBoolean,
|
|
5682
7363
|
dry_run: queryBoolean,
|
|
5683
7364
|
upstream_status_min: optionalQueryInt,
|
|
5684
|
-
upstream_status_max: optionalQueryInt
|
|
7365
|
+
upstream_status_max: optionalQueryInt,
|
|
7366
|
+
origin: optionalQueryString,
|
|
7367
|
+
record_kind: optionalQueryString,
|
|
7368
|
+
channel_id: optionalQueryString,
|
|
7369
|
+
sender_id: optionalQueryString
|
|
5685
7370
|
});
|
|
5686
|
-
var analyticsQuerySchema =
|
|
7371
|
+
var analyticsQuerySchema = z8.object({
|
|
5687
7372
|
from: optionalQueryString,
|
|
5688
7373
|
to: optionalQueryString
|
|
5689
7374
|
});
|
|
5690
|
-
var authSessionBodySchema =
|
|
5691
|
-
secret:
|
|
7375
|
+
var authSessionBodySchema = z8.object({
|
|
7376
|
+
secret: z8.string()
|
|
5692
7377
|
});
|
|
5693
7378
|
var SESSION_COOKIE = "helio_session";
|
|
5694
7379
|
var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
@@ -5748,7 +7433,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
5748
7433
|
} = deps;
|
|
5749
7434
|
const apiSecret = options?.apiSecret;
|
|
5750
7435
|
const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
|
|
5751
|
-
const app = new
|
|
7436
|
+
const app = new Hono8();
|
|
5752
7437
|
app.use(
|
|
5753
7438
|
"*",
|
|
5754
7439
|
cors({
|
|
@@ -5888,7 +7573,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
5888
7573
|
from: query.from,
|
|
5889
7574
|
to: query.to,
|
|
5890
7575
|
upstream_status_min: query.upstream_status_min,
|
|
5891
|
-
upstream_status_max: query.upstream_status_max
|
|
7576
|
+
upstream_status_max: query.upstream_status_max,
|
|
7577
|
+
origin: query.origin,
|
|
7578
|
+
record_kind: query.record_kind,
|
|
7579
|
+
channel_id: query.channel_id,
|
|
7580
|
+
sender_id: query.sender_id
|
|
5892
7581
|
};
|
|
5893
7582
|
const result = auditStore.list(filters, { limit, order: "asc" });
|
|
5894
7583
|
if (format === "csv") {
|
|
@@ -5930,7 +7619,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
5930
7619
|
flagged_destructive: query.destructive,
|
|
5931
7620
|
dry_run: query.dry_run,
|
|
5932
7621
|
upstream_status_min: query.upstream_status_min,
|
|
5933
|
-
upstream_status_max: query.upstream_status_max
|
|
7622
|
+
upstream_status_max: query.upstream_status_max,
|
|
7623
|
+
origin: query.origin,
|
|
7624
|
+
record_kind: query.record_kind,
|
|
7625
|
+
channel_id: query.channel_id,
|
|
7626
|
+
sender_id: query.sender_id
|
|
5934
7627
|
};
|
|
5935
7628
|
const result = auditStore.list(filters, { limit, offset, order: "desc" });
|
|
5936
7629
|
return c.json({
|
|
@@ -5991,7 +7684,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
5991
7684
|
app.get("/api/events", (c) => {
|
|
5992
7685
|
return streamSSE(c, async (stream) => {
|
|
5993
7686
|
if (closed) return;
|
|
5994
|
-
const connId =
|
|
7687
|
+
const connId = randomUUID6();
|
|
5995
7688
|
let streamClosed = false;
|
|
5996
7689
|
let stopHeartbeat = () => {
|
|
5997
7690
|
};
|
|
@@ -6020,7 +7713,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6020
7713
|
void stream.writeSSE({
|
|
6021
7714
|
event: eventType,
|
|
6022
7715
|
data: JSON.stringify(data),
|
|
6023
|
-
id:
|
|
7716
|
+
id: randomUUID6()
|
|
6024
7717
|
}).then(() => {
|
|
6025
7718
|
const conn = activeConnections.get(connId);
|
|
6026
7719
|
if (conn) conn.lastWrite = Date.now();
|
|
@@ -6121,6 +7814,8 @@ export {
|
|
|
6121
7814
|
ConfigError,
|
|
6122
7815
|
DashboardEventBus,
|
|
6123
7816
|
EvidenceStore,
|
|
7817
|
+
GovernanceConfigError,
|
|
7818
|
+
GovernanceService,
|
|
6124
7819
|
GovernedForwarder,
|
|
6125
7820
|
PolicyParseError,
|
|
6126
7821
|
QueueChannel,
|