@gethelio/proxy 0.4.0 → 0.6.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 +1822 -267
- 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 +439 -11
- package/dist/index.js +1790 -266
- package/package.json +2 -2
- 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,30 @@ 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(),
|
|
164
195
|
/**
|
|
165
196
|
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
166
197
|
* description) has drifted from the baseline Helio captured on first
|
|
@@ -221,7 +252,14 @@ var auditSchema = z.object({
|
|
|
221
252
|
var sdkSchema = z.object({
|
|
222
253
|
enabled: z.boolean().default(false),
|
|
223
254
|
port: z.number().int().min(1).max(65535).default(3200),
|
|
224
|
-
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")
|
|
225
263
|
});
|
|
226
264
|
var helioConfigBaseSchema = z.object({
|
|
227
265
|
version: z.literal("1"),
|
|
@@ -284,6 +322,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
284
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.`
|
|
285
323
|
});
|
|
286
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
|
+
}
|
|
287
341
|
if (rule.action === "rate_limit") {
|
|
288
342
|
if (rule.limits?.max_calls === void 0) {
|
|
289
343
|
ctx.addIssue({
|
|
@@ -425,6 +479,7 @@ var PolicyParseError = class extends Error {
|
|
|
425
479
|
|
|
426
480
|
// src/policy/parser.ts
|
|
427
481
|
var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
|
|
482
|
+
var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
|
|
428
483
|
function compilePolicies(config) {
|
|
429
484
|
const warnings = [];
|
|
430
485
|
const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
|
|
@@ -433,10 +488,36 @@ function compilePolicies(config) {
|
|
|
433
488
|
flagDestructive: config.flag_destructive,
|
|
434
489
|
...config.dry_run && { dryRun: true },
|
|
435
490
|
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
436
|
-
rules
|
|
491
|
+
rules,
|
|
492
|
+
...config.install && { install: compileInstallPolicy(config.install) }
|
|
437
493
|
};
|
|
438
494
|
return { policy, warnings };
|
|
439
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
|
+
}
|
|
440
521
|
function compileRule(rule, index, warnings) {
|
|
441
522
|
const match = compileMatch(rule.match, index, rule.name);
|
|
442
523
|
const approval = compileApproval(rule.approval);
|
|
@@ -469,7 +550,10 @@ function compileMatch(match, ruleIndex, ruleName) {
|
|
|
469
550
|
...match.input !== void 0 && {
|
|
470
551
|
input: flattenInputConditions(match.input, ruleIndex, ruleName)
|
|
471
552
|
},
|
|
472
|
-
...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
|
+
}
|
|
473
557
|
};
|
|
474
558
|
}
|
|
475
559
|
function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
@@ -521,6 +605,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
521
605
|
}
|
|
522
606
|
return conditions;
|
|
523
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
|
+
}
|
|
524
645
|
function compileApproval(approval) {
|
|
525
646
|
if (!approval) return void 0;
|
|
526
647
|
return {
|
|
@@ -2155,12 +2276,31 @@ function matchEnvironment(required, ctx) {
|
|
|
2155
2276
|
if (ctx.environment === void 0) return false;
|
|
2156
2277
|
return ctx.environment === required;
|
|
2157
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
|
+
}
|
|
2158
2297
|
function matchRule(rule, ctx) {
|
|
2159
2298
|
const { match } = rule;
|
|
2160
2299
|
if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
|
|
2161
2300
|
if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
|
|
2162
2301
|
if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
|
|
2163
2302
|
if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
|
|
2303
|
+
if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
|
|
2164
2304
|
return true;
|
|
2165
2305
|
}
|
|
2166
2306
|
|
|
@@ -2183,6 +2323,197 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2183
2323
|
};
|
|
2184
2324
|
}
|
|
2185
2325
|
|
|
2326
|
+
// src/evidence/grounding.ts
|
|
2327
|
+
function checkEvidence(store, sessionId, requirements) {
|
|
2328
|
+
if (requirements.length === 0) {
|
|
2329
|
+
return { satisfied: true, missing: [], expired: [], found: [] };
|
|
2330
|
+
}
|
|
2331
|
+
const found = [];
|
|
2332
|
+
const missing = [];
|
|
2333
|
+
const expired = [];
|
|
2334
|
+
for (const key of requirements) {
|
|
2335
|
+
const valid = store.getEvidence(sessionId, key);
|
|
2336
|
+
if (valid) {
|
|
2337
|
+
found.push(key);
|
|
2338
|
+
} else if (store.hasSeenEvidence(sessionId, key)) {
|
|
2339
|
+
expired.push(key);
|
|
2340
|
+
} else {
|
|
2341
|
+
missing.push(key);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
return {
|
|
2345
|
+
satisfied: missing.length === 0 && expired.length === 0,
|
|
2346
|
+
missing,
|
|
2347
|
+
expired,
|
|
2348
|
+
found
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
2352
|
+
if (requirements.length === 0) {
|
|
2353
|
+
return { satisfied: true, missing: [] };
|
|
2354
|
+
}
|
|
2355
|
+
const requireSuccess = options.requireSuccess ?? true;
|
|
2356
|
+
const missing = [];
|
|
2357
|
+
for (const toolName of requirements) {
|
|
2358
|
+
const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
|
|
2359
|
+
if (!satisfied) {
|
|
2360
|
+
missing.push(toolName);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
return {
|
|
2364
|
+
satisfied: missing.length === 0,
|
|
2365
|
+
missing
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
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
|
+
|
|
2186
2517
|
// src/policy/annotation-cache.ts
|
|
2187
2518
|
var ASPECT_FIELDS = [
|
|
2188
2519
|
"annotations",
|
|
@@ -2285,6 +2616,74 @@ var ToolAnnotationCache = class {
|
|
|
2285
2616
|
this.currentAnnotations = currentAnnotations;
|
|
2286
2617
|
return { updated: true, baselined, drifted, reverted };
|
|
2287
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
|
+
}
|
|
2288
2687
|
/**
|
|
2289
2688
|
* Get the **baseline** annotations for a tool — the definition first seen,
|
|
2290
2689
|
* not the latest upstream claim. Returns `undefined` if the tool has no
|
|
@@ -2318,27 +2717,6 @@ function extractAnnotations(tool) {
|
|
|
2318
2717
|
const annotations = tool["annotations"];
|
|
2319
2718
|
return annotations && typeof annotations === "object" ? annotations : void 0;
|
|
2320
2719
|
}
|
|
2321
|
-
function canonicalize(value) {
|
|
2322
|
-
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2323
|
-
return encoded ?? "";
|
|
2324
|
-
}
|
|
2325
|
-
function sortKeysDeep(value) {
|
|
2326
|
-
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2327
|
-
if (value !== null && typeof value === "object") {
|
|
2328
|
-
const source = value;
|
|
2329
|
-
const out = {};
|
|
2330
|
-
for (const key of Object.keys(source).sort()) {
|
|
2331
|
-
Object.defineProperty(out, key, {
|
|
2332
|
-
value: sortKeysDeep(source[key]),
|
|
2333
|
-
enumerable: true,
|
|
2334
|
-
writable: true,
|
|
2335
|
-
configurable: true
|
|
2336
|
-
});
|
|
2337
|
-
}
|
|
2338
|
-
return out;
|
|
2339
|
-
}
|
|
2340
|
-
return value;
|
|
2341
|
-
}
|
|
2342
2720
|
function extractTools(body) {
|
|
2343
2721
|
if (typeof body !== "object" || body === null) return null;
|
|
2344
2722
|
const b = body;
|
|
@@ -2350,49 +2728,6 @@ function extractTools(body) {
|
|
|
2350
2728
|
return tools;
|
|
2351
2729
|
}
|
|
2352
2730
|
|
|
2353
|
-
// src/evidence/grounding.ts
|
|
2354
|
-
function checkEvidence(store, sessionId, requirements) {
|
|
2355
|
-
if (requirements.length === 0) {
|
|
2356
|
-
return { satisfied: true, missing: [], expired: [], found: [] };
|
|
2357
|
-
}
|
|
2358
|
-
const found = [];
|
|
2359
|
-
const missing = [];
|
|
2360
|
-
const expired = [];
|
|
2361
|
-
for (const key of requirements) {
|
|
2362
|
-
const valid = store.getEvidence(sessionId, key);
|
|
2363
|
-
if (valid) {
|
|
2364
|
-
found.push(key);
|
|
2365
|
-
} else if (store.hasSeenEvidence(sessionId, key)) {
|
|
2366
|
-
expired.push(key);
|
|
2367
|
-
} else {
|
|
2368
|
-
missing.push(key);
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
return {
|
|
2372
|
-
satisfied: missing.length === 0 && expired.length === 0,
|
|
2373
|
-
missing,
|
|
2374
|
-
expired,
|
|
2375
|
-
found
|
|
2376
|
-
};
|
|
2377
|
-
}
|
|
2378
|
-
function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
2379
|
-
if (requirements.length === 0) {
|
|
2380
|
-
return { satisfied: true, missing: [] };
|
|
2381
|
-
}
|
|
2382
|
-
const requireSuccess = options.requireSuccess ?? true;
|
|
2383
|
-
const missing = [];
|
|
2384
|
-
for (const toolName of requirements) {
|
|
2385
|
-
const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
|
|
2386
|
-
if (!satisfied) {
|
|
2387
|
-
missing.push(toolName);
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
|
-
return {
|
|
2391
|
-
satisfied: missing.length === 0,
|
|
2392
|
-
missing
|
|
2393
|
-
};
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
2731
|
// src/feedback/self-repair.ts
|
|
2397
2732
|
function ruleInfo(rule) {
|
|
2398
2733
|
return {
|
|
@@ -2604,6 +2939,7 @@ var GovernedForwarder = class {
|
|
|
2604
2939
|
spendLimiter;
|
|
2605
2940
|
annotationCache = new ToolAnnotationCache();
|
|
2606
2941
|
agentKeyWarned = false;
|
|
2942
|
+
senderKeyWarned = false;
|
|
2607
2943
|
constructor(inner, policy, options) {
|
|
2608
2944
|
this.inner = inner;
|
|
2609
2945
|
this.policy = policy;
|
|
@@ -2765,7 +3101,10 @@ var GovernedForwarder = class {
|
|
|
2765
3101
|
approval_wait_ms: 0,
|
|
2766
3102
|
proxy_compute_ms: 0,
|
|
2767
3103
|
flagged_destructive: false,
|
|
2768
|
-
dry_run: false
|
|
3104
|
+
dry_run: false,
|
|
3105
|
+
record_kind: "drift_event",
|
|
3106
|
+
origin: "mcp",
|
|
3107
|
+
metadata: null
|
|
2769
3108
|
});
|
|
2770
3109
|
}
|
|
2771
3110
|
async handleToolsCall(request) {
|
|
@@ -2777,99 +3116,28 @@ var GovernedForwarder = class {
|
|
|
2777
3116
|
return this.inner.forward(request);
|
|
2778
3117
|
}
|
|
2779
3118
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
2780
|
-
const
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
3119
|
+
const {
|
|
3120
|
+
decision,
|
|
3121
|
+
driftEvent,
|
|
3122
|
+
driftMode,
|
|
3123
|
+
driftBlocked,
|
|
3124
|
+
flaggedDestructive,
|
|
3125
|
+
evidenceResult,
|
|
3126
|
+
dependencyResult,
|
|
3127
|
+
evidenceBlocked,
|
|
3128
|
+
sessionBlocked,
|
|
3129
|
+
isDryRun
|
|
3130
|
+
} = decide({
|
|
3131
|
+
toolName,
|
|
2786
3132
|
toolArguments,
|
|
2787
|
-
|
|
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)
|
|
2788
3140
|
});
|
|
2789
|
-
if (driftEvent && driftMode === "log") {
|
|
2790
|
-
const currentDecision = evaluatePolicy(this.policy, {
|
|
2791
|
-
toolName,
|
|
2792
|
-
annotations: this.annotationCache.getCurrent(toolName),
|
|
2793
|
-
toolArguments,
|
|
2794
|
-
environment: this.environment
|
|
2795
|
-
});
|
|
2796
|
-
decision = stricterDecision(decision, currentDecision);
|
|
2797
|
-
}
|
|
2798
|
-
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2799
|
-
const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
|
|
2800
|
-
const isDestructive = baselineDestructive || currentDestructive;
|
|
2801
|
-
let flaggedDestructive = false;
|
|
2802
|
-
if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
|
|
2803
|
-
flaggedDestructive = true;
|
|
2804
|
-
if (this.policy.flagDestructive === "log") {
|
|
2805
|
-
console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
|
|
2806
|
-
} else {
|
|
2807
|
-
decision = {
|
|
2808
|
-
action: "require_approval",
|
|
2809
|
-
matchedRule: void 0,
|
|
2810
|
-
reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
|
|
2811
|
-
};
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
let driftBlocked = false;
|
|
2815
|
-
if (driftEvent && driftMode !== "log") {
|
|
2816
|
-
driftBlocked = driftMode === "block";
|
|
2817
|
-
decision = {
|
|
2818
|
-
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2819
|
-
matchedRule: void 0,
|
|
2820
|
-
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2821
|
-
};
|
|
2822
|
-
}
|
|
2823
|
-
const originalAction = decision.action;
|
|
2824
|
-
let evidenceResult;
|
|
2825
|
-
let dependencyResult;
|
|
2826
|
-
let evidenceBlocked = false;
|
|
2827
|
-
let sessionBlocked = false;
|
|
2828
|
-
const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
|
|
2829
|
-
if (requiresGroundedSession && !request.sessionId) {
|
|
2830
|
-
sessionBlocked = true;
|
|
2831
|
-
evidenceBlocked = true;
|
|
2832
|
-
decision = {
|
|
2833
|
-
action: "deny",
|
|
2834
|
-
matchedRule: decision.matchedRule,
|
|
2835
|
-
reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
|
|
2836
|
-
};
|
|
2837
|
-
}
|
|
2838
|
-
if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
|
|
2839
|
-
const rule = decision.matchedRule;
|
|
2840
|
-
if (rule.evidence?.requires.length) {
|
|
2841
|
-
evidenceResult = checkEvidence(
|
|
2842
|
-
this.evidenceStore,
|
|
2843
|
-
request.sessionId,
|
|
2844
|
-
rule.evidence.requires
|
|
2845
|
-
);
|
|
2846
|
-
if (!evidenceResult.satisfied) {
|
|
2847
|
-
evidenceBlocked = true;
|
|
2848
|
-
const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
|
|
2849
|
-
decision = {
|
|
2850
|
-
action: "deny",
|
|
2851
|
-
matchedRule: rule,
|
|
2852
|
-
reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
|
|
2853
|
-
};
|
|
2854
|
-
}
|
|
2855
|
-
}
|
|
2856
|
-
if (!evidenceBlocked && rule.requires?.length) {
|
|
2857
|
-
dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
|
|
2858
|
-
requireSuccess: rule.requiresSuccess ?? true
|
|
2859
|
-
});
|
|
2860
|
-
if (!dependencyResult.satisfied) {
|
|
2861
|
-
evidenceBlocked = true;
|
|
2862
|
-
decision = {
|
|
2863
|
-
action: "deny",
|
|
2864
|
-
matchedRule: rule,
|
|
2865
|
-
reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
|
|
2866
|
-
};
|
|
2867
|
-
}
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
const isPerRuleDryRun = originalAction === "dry_run";
|
|
2871
|
-
const isGlobalDryRun = this.policy.dryRun === true;
|
|
2872
|
-
const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
|
|
2873
3141
|
let result;
|
|
2874
3142
|
let approvalOutcome;
|
|
2875
3143
|
let approvalWaitMs = 0;
|
|
@@ -3166,6 +3434,14 @@ var GovernedForwarder = class {
|
|
|
3166
3434
|
);
|
|
3167
3435
|
}
|
|
3168
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}`;
|
|
3169
3445
|
case "tool":
|
|
3170
3446
|
default:
|
|
3171
3447
|
return `tool:${toolName}`;
|
|
@@ -3267,7 +3543,10 @@ var GovernedForwarder = class {
|
|
|
3267
3543
|
approval_wait_ms: approvalWaitMs,
|
|
3268
3544
|
proxy_compute_ms: proxyComputeMs,
|
|
3269
3545
|
flagged_destructive: flaggedDestructive,
|
|
3270
|
-
dry_run: isDryRun ?? false
|
|
3546
|
+
dry_run: isDryRun ?? false,
|
|
3547
|
+
record_kind: "tool_call",
|
|
3548
|
+
origin: "mcp",
|
|
3549
|
+
metadata: null
|
|
3271
3550
|
};
|
|
3272
3551
|
const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
|
|
3273
3552
|
if (isEnforcementDecision) {
|
|
@@ -3373,17 +3652,6 @@ function collectAllowedEvidenceKeys(policy) {
|
|
|
3373
3652
|
}
|
|
3374
3653
|
return [...keys];
|
|
3375
3654
|
}
|
|
3376
|
-
var ACTION_SEVERITY = {
|
|
3377
|
-
deny: 5,
|
|
3378
|
-
require_approval: 4,
|
|
3379
|
-
dry_run: 3,
|
|
3380
|
-
spend_limit: 2,
|
|
3381
|
-
rate_limit: 1,
|
|
3382
|
-
allow: 0
|
|
3383
|
-
};
|
|
3384
|
-
function stricterDecision(a, b) {
|
|
3385
|
-
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
3386
|
-
}
|
|
3387
3655
|
function makeErrorResult(request, code, message, data) {
|
|
3388
3656
|
const body = {
|
|
3389
3657
|
jsonrpc: "2.0",
|
|
@@ -3516,6 +3784,46 @@ var RateLimiter = class {
|
|
|
3516
3784
|
resetAtMs
|
|
3517
3785
|
};
|
|
3518
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
|
+
}
|
|
3519
3827
|
/**
|
|
3520
3828
|
* Check the rate limit without recording the call (non-destructive).
|
|
3521
3829
|
*
|
|
@@ -3730,6 +4038,57 @@ var SpendLimiter = class {
|
|
|
3730
4038
|
resetAtMs
|
|
3731
4039
|
};
|
|
3732
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
|
+
}
|
|
3733
4092
|
/**
|
|
3734
4093
|
* Check the spend limit without recording the spend (non-destructive).
|
|
3735
4094
|
*
|
|
@@ -4175,8 +4534,9 @@ var EvidenceStore = class _EvidenceStore {
|
|
|
4175
4534
|
};
|
|
4176
4535
|
|
|
4177
4536
|
// src/evidence/api.ts
|
|
4178
|
-
import { Hono as
|
|
4179
|
-
import {
|
|
4537
|
+
import { Hono as Hono5 } from "hono";
|
|
4538
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4539
|
+
import { z as z5 } from "zod";
|
|
4180
4540
|
|
|
4181
4541
|
// src/auth/bearer.ts
|
|
4182
4542
|
import { createHash, timingSafeEqual } from "crypto";
|
|
@@ -4188,22 +4548,194 @@ function verifyBearer(authHeader, expected) {
|
|
|
4188
4548
|
return timingSafeEqual(actualDigest, expectedDigest);
|
|
4189
4549
|
}
|
|
4190
4550
|
|
|
4191
|
-
// src/
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
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 evidenceEntrySchema = z4.object({
|
|
4195
4588
|
evidence_key: z4.string().min(1),
|
|
4196
4589
|
evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
4197
4590
|
ttl_seconds: z4.number().int().positive().optional()
|
|
4198
4591
|
});
|
|
4199
|
-
var
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4592
|
+
var auditBody = z4.object({
|
|
4593
|
+
evaluation_id: z4.string().min(1),
|
|
4594
|
+
status: z4.enum(["success", "error", "not_executed"]),
|
|
4595
|
+
error: z4.string().optional(),
|
|
4596
|
+
duration_ms: z4.number().optional(),
|
|
4597
|
+
result: z4.unknown().optional(),
|
|
4598
|
+
actual_amount: z4.number().optional(),
|
|
4599
|
+
// No `.max()` / size refinement here on purpose (issue #11): caps are
|
|
4600
|
+
// enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
|
|
4601
|
+
// an over-cap entry never 400s away the audit row for a call that already ran.
|
|
4602
|
+
evidence: z4.array(evidenceEntrySchema).optional()
|
|
4203
4603
|
});
|
|
4204
|
-
|
|
4604
|
+
var resolveBody = z4.object({
|
|
4605
|
+
resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
|
|
4606
|
+
resolved_by: z4.string().optional(),
|
|
4607
|
+
reason: z4.string().optional(),
|
|
4608
|
+
scope: z4.enum(["once", "always"]).optional()
|
|
4609
|
+
});
|
|
4610
|
+
var MAX_METADATA_BYTES = 4 * 1024;
|
|
4611
|
+
function createGovernanceApp(service) {
|
|
4205
4612
|
const app = new Hono4();
|
|
4206
|
-
const
|
|
4613
|
+
const unavailable = () => ({ error: "governance_unavailable" });
|
|
4614
|
+
app.post("/evaluate", async (c) => {
|
|
4615
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4616
|
+
const parsed = await parseJson(c);
|
|
4617
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4618
|
+
const result = evaluateBody.safeParse(parsed.body);
|
|
4619
|
+
if (!result.success) {
|
|
4620
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4621
|
+
}
|
|
4622
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
4623
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
4624
|
+
}
|
|
4625
|
+
const r = service.evaluate({
|
|
4626
|
+
origin: result.data.origin,
|
|
4627
|
+
adapter_version: result.data.adapter_version,
|
|
4628
|
+
agent_id: result.data.agent_id ?? null,
|
|
4629
|
+
session_id: result.data.session_id ?? null,
|
|
4630
|
+
tool: result.data.tool,
|
|
4631
|
+
arguments: result.data.arguments,
|
|
4632
|
+
metadata: result.data.metadata ?? null
|
|
4633
|
+
});
|
|
4634
|
+
return c.json(r.body, asStatus(r.status));
|
|
4635
|
+
});
|
|
4636
|
+
app.post("/audit", async (c) => {
|
|
4637
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4638
|
+
const parsed = await parseJson(c);
|
|
4639
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4640
|
+
const result = auditBody.safeParse(parsed.body);
|
|
4641
|
+
if (!result.success) {
|
|
4642
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4643
|
+
}
|
|
4644
|
+
const hash = auditPayloadHash(result.data);
|
|
4645
|
+
const r = service.audit(result.data, hash);
|
|
4646
|
+
return c.json(r.body, asStatus(r.status));
|
|
4647
|
+
});
|
|
4648
|
+
app.post("/install-scan", async (c) => {
|
|
4649
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4650
|
+
const parsed = await parseJson(c);
|
|
4651
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4652
|
+
const result = installScanBody.safeParse(parsed.body);
|
|
4653
|
+
if (!result.success) {
|
|
4654
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4655
|
+
}
|
|
4656
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
4657
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
4658
|
+
}
|
|
4659
|
+
const r = service.installScan({
|
|
4660
|
+
origin: result.data.origin,
|
|
4661
|
+
agent_id: result.data.agent_id ?? null,
|
|
4662
|
+
session_id: result.data.session_id ?? null,
|
|
4663
|
+
package: result.data.package,
|
|
4664
|
+
metadata: result.data.metadata ?? null
|
|
4665
|
+
});
|
|
4666
|
+
return c.json(r.body, asStatus(r.status));
|
|
4667
|
+
});
|
|
4668
|
+
app.post("/approval/:id/resolve", async (c) => {
|
|
4669
|
+
if (!service) return c.json(unavailable(), 503);
|
|
4670
|
+
const parsed = await parseJson(c);
|
|
4671
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
4672
|
+
const result = resolveBody.safeParse(parsed.body);
|
|
4673
|
+
if (!result.success) {
|
|
4674
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
4675
|
+
}
|
|
4676
|
+
if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
|
|
4677
|
+
return c.json({ error: "resolved_by is required for approved/denied" }, 400);
|
|
4678
|
+
}
|
|
4679
|
+
const r = service.resolveApproval(c.req.param("id"), result.data);
|
|
4680
|
+
return c.json(r.body, asStatus(r.status));
|
|
4681
|
+
});
|
|
4682
|
+
return app;
|
|
4683
|
+
}
|
|
4684
|
+
function isGovernancePath(path) {
|
|
4685
|
+
return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
|
|
4686
|
+
}
|
|
4687
|
+
async function parseJson(c) {
|
|
4688
|
+
try {
|
|
4689
|
+
return { body: await c.req.json() };
|
|
4690
|
+
} catch {
|
|
4691
|
+
return { error: { error: "Invalid JSON" } };
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
function metadataTooLarge(metadata) {
|
|
4695
|
+
if (metadata == null) return false;
|
|
4696
|
+
return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
|
|
4697
|
+
}
|
|
4698
|
+
function auditPayloadHash(data) {
|
|
4699
|
+
const semantic = {
|
|
4700
|
+
status: data.status,
|
|
4701
|
+
error: data.error ?? null,
|
|
4702
|
+
duration_ms: data.duration_ms ?? null,
|
|
4703
|
+
result: data.result ?? null,
|
|
4704
|
+
actual_amount: data.actual_amount ?? null,
|
|
4705
|
+
evidence: canonicalEvidence(data.evidence)
|
|
4706
|
+
};
|
|
4707
|
+
return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
|
|
4708
|
+
}
|
|
4709
|
+
function canonicalEvidence(evidence) {
|
|
4710
|
+
if (!evidence || evidence.length === 0) return null;
|
|
4711
|
+
return evidence.map((e) => ({
|
|
4712
|
+
evidence_key: e.evidence_key,
|
|
4713
|
+
evidence_data: e.evidence_data ?? null,
|
|
4714
|
+
ttl_seconds: e.ttl_seconds ?? null
|
|
4715
|
+
})).map((norm) => ({ sortKey: canonicalize(norm), norm })).sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0).map((x) => x.norm);
|
|
4716
|
+
}
|
|
4717
|
+
function asStatus(status) {
|
|
4718
|
+
return status;
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
// src/evidence/api.ts
|
|
4722
|
+
var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
4723
|
+
var postEvidenceBody = z5.object({
|
|
4724
|
+
session_id: z5.string().min(1),
|
|
4725
|
+
tool_name: z5.string().min(1),
|
|
4726
|
+
evidence_key: z5.string().min(1),
|
|
4727
|
+
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
4728
|
+
ttl_seconds: z5.number().int().positive().optional()
|
|
4729
|
+
});
|
|
4730
|
+
var postContextBody = z5.object({
|
|
4731
|
+
session_id: z5.string().min(1),
|
|
4732
|
+
key: z5.string().min(1),
|
|
4733
|
+
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
4734
|
+
});
|
|
4735
|
+
function createSidebandApp(store, options = {}) {
|
|
4736
|
+
const app = new Hono5();
|
|
4737
|
+
const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
|
|
4738
|
+
const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
|
|
4207
4739
|
app.use("*", async (c, next) => {
|
|
4208
4740
|
const origin = c.req.header("origin");
|
|
4209
4741
|
if (origin) {
|
|
@@ -4214,20 +4746,26 @@ function createSidebandApp(store, options = {}) {
|
|
|
4214
4746
|
}
|
|
4215
4747
|
await next();
|
|
4216
4748
|
});
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
}
|
|
4749
|
+
app.use(
|
|
4750
|
+
"*",
|
|
4751
|
+
bodyLimit({
|
|
4752
|
+
maxSize: SIDEBAND_BODY_LIMIT_BYTES,
|
|
4753
|
+
onError: (c) => c.json({ error: "request_body_too_large" }, 413)
|
|
4754
|
+
})
|
|
4755
|
+
);
|
|
4756
|
+
app.use("*", async (c, next) => {
|
|
4757
|
+
if (c.req.path === "/healthz") {
|
|
4227
4758
|
await next();
|
|
4228
|
-
|
|
4229
|
-
|
|
4759
|
+
return;
|
|
4760
|
+
}
|
|
4761
|
+
const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
|
|
4762
|
+
if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
|
|
4763
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
4764
|
+
}
|
|
4765
|
+
await next();
|
|
4766
|
+
});
|
|
4230
4767
|
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
4768
|
+
app.route("/", createGovernanceApp(options.governance));
|
|
4231
4769
|
app.post("/evidence", async (c) => {
|
|
4232
4770
|
let body;
|
|
4233
4771
|
try {
|
|
@@ -4290,9 +4828,863 @@ function createSidebandApp(store, options = {}) {
|
|
|
4290
4828
|
return app;
|
|
4291
4829
|
}
|
|
4292
4830
|
|
|
4831
|
+
// src/sideband/governance-service.ts
|
|
4832
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
4833
|
+
|
|
4834
|
+
// src/sideband/errors.ts
|
|
4835
|
+
var GovernanceConfigError = class extends Error {
|
|
4836
|
+
constructor(message) {
|
|
4837
|
+
super(message);
|
|
4838
|
+
this.name = "GovernanceConfigError";
|
|
4839
|
+
}
|
|
4840
|
+
};
|
|
4841
|
+
|
|
4842
|
+
// src/sideband/governance-service.ts
|
|
4843
|
+
var MAX_ORIGINS = 32;
|
|
4844
|
+
var MAX_TOOLS_PER_ORIGIN = 1024;
|
|
4845
|
+
var MAX_TOOL_INPUT_BYTES = 64 * 1024;
|
|
4846
|
+
var MAX_PENDING_COUNT = 1e4;
|
|
4847
|
+
var MAX_PENDING_BYTES = 64 * 1024 * 1024;
|
|
4848
|
+
var MAX_SENDER_KEYS = 5e4;
|
|
4849
|
+
var MAX_EVIDENCE_ENTRIES = 16;
|
|
4850
|
+
var MAX_EVIDENCE_BYTES = 64 * 1024;
|
|
4851
|
+
var SWEEP_INTERVAL_MS2 = 3e4;
|
|
4852
|
+
var GovernanceService = class {
|
|
4853
|
+
policy;
|
|
4854
|
+
environment;
|
|
4855
|
+
evidenceStore;
|
|
4856
|
+
approvalRouter;
|
|
4857
|
+
rateLimiter;
|
|
4858
|
+
spendLimiter;
|
|
4859
|
+
auditWriter;
|
|
4860
|
+
approvalTimeoutMs;
|
|
4861
|
+
ttlMs;
|
|
4862
|
+
now;
|
|
4863
|
+
maxPending;
|
|
4864
|
+
maxPendingBytes;
|
|
4865
|
+
maxSenderKeys;
|
|
4866
|
+
/** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
|
|
4867
|
+
senderKeys = /* @__PURE__ */ new Set();
|
|
4868
|
+
pending = /* @__PURE__ */ new Map();
|
|
4869
|
+
tombstones = /* @__PURE__ */ new Map();
|
|
4870
|
+
caches = /* @__PURE__ */ new Map();
|
|
4871
|
+
/** Native approval ticket id → its pending evaluation id, for on-access
|
|
4872
|
+
* deadline enforcement on the resolve path. */
|
|
4873
|
+
ticketToEvaluation = /* @__PURE__ */ new Map();
|
|
4874
|
+
pendingBytes = 0;
|
|
4875
|
+
sweepTimer = null;
|
|
4876
|
+
closed = false;
|
|
4877
|
+
constructor(options) {
|
|
4878
|
+
this.policy = options.policy;
|
|
4879
|
+
this.environment = options.environment;
|
|
4880
|
+
this.evidenceStore = options.evidenceStore;
|
|
4881
|
+
this.approvalRouter = options.approvalRouter;
|
|
4882
|
+
this.rateLimiter = options.rateLimiter;
|
|
4883
|
+
this.spendLimiter = options.spendLimiter;
|
|
4884
|
+
this.auditWriter = options.auditWriter;
|
|
4885
|
+
this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
|
|
4886
|
+
this.ttlMs = options.ttlMs ?? 6e5;
|
|
4887
|
+
this.now = options.now ?? Date.now;
|
|
4888
|
+
this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
|
|
4889
|
+
this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
|
|
4890
|
+
this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
|
|
4891
|
+
this.assertApprovalRouter(this.policy);
|
|
4892
|
+
const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
|
|
4893
|
+
if (sweepMs > 0) {
|
|
4894
|
+
this.sweepTimer = setInterval(() => {
|
|
4895
|
+
this.sweep();
|
|
4896
|
+
}, sweepMs);
|
|
4897
|
+
this.sweepTimer.unref();
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
/** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
|
|
4901
|
+
updatePolicy(policy) {
|
|
4902
|
+
this.assertApprovalRouter(policy);
|
|
4903
|
+
this.policy = policy;
|
|
4904
|
+
}
|
|
4905
|
+
// -------------------------------------------------------------------------
|
|
4906
|
+
// POST /evaluate
|
|
4907
|
+
// -------------------------------------------------------------------------
|
|
4908
|
+
evaluate(req) {
|
|
4909
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
4910
|
+
if (reserved) {
|
|
4911
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
4912
|
+
}
|
|
4913
|
+
const inputBytes = byteLength(req.arguments ?? {});
|
|
4914
|
+
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
4915
|
+
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
4916
|
+
}
|
|
4917
|
+
const entryBytes = inputBytes + byteLength(req.metadata ?? {});
|
|
4918
|
+
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
4919
|
+
return { status: 400, body: { error: "origin_limit_exceeded" } };
|
|
4920
|
+
}
|
|
4921
|
+
if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
|
|
4922
|
+
return { status: 503, body: { error: "evaluation_backlog_full" } };
|
|
4923
|
+
}
|
|
4924
|
+
const cache = this.cacheFor(req.origin);
|
|
4925
|
+
const toolName = req.tool.name;
|
|
4926
|
+
const hasDefinition = definitionProvided(req.tool);
|
|
4927
|
+
if (hasDefinition) {
|
|
4928
|
+
if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
|
|
4929
|
+
return { status: 400, body: { error: "tool_baseline_limit" } };
|
|
4930
|
+
}
|
|
4931
|
+
cache.updateSingle(toMcpToolDef(req.tool));
|
|
4932
|
+
}
|
|
4933
|
+
const pipeline = decide({
|
|
4934
|
+
toolName,
|
|
4935
|
+
toolArguments: req.arguments,
|
|
4936
|
+
sessionId: req.session_id ?? void 0,
|
|
4937
|
+
policy: this.policy,
|
|
4938
|
+
environment: this.environment,
|
|
4939
|
+
evidenceStore: this.evidenceStore,
|
|
4940
|
+
baselineAnnotations: cache.get(toolName),
|
|
4941
|
+
currentAnnotations: cache.getCurrent(toolName),
|
|
4942
|
+
driftEvent: cache.getDrift(toolName),
|
|
4943
|
+
metadata: req.metadata ?? void 0,
|
|
4944
|
+
agentId: req.agent_id ?? void 0
|
|
4945
|
+
});
|
|
4946
|
+
const { decision } = pipeline;
|
|
4947
|
+
const evaluationId = randomUUID2();
|
|
4948
|
+
const timestampIso = new Date(this.now()).toISOString();
|
|
4949
|
+
let wire;
|
|
4950
|
+
let limitPlan;
|
|
4951
|
+
let limitsBlock;
|
|
4952
|
+
const senderId = senderIdOf(req.metadata);
|
|
4953
|
+
if (pipeline.isDryRun) {
|
|
4954
|
+
wire = "dry_run";
|
|
4955
|
+
} else if (decision.action === "deny") {
|
|
4956
|
+
wire = "deny";
|
|
4957
|
+
} else if (decision.action === "require_approval") {
|
|
4958
|
+
wire = "require_approval";
|
|
4959
|
+
} else if (decision.action === "rate_limit") {
|
|
4960
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
4961
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
4962
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
4963
|
+
}
|
|
4964
|
+
limitPlan = planned?.plan;
|
|
4965
|
+
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
4966
|
+
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
4967
|
+
} else if (decision.action === "spend_limit") {
|
|
4968
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
4969
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
4970
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
4971
|
+
}
|
|
4972
|
+
limitPlan = planned?.plan;
|
|
4973
|
+
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
4974
|
+
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
4975
|
+
} else {
|
|
4976
|
+
wire = "allow";
|
|
4977
|
+
}
|
|
4978
|
+
const matchedRuleName = decision.matchedRule?.name ?? null;
|
|
4979
|
+
const matchedRuleIndex = decision.matchedRule?.index ?? null;
|
|
4980
|
+
const responseBody = {
|
|
4981
|
+
evaluation_id: evaluationId,
|
|
4982
|
+
decision: wire,
|
|
4983
|
+
reason: decision.reason,
|
|
4984
|
+
matched_rule: matchedRuleName,
|
|
4985
|
+
matched_rule_index: matchedRuleIndex
|
|
4986
|
+
};
|
|
4987
|
+
if (isBlocking(wire)) {
|
|
4988
|
+
responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
|
|
4989
|
+
}
|
|
4990
|
+
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
4991
|
+
if (wire === "dry_run") {
|
|
4992
|
+
responseBody["dry_run"] = {
|
|
4993
|
+
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
|
|
4994
|
+
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
4995
|
+
limits_ok: true
|
|
4996
|
+
};
|
|
4997
|
+
}
|
|
4998
|
+
if (pipeline.driftEvent) {
|
|
4999
|
+
responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
|
|
5000
|
+
}
|
|
5001
|
+
if (isTerminalAtEvaluate(wire)) {
|
|
5002
|
+
const auditId = this.writeAudit({
|
|
5003
|
+
timestampIso,
|
|
5004
|
+
origin: req.origin,
|
|
5005
|
+
agentId: req.agent_id,
|
|
5006
|
+
sessionId: req.session_id,
|
|
5007
|
+
toolName,
|
|
5008
|
+
toolInput: req.arguments ?? {},
|
|
5009
|
+
metadata: req.metadata,
|
|
5010
|
+
action: decision.action,
|
|
5011
|
+
wire,
|
|
5012
|
+
matchedRuleName,
|
|
5013
|
+
matchedRuleIndex,
|
|
5014
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5015
|
+
dryRun: wire === "dry_run",
|
|
5016
|
+
recordKind: "tool_call",
|
|
5017
|
+
limitsChain: limitsBlock
|
|
5018
|
+
});
|
|
5019
|
+
this.tombstones.set(evaluationId, {
|
|
5020
|
+
auditRecordId: auditId,
|
|
5021
|
+
payloadHash: null,
|
|
5022
|
+
finalizedBy: "evaluate",
|
|
5023
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5024
|
+
});
|
|
5025
|
+
return { status: 200, body: responseBody };
|
|
5026
|
+
}
|
|
5027
|
+
let approvalTicketId;
|
|
5028
|
+
let ticketTimeoutAtMs;
|
|
5029
|
+
if (wire === "require_approval") {
|
|
5030
|
+
const router = this.approvalRouter;
|
|
5031
|
+
if (!router) {
|
|
5032
|
+
throw new GovernanceConfigError(
|
|
5033
|
+
"[helio] invariant violation: require_approval decision without an approvalRouter"
|
|
5034
|
+
);
|
|
5035
|
+
}
|
|
5036
|
+
const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
|
|
5037
|
+
const ticket = router.createNativeTicket({
|
|
5038
|
+
tool_name: toolName,
|
|
5039
|
+
tool_input: req.arguments ?? {},
|
|
5040
|
+
matched_rule: decision.matchedRule,
|
|
5041
|
+
session_id: req.session_id,
|
|
5042
|
+
origin: req.origin,
|
|
5043
|
+
timeout_ms: timeoutMs
|
|
5044
|
+
});
|
|
5045
|
+
approvalTicketId = ticket.id;
|
|
5046
|
+
ticketTimeoutAtMs = this.now() + timeoutMs;
|
|
5047
|
+
responseBody["approval"] = {
|
|
5048
|
+
id: ticket.id,
|
|
5049
|
+
timeout_ms: timeoutMs,
|
|
5050
|
+
resolve_path: `/approval/${ticket.id}/resolve`
|
|
5051
|
+
};
|
|
5052
|
+
}
|
|
5053
|
+
const entry = {
|
|
5054
|
+
evaluationId,
|
|
5055
|
+
origin: req.origin,
|
|
5056
|
+
agentId: req.agent_id,
|
|
5057
|
+
sessionId: req.session_id,
|
|
5058
|
+
toolName,
|
|
5059
|
+
toolInput: req.arguments ?? {},
|
|
5060
|
+
metadata: req.metadata,
|
|
5061
|
+
action: decision.action,
|
|
5062
|
+
matchedRuleName,
|
|
5063
|
+
matchedRuleIndex,
|
|
5064
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5065
|
+
limitPlan,
|
|
5066
|
+
approvalTicketId,
|
|
5067
|
+
timestampIso,
|
|
5068
|
+
createdAtMs: this.now(),
|
|
5069
|
+
evaluationExpiresAtMs: this.now() + this.ttlMs,
|
|
5070
|
+
ticketTimeoutAtMs,
|
|
5071
|
+
bytes: entryBytes
|
|
5072
|
+
};
|
|
5073
|
+
this.pending.set(evaluationId, entry);
|
|
5074
|
+
this.pendingBytes += entryBytes;
|
|
5075
|
+
if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
|
|
5076
|
+
return { status: 200, body: responseBody };
|
|
5077
|
+
}
|
|
5078
|
+
// -------------------------------------------------------------------------
|
|
5079
|
+
// POST /audit
|
|
5080
|
+
// -------------------------------------------------------------------------
|
|
5081
|
+
audit(req, payloadHash) {
|
|
5082
|
+
const id = req.evaluation_id;
|
|
5083
|
+
const tomb = this.tombstones.get(id);
|
|
5084
|
+
if (tomb) {
|
|
5085
|
+
if (tomb.finalizedBy === "expired") {
|
|
5086
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5087
|
+
}
|
|
5088
|
+
if (tomb.finalizedBy === "evaluate") {
|
|
5089
|
+
return {
|
|
5090
|
+
status: 200,
|
|
5091
|
+
body: {
|
|
5092
|
+
ok: true,
|
|
5093
|
+
audit_record_id: tomb.auditRecordId,
|
|
5094
|
+
already_finalized: true,
|
|
5095
|
+
finalized_by: "evaluate"
|
|
5096
|
+
}
|
|
5097
|
+
};
|
|
5098
|
+
}
|
|
5099
|
+
if (tomb.payloadHash === payloadHash) {
|
|
5100
|
+
return {
|
|
5101
|
+
status: 200,
|
|
5102
|
+
body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
|
|
5103
|
+
};
|
|
5104
|
+
}
|
|
5105
|
+
return { status: 409, body: { error: "evaluation_conflict" } };
|
|
5106
|
+
}
|
|
5107
|
+
const entry = this.pending.get(id);
|
|
5108
|
+
if (!entry) {
|
|
5109
|
+
return { status: 404, body: { error: "evaluation_unknown" } };
|
|
5110
|
+
}
|
|
5111
|
+
if (this.enforceDeadlines(entry) === "expired") {
|
|
5112
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5113
|
+
}
|
|
5114
|
+
let approvalStatus = null;
|
|
5115
|
+
let approvedBy = null;
|
|
5116
|
+
if (entry.approvalTicketId) {
|
|
5117
|
+
const ticket = this.getTicketStatus(entry.approvalTicketId);
|
|
5118
|
+
const status = ticket?.status;
|
|
5119
|
+
if (!status || status === "pending") {
|
|
5120
|
+
return { status: 409, body: { error: "approval_unresolved" } };
|
|
5121
|
+
}
|
|
5122
|
+
approvalStatus = status;
|
|
5123
|
+
approvedBy = ticket.resolved_by ?? null;
|
|
5124
|
+
}
|
|
5125
|
+
if (req.actual_amount !== void 0) {
|
|
5126
|
+
if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
|
|
5127
|
+
return { status: 400, body: { error: "invalid_actual_amount" } };
|
|
5128
|
+
}
|
|
5129
|
+
if (entry.limitPlan?.kind !== "spend") {
|
|
5130
|
+
return { status: 400, body: { error: "no_spend_rule" } };
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
const callHappened = req.status === "success" || req.status === "error";
|
|
5134
|
+
let limitsChain;
|
|
5135
|
+
if (callHappened && entry.limitPlan) {
|
|
5136
|
+
limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
|
|
5137
|
+
}
|
|
5138
|
+
if (callHappened && this.evidenceStore && entry.sessionId) {
|
|
5139
|
+
this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
|
|
5140
|
+
}
|
|
5141
|
+
const evidenceOutcomes = this.populateEvidence(req, entry);
|
|
5142
|
+
const auditId = this.writeAudit({
|
|
5143
|
+
timestampIso: entry.timestampIso,
|
|
5144
|
+
origin: entry.origin,
|
|
5145
|
+
agentId: entry.agentId,
|
|
5146
|
+
sessionId: entry.sessionId,
|
|
5147
|
+
toolName: entry.toolName,
|
|
5148
|
+
toolInput: entry.toolInput,
|
|
5149
|
+
metadata: entry.metadata,
|
|
5150
|
+
action: entry.action,
|
|
5151
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5152
|
+
matchedRuleName: entry.matchedRuleName,
|
|
5153
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
5154
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
5155
|
+
dryRun: false,
|
|
5156
|
+
recordKind: "tool_call",
|
|
5157
|
+
limitsChain,
|
|
5158
|
+
approvalStatus,
|
|
5159
|
+
approvedBy,
|
|
5160
|
+
upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
|
|
5161
|
+
upstreamResponse: req.result ?? null,
|
|
5162
|
+
upstreamLatencyMs: req.duration_ms ?? null
|
|
5163
|
+
});
|
|
5164
|
+
this.discardPending(entry);
|
|
5165
|
+
this.tombstones.set(id, {
|
|
5166
|
+
auditRecordId: auditId,
|
|
5167
|
+
payloadHash,
|
|
5168
|
+
finalizedBy: "audit",
|
|
5169
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5170
|
+
});
|
|
5171
|
+
const body = { ok: true, audit_record_id: auditId };
|
|
5172
|
+
if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
|
|
5173
|
+
return { status: 201, body };
|
|
5174
|
+
}
|
|
5175
|
+
/**
|
|
5176
|
+
* Write the optional `/audit` evidence entries for a successful call
|
|
5177
|
+
* (issue #11), returning a per-entry outcome list — or `undefined`
|
|
5178
|
+
* when there is nothing to report (non-success status, or no evidence
|
|
5179
|
+
* supplied). Caps are enforced here, NOT in route validation, so an over-cap
|
|
5180
|
+
* entry soft-drops without discarding the audit row: entries past
|
|
5181
|
+
* `MAX_EVIDENCE_ENTRIES` → `too_many`; oversized `evidence_data` →
|
|
5182
|
+
* `too_large`; no evidence store on the service → `evidence_unavailable`;
|
|
5183
|
+
* a sessionless evaluation → `no_session`; the store's own rejections
|
|
5184
|
+
* (`key_not_in_policy_allowlist`, `closed`) pass through as the per-entry
|
|
5185
|
+
* reason. None of these fail the audit.
|
|
5186
|
+
*/
|
|
5187
|
+
populateEvidence(req, entry) {
|
|
5188
|
+
if (req.status !== "success" || !req.evidence || req.evidence.length === 0) {
|
|
5189
|
+
return void 0;
|
|
5190
|
+
}
|
|
5191
|
+
const outcomes = [];
|
|
5192
|
+
for (let i = 0; i < req.evidence.length; i++) {
|
|
5193
|
+
const e = req.evidence[i];
|
|
5194
|
+
if (!e) continue;
|
|
5195
|
+
if (i >= MAX_EVIDENCE_ENTRIES) {
|
|
5196
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_many" });
|
|
5197
|
+
continue;
|
|
5198
|
+
}
|
|
5199
|
+
const bytes = Buffer.byteLength(canonicalize(e.evidence_data ?? null), "utf8");
|
|
5200
|
+
if (bytes > MAX_EVIDENCE_BYTES) {
|
|
5201
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_large" });
|
|
5202
|
+
continue;
|
|
5203
|
+
}
|
|
5204
|
+
if (!this.evidenceStore) {
|
|
5205
|
+
outcomes.push({
|
|
5206
|
+
evidence_key: e.evidence_key,
|
|
5207
|
+
stored: false,
|
|
5208
|
+
reason: "evidence_unavailable"
|
|
5209
|
+
});
|
|
5210
|
+
continue;
|
|
5211
|
+
}
|
|
5212
|
+
if (!entry.sessionId) {
|
|
5213
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "no_session" });
|
|
5214
|
+
continue;
|
|
5215
|
+
}
|
|
5216
|
+
const result = this.evidenceStore.putEvidence(entry.sessionId, {
|
|
5217
|
+
evidence_key: e.evidence_key,
|
|
5218
|
+
data: e.evidence_data,
|
|
5219
|
+
tool_name: entry.toolName,
|
|
5220
|
+
ttl_seconds: e.ttl_seconds
|
|
5221
|
+
});
|
|
5222
|
+
outcomes.push(
|
|
5223
|
+
result.stored ? { evidence_key: e.evidence_key, stored: true } : { evidence_key: e.evidence_key, stored: false, reason: result.reason }
|
|
5224
|
+
);
|
|
5225
|
+
}
|
|
5226
|
+
return outcomes;
|
|
5227
|
+
}
|
|
5228
|
+
// -------------------------------------------------------------------------
|
|
5229
|
+
// POST /install-scan — evaluates install-time policy (issue #13)
|
|
5230
|
+
// -------------------------------------------------------------------------
|
|
5231
|
+
installScan(req) {
|
|
5232
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
5233
|
+
if (reserved) {
|
|
5234
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
5235
|
+
}
|
|
5236
|
+
const evaluationId = randomUUID2();
|
|
5237
|
+
const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
|
|
5238
|
+
const verdict = this.evaluateInstall(req);
|
|
5239
|
+
const denied = verdict.decision === "deny";
|
|
5240
|
+
const auditId = this.writeAudit({
|
|
5241
|
+
timestampIso: new Date(this.now()).toISOString(),
|
|
5242
|
+
origin: req.origin,
|
|
5243
|
+
agentId: req.agent_id,
|
|
5244
|
+
sessionId: req.session_id,
|
|
5245
|
+
toolName,
|
|
5246
|
+
toolInput: { ...req.package },
|
|
5247
|
+
metadata: req.metadata,
|
|
5248
|
+
// policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
|
|
5249
|
+
// blocked install as a block, not an allow. The install context lives in
|
|
5250
|
+
// record_kind + block_reason.
|
|
5251
|
+
action: denied ? "deny" : "allow",
|
|
5252
|
+
wire: denied ? "deny" : "allow",
|
|
5253
|
+
matchedRuleName: verdict.matchedRule?.name ?? null,
|
|
5254
|
+
matchedRuleIndex: verdict.matchedRule?.index ?? null,
|
|
5255
|
+
flaggedDestructive: false,
|
|
5256
|
+
dryRun: false,
|
|
5257
|
+
recordKind: "install_scan"
|
|
5258
|
+
});
|
|
5259
|
+
this.tombstones.set(evaluationId, {
|
|
5260
|
+
auditRecordId: auditId,
|
|
5261
|
+
payloadHash: null,
|
|
5262
|
+
finalizedBy: "evaluate",
|
|
5263
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5264
|
+
});
|
|
5265
|
+
const body = {
|
|
5266
|
+
evaluation_id: evaluationId,
|
|
5267
|
+
decision: verdict.decision,
|
|
5268
|
+
reason: verdict.reason,
|
|
5269
|
+
matched_rule: verdict.matchedRule?.name ?? null,
|
|
5270
|
+
matched_rule_index: verdict.matchedRule?.index ?? null
|
|
5271
|
+
};
|
|
5272
|
+
if (denied) {
|
|
5273
|
+
body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
|
|
5274
|
+
}
|
|
5275
|
+
return { status: 200, body };
|
|
5276
|
+
}
|
|
5277
|
+
/** First-match-wins evaluation of the compiled install policy (issue #13). */
|
|
5278
|
+
evaluateInstall(req) {
|
|
5279
|
+
const install = this.policy.install;
|
|
5280
|
+
if (!install) {
|
|
5281
|
+
return { decision: "allow", reason: "no install-time rules defined" };
|
|
5282
|
+
}
|
|
5283
|
+
const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
|
|
5284
|
+
for (const rule of install.rules) {
|
|
5285
|
+
if (matchInstallRule(rule, req.package, metadataView)) {
|
|
5286
|
+
const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
|
|
5287
|
+
return {
|
|
5288
|
+
decision: rule.action === "deny_install" ? "deny" : "allow",
|
|
5289
|
+
matchedRule: rule,
|
|
5290
|
+
reason: `Matched ${label} \u2192 ${rule.action}`
|
|
5291
|
+
};
|
|
5292
|
+
}
|
|
5293
|
+
}
|
|
5294
|
+
return {
|
|
5295
|
+
decision: install.defaultAction,
|
|
5296
|
+
reason: `No matching install rule; default ${install.defaultAction}`
|
|
5297
|
+
};
|
|
5298
|
+
}
|
|
5299
|
+
// -------------------------------------------------------------------------
|
|
5300
|
+
// POST /approval/:id/resolve
|
|
5301
|
+
// -------------------------------------------------------------------------
|
|
5302
|
+
resolveApproval(ticketId, req) {
|
|
5303
|
+
if (!this.approvalRouter) {
|
|
5304
|
+
return { status: 503, body: { error: "governance_unavailable" } };
|
|
5305
|
+
}
|
|
5306
|
+
const ticket = this.getTicketStatus(ticketId);
|
|
5307
|
+
if (!ticket) {
|
|
5308
|
+
return { status: 404, body: { error: "ticket_not_found" } };
|
|
5309
|
+
}
|
|
5310
|
+
if (!ticket.channel_name.startsWith("native:")) {
|
|
5311
|
+
return { status: 409, body: { error: "not_a_native_ticket" } };
|
|
5312
|
+
}
|
|
5313
|
+
const evaluationId = this.ticketToEvaluation.get(ticketId);
|
|
5314
|
+
const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
|
|
5315
|
+
if (entry) this.enforceDeadlines(entry);
|
|
5316
|
+
const current = this.getTicketStatus(ticketId);
|
|
5317
|
+
if (!current || current.status !== "pending") {
|
|
5318
|
+
return { status: 409, body: { error: "already_resolved", status: current?.status } };
|
|
5319
|
+
}
|
|
5320
|
+
const resolved = this.approvalRouter.resolveNativeTicket(
|
|
5321
|
+
ticketId,
|
|
5322
|
+
req.resolution,
|
|
5323
|
+
req.resolved_by,
|
|
5324
|
+
{ denial_reason: req.resolution === "denied" ? req.reason : void 0 }
|
|
5325
|
+
);
|
|
5326
|
+
if (!resolved) {
|
|
5327
|
+
return { status: 409, body: { error: "already_resolved" } };
|
|
5328
|
+
}
|
|
5329
|
+
return { status: 200, body: { ok: true } };
|
|
5330
|
+
}
|
|
5331
|
+
// -------------------------------------------------------------------------
|
|
5332
|
+
// Sweep — GC backstop for callers that never return
|
|
5333
|
+
// -------------------------------------------------------------------------
|
|
5334
|
+
sweep() {
|
|
5335
|
+
for (const entry of [...this.pending.values()]) {
|
|
5336
|
+
this.enforceDeadlines(entry);
|
|
5337
|
+
}
|
|
5338
|
+
const now = this.now();
|
|
5339
|
+
for (const [id, tomb] of this.tombstones) {
|
|
5340
|
+
if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
|
|
5341
|
+
}
|
|
5342
|
+
this.pruneSenderKeys();
|
|
5343
|
+
}
|
|
5344
|
+
/**
|
|
5345
|
+
* Reserve a cardinality slot for a sender-keyed limit (issue #13).
|
|
5346
|
+
*
|
|
5347
|
+
* Only `sender:*` keys are gated — tool/session families are bounded by upstream
|
|
5348
|
+
* cardinality, and the MCP path never reaches here, so structural traffic cannot
|
|
5349
|
+
* be starved. A key already backed by live state (registry or a live limiter
|
|
5350
|
+
* bucket) costs no new slot. At capacity we lazily prune dead keys before failing
|
|
5351
|
+
* closed, so an emptied bucket frees its slot without waiting for the sweep.
|
|
5352
|
+
*/
|
|
5353
|
+
reserveSenderKey(key) {
|
|
5354
|
+
if (!key.startsWith("sender:")) return true;
|
|
5355
|
+
if (this.senderKeys.has(key)) return true;
|
|
5356
|
+
if (this.hasLiveBucket(key)) {
|
|
5357
|
+
this.senderKeys.add(key);
|
|
5358
|
+
return true;
|
|
5359
|
+
}
|
|
5360
|
+
if (this.senderKeys.size >= this.maxSenderKeys) {
|
|
5361
|
+
this.pruneSenderKeys();
|
|
5362
|
+
if (this.senderKeys.size >= this.maxSenderKeys) return false;
|
|
5363
|
+
}
|
|
5364
|
+
this.senderKeys.add(key);
|
|
5365
|
+
return true;
|
|
5366
|
+
}
|
|
5367
|
+
/** Drop registry keys with no pending evaluation AND no live limiter bucket. */
|
|
5368
|
+
pruneSenderKeys() {
|
|
5369
|
+
if (this.senderKeys.size === 0) return;
|
|
5370
|
+
const inUse = /* @__PURE__ */ new Set();
|
|
5371
|
+
for (const entry of this.pending.values()) {
|
|
5372
|
+
if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
|
|
5373
|
+
inUse.add(entry.limitPlan.key);
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
for (const key of this.senderKeys) {
|
|
5377
|
+
if (inUse.has(key)) continue;
|
|
5378
|
+
if (this.hasLiveBucket(key)) continue;
|
|
5379
|
+
this.senderKeys.delete(key);
|
|
5380
|
+
}
|
|
5381
|
+
}
|
|
5382
|
+
/**
|
|
5383
|
+
* Whether either limiter still holds a live bucket for `key`. Uses the public
|
|
5384
|
+
* `getKeyState()` — never the limiters' private maps — and its lazy eviction of
|
|
5385
|
+
* an emptied bucket IS the prune-on-touch mechanism.
|
|
5386
|
+
*/
|
|
5387
|
+
hasLiveBucket(key) {
|
|
5388
|
+
return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
|
|
5389
|
+
}
|
|
5390
|
+
close() {
|
|
5391
|
+
if (this.closed) return;
|
|
5392
|
+
this.closed = true;
|
|
5393
|
+
if (this.sweepTimer) {
|
|
5394
|
+
clearInterval(this.sweepTimer);
|
|
5395
|
+
this.sweepTimer = null;
|
|
5396
|
+
}
|
|
5397
|
+
this.pending.clear();
|
|
5398
|
+
this.tombstones.clear();
|
|
5399
|
+
this.caches.clear();
|
|
5400
|
+
this.senderKeys.clear();
|
|
5401
|
+
this.pendingBytes = 0;
|
|
5402
|
+
}
|
|
5403
|
+
// -------------------------------------------------------------------------
|
|
5404
|
+
// Internals
|
|
5405
|
+
// -------------------------------------------------------------------------
|
|
5406
|
+
/** Apply crossed deadlines to one pending entry. Returns its post-state. */
|
|
5407
|
+
enforceDeadlines(entry) {
|
|
5408
|
+
const now = this.now();
|
|
5409
|
+
if (now >= entry.evaluationExpiresAtMs) {
|
|
5410
|
+
if (entry.approvalTicketId) {
|
|
5411
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
5412
|
+
}
|
|
5413
|
+
const auditId = this.writeAudit({
|
|
5414
|
+
timestampIso: entry.timestampIso,
|
|
5415
|
+
origin: entry.origin,
|
|
5416
|
+
agentId: entry.agentId,
|
|
5417
|
+
sessionId: entry.sessionId,
|
|
5418
|
+
toolName: entry.toolName,
|
|
5419
|
+
toolInput: entry.toolInput,
|
|
5420
|
+
metadata: entry.metadata,
|
|
5421
|
+
action: entry.action,
|
|
5422
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5423
|
+
matchedRuleName: entry.matchedRuleName,
|
|
5424
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
5425
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
5426
|
+
dryRun: false,
|
|
5427
|
+
recordKind: "evaluation_expired",
|
|
5428
|
+
sidebandUnreported: true
|
|
5429
|
+
});
|
|
5430
|
+
this.discardPending(entry);
|
|
5431
|
+
this.tombstones.set(entry.evaluationId, {
|
|
5432
|
+
auditRecordId: auditId,
|
|
5433
|
+
payloadHash: null,
|
|
5434
|
+
finalizedBy: "expired",
|
|
5435
|
+
expiresAtMs: now + this.ttlMs
|
|
5436
|
+
});
|
|
5437
|
+
console.error(
|
|
5438
|
+
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
5439
|
+
);
|
|
5440
|
+
return "expired";
|
|
5441
|
+
}
|
|
5442
|
+
if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
|
|
5443
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
5444
|
+
}
|
|
5445
|
+
return "active";
|
|
5446
|
+
}
|
|
5447
|
+
cacheFor(origin) {
|
|
5448
|
+
let cache = this.caches.get(origin);
|
|
5449
|
+
if (!cache) {
|
|
5450
|
+
cache = new ToolAnnotationCache();
|
|
5451
|
+
this.caches.set(origin, cache);
|
|
5452
|
+
}
|
|
5453
|
+
return cache;
|
|
5454
|
+
}
|
|
5455
|
+
discardPending(entry) {
|
|
5456
|
+
if (this.pending.delete(entry.evaluationId)) {
|
|
5457
|
+
this.pendingBytes -= entry.bytes;
|
|
5458
|
+
}
|
|
5459
|
+
if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
|
|
5460
|
+
}
|
|
5461
|
+
getTicketStatus(ticketId) {
|
|
5462
|
+
return this.approvalRouter?.getTicket(ticketId);
|
|
5463
|
+
}
|
|
5464
|
+
planRate(decision, toolName, sessionId, senderId) {
|
|
5465
|
+
const limits = decision.matchedRule?.limits;
|
|
5466
|
+
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
5467
|
+
return { allowed: true };
|
|
5468
|
+
}
|
|
5469
|
+
const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
|
|
5470
|
+
const peek = this.rateLimiter.peek({
|
|
5471
|
+
key,
|
|
5472
|
+
maxCalls: limits.maxCalls,
|
|
5473
|
+
windowMs: limits.windowMs
|
|
5474
|
+
});
|
|
5475
|
+
return {
|
|
5476
|
+
plan: { kind: "rate", key, limits },
|
|
5477
|
+
block: {
|
|
5478
|
+
current: peek.current,
|
|
5479
|
+
limit: peek.limit,
|
|
5480
|
+
window_ms: peek.windowMs,
|
|
5481
|
+
reset_at_ms: peek.resetAtMs
|
|
5482
|
+
},
|
|
5483
|
+
allowed: peek.allowed
|
|
5484
|
+
};
|
|
5485
|
+
}
|
|
5486
|
+
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
5487
|
+
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
5488
|
+
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
5489
|
+
const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
|
|
5490
|
+
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
5491
|
+
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
5492
|
+
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
5493
|
+
}
|
|
5494
|
+
const peek = this.spendLimiter.peek({
|
|
5495
|
+
key,
|
|
5496
|
+
amount: rawAmount,
|
|
5497
|
+
limit: maxSpend.limit,
|
|
5498
|
+
windowMs: maxSpend.windowMs
|
|
5499
|
+
});
|
|
5500
|
+
return {
|
|
5501
|
+
plan: {
|
|
5502
|
+
kind: "spend",
|
|
5503
|
+
key,
|
|
5504
|
+
limits: decision.matchedRule.limits,
|
|
5505
|
+
amount: rawAmount,
|
|
5506
|
+
currency: maxSpend.currency
|
|
5507
|
+
},
|
|
5508
|
+
block: {
|
|
5509
|
+
current_spend: peek.currentSpend,
|
|
5510
|
+
limit: peek.limit,
|
|
5511
|
+
currency: maxSpend.currency,
|
|
5512
|
+
window_ms: peek.windowMs,
|
|
5513
|
+
reset_at_ms: peek.resetAtMs
|
|
5514
|
+
},
|
|
5515
|
+
allowed: peek.allowed
|
|
5516
|
+
};
|
|
5517
|
+
}
|
|
5518
|
+
/** Commit a limit plan at /audit time and return the evidence_chain block. */
|
|
5519
|
+
commitLimit(plan, actualAmount) {
|
|
5520
|
+
if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
|
|
5521
|
+
const r = this.rateLimiter.record({
|
|
5522
|
+
key: plan.key,
|
|
5523
|
+
maxCalls: plan.limits.maxCalls,
|
|
5524
|
+
windowMs: plan.limits.windowMs
|
|
5525
|
+
});
|
|
5526
|
+
return {
|
|
5527
|
+
rate_limit: {
|
|
5528
|
+
allowed: r.allowed,
|
|
5529
|
+
current: r.current,
|
|
5530
|
+
limit: r.limit,
|
|
5531
|
+
window_ms: r.windowMs,
|
|
5532
|
+
reset_at_ms: r.resetAtMs
|
|
5533
|
+
}
|
|
5534
|
+
};
|
|
5535
|
+
}
|
|
5536
|
+
if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
|
|
5537
|
+
const amount = actualAmount ?? plan.amount ?? 0;
|
|
5538
|
+
const r = this.spendLimiter.record({
|
|
5539
|
+
key: plan.key,
|
|
5540
|
+
amount,
|
|
5541
|
+
limit: plan.limits.maxSpend.limit,
|
|
5542
|
+
windowMs: plan.limits.maxSpend.windowMs
|
|
5543
|
+
});
|
|
5544
|
+
this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
|
|
5545
|
+
return {
|
|
5546
|
+
spend_limit: {
|
|
5547
|
+
allowed: r.allowed,
|
|
5548
|
+
current_spend: r.currentSpend,
|
|
5549
|
+
limit: r.limit,
|
|
5550
|
+
window_ms: r.windowMs,
|
|
5551
|
+
reset_at_ms: r.resetAtMs
|
|
5552
|
+
}
|
|
5553
|
+
};
|
|
5554
|
+
}
|
|
5555
|
+
return void 0;
|
|
5556
|
+
}
|
|
5557
|
+
writeAudit(args) {
|
|
5558
|
+
const id = randomUUID2();
|
|
5559
|
+
if (!this.auditWriter) return id;
|
|
5560
|
+
const blockReason = deriveBlockReason(args);
|
|
5561
|
+
let evidenceChain = args.limitsChain ?? null;
|
|
5562
|
+
if (args.sidebandUnreported) {
|
|
5563
|
+
evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
|
|
5564
|
+
}
|
|
5565
|
+
const record = {
|
|
5566
|
+
timestamp: args.timestampIso,
|
|
5567
|
+
session_id: args.sessionId,
|
|
5568
|
+
agent_id: args.agentId,
|
|
5569
|
+
environment: this.environment ?? null,
|
|
5570
|
+
tool_name: args.toolName,
|
|
5571
|
+
tool_input: args.toolInput,
|
|
5572
|
+
policy_decision: args.action,
|
|
5573
|
+
block_reason: blockReason,
|
|
5574
|
+
matched_rule: args.matchedRuleName,
|
|
5575
|
+
matched_rule_index: args.matchedRuleIndex,
|
|
5576
|
+
evidence_chain: evidenceChain,
|
|
5577
|
+
approval_status: args.approvalStatus ?? null,
|
|
5578
|
+
approved_by: args.approvedBy ?? null,
|
|
5579
|
+
upstream_response: args.upstreamResponse ?? null,
|
|
5580
|
+
upstream_error: args.upstreamError ?? null,
|
|
5581
|
+
upstream_http_status: null,
|
|
5582
|
+
upstream_latency_ms: args.upstreamLatencyMs ?? null,
|
|
5583
|
+
total_duration_ms: 0,
|
|
5584
|
+
approval_wait_ms: 0,
|
|
5585
|
+
proxy_compute_ms: 0,
|
|
5586
|
+
flagged_destructive: args.flaggedDestructive,
|
|
5587
|
+
dry_run: args.dryRun,
|
|
5588
|
+
record_kind: args.recordKind,
|
|
5589
|
+
origin: args.origin,
|
|
5590
|
+
metadata: args.metadata
|
|
5591
|
+
};
|
|
5592
|
+
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
5593
|
+
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
5594
|
+
else this.auditWriter.push(record, id);
|
|
5595
|
+
return id;
|
|
5596
|
+
}
|
|
5597
|
+
assertApprovalRouter(policy) {
|
|
5598
|
+
if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
|
|
5599
|
+
throw new GovernanceConfigError(
|
|
5600
|
+
"[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
|
|
5601
|
+
);
|
|
5602
|
+
}
|
|
5603
|
+
};
|
|
5604
|
+
function deriveBlockReason(args) {
|
|
5605
|
+
if (args.recordKind === "evaluation_expired") return null;
|
|
5606
|
+
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
5607
|
+
if (args.dryRun) return null;
|
|
5608
|
+
if (args.approvalStatus === "denied") return "approval_denied";
|
|
5609
|
+
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
5610
|
+
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
5611
|
+
switch (args.wire) {
|
|
5612
|
+
case "deny":
|
|
5613
|
+
return "policy_denied";
|
|
5614
|
+
case "rate_limited":
|
|
5615
|
+
return "rate_limited";
|
|
5616
|
+
case "spend_limited":
|
|
5617
|
+
return "spend_limited";
|
|
5618
|
+
default:
|
|
5619
|
+
return null;
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
function buildFeedback(rule, reason) {
|
|
5623
|
+
const message = rule?.feedback?.message ?? reason;
|
|
5624
|
+
const suggestion = rule?.feedback?.suggestion;
|
|
5625
|
+
return suggestion ? { message, suggestion } : { message };
|
|
5626
|
+
}
|
|
5627
|
+
function isBlocking(wire) {
|
|
5628
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
|
|
5629
|
+
}
|
|
5630
|
+
function isTerminalAtEvaluate(wire) {
|
|
5631
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
|
|
5632
|
+
}
|
|
5633
|
+
function policyCanRequireApproval(policy) {
|
|
5634
|
+
if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
|
|
5635
|
+
return true;
|
|
5636
|
+
}
|
|
5637
|
+
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
5638
|
+
}
|
|
5639
|
+
function buildLimitKey(keyType, toolName, sessionId, senderId) {
|
|
5640
|
+
switch (keyType) {
|
|
5641
|
+
case "session":
|
|
5642
|
+
return `session:${sessionId ?? "unknown"}`;
|
|
5643
|
+
case "sender_id":
|
|
5644
|
+
return `sender:${senderId ?? "unknown"}`;
|
|
5645
|
+
case "agent":
|
|
5646
|
+
case "tool":
|
|
5647
|
+
default:
|
|
5648
|
+
return `tool:${toolName}`;
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
function senderIdOf(metadata) {
|
|
5652
|
+
const v = metadata?.["sender_id"];
|
|
5653
|
+
return typeof v === "string" ? v : null;
|
|
5654
|
+
}
|
|
5655
|
+
function matchInstallRule(rule, pkg2, metadataView) {
|
|
5656
|
+
if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
|
|
5657
|
+
if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
|
|
5658
|
+
if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
|
|
5659
|
+
return false;
|
|
5660
|
+
}
|
|
5661
|
+
return true;
|
|
5662
|
+
}
|
|
5663
|
+
function reservedMetadataKey(metadata) {
|
|
5664
|
+
if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
|
|
5665
|
+
return "agent_id";
|
|
5666
|
+
}
|
|
5667
|
+
return null;
|
|
5668
|
+
}
|
|
5669
|
+
function definitionProvided(tool) {
|
|
5670
|
+
return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
|
|
5671
|
+
}
|
|
5672
|
+
function toMcpToolDef(tool) {
|
|
5673
|
+
const def = { name: tool.name };
|
|
5674
|
+
if (tool.description !== void 0) def["description"] = tool.description;
|
|
5675
|
+
if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
|
|
5676
|
+
if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
|
|
5677
|
+
if (tool.title !== void 0) def["title"] = tool.title;
|
|
5678
|
+
if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
|
|
5679
|
+
return def;
|
|
5680
|
+
}
|
|
5681
|
+
function byteLength(value) {
|
|
5682
|
+
return Buffer.byteLength(canonicalize(value), "utf8");
|
|
5683
|
+
}
|
|
5684
|
+
|
|
4293
5685
|
// src/audit/store.ts
|
|
4294
5686
|
import Database from "better-sqlite3";
|
|
4295
|
-
import { randomUUID as
|
|
5687
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4296
5688
|
import { chmodSync } from "fs";
|
|
4297
5689
|
|
|
4298
5690
|
// src/upstream/response-summary.ts
|
|
@@ -4381,6 +5773,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
4381
5773
|
proxy_compute_ms REAL NOT NULL,
|
|
4382
5774
|
flagged_destructive INTEGER NOT NULL DEFAULT 0,
|
|
4383
5775
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
5776
|
+
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5777
|
+
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5778
|
+
metadata TEXT,
|
|
4384
5779
|
created_at TEXT NOT NULL
|
|
4385
5780
|
);
|
|
4386
5781
|
`;
|
|
@@ -4391,6 +5786,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
|
|
|
4391
5786
|
CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
|
|
4392
5787
|
CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
|
|
4393
5788
|
CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
|
|
5789
|
+
CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
|
|
5790
|
+
CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
4394
5791
|
`;
|
|
4395
5792
|
var INSERT_SQL = `
|
|
4396
5793
|
INSERT INTO audit_records (
|
|
@@ -4399,14 +5796,14 @@ INSERT INTO audit_records (
|
|
|
4399
5796
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
4400
5797
|
upstream_http_status,
|
|
4401
5798
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
4402
|
-
flagged_destructive, dry_run, created_at
|
|
5799
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
4403
5800
|
) VALUES (
|
|
4404
5801
|
@id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
|
|
4405
5802
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
4406
5803
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
4407
5804
|
@upstream_http_status,
|
|
4408
5805
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
4409
|
-
@flagged_destructive, @dry_run, @created_at
|
|
5806
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
4410
5807
|
)
|
|
4411
5808
|
`;
|
|
4412
5809
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -4416,7 +5813,10 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
4416
5813
|
"total_duration_ms",
|
|
4417
5814
|
"approval_wait_ms",
|
|
4418
5815
|
"proxy_compute_ms",
|
|
4419
|
-
"upstream_http_status"
|
|
5816
|
+
"upstream_http_status",
|
|
5817
|
+
"record_kind",
|
|
5818
|
+
"origin",
|
|
5819
|
+
"metadata"
|
|
4420
5820
|
];
|
|
4421
5821
|
function deserializeRow(row) {
|
|
4422
5822
|
return {
|
|
@@ -4443,6 +5843,9 @@ function deserializeRow(row) {
|
|
|
4443
5843
|
proxy_compute_ms: row.proxy_compute_ms,
|
|
4444
5844
|
flagged_destructive: row.flagged_destructive === 1,
|
|
4445
5845
|
dry_run: row.dry_run === 1,
|
|
5846
|
+
record_kind: row.record_kind,
|
|
5847
|
+
origin: row.origin,
|
|
5848
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
4446
5849
|
created_at: row.created_at
|
|
4447
5850
|
};
|
|
4448
5851
|
}
|
|
@@ -4464,6 +5867,22 @@ function buildWhereClause(filters) {
|
|
|
4464
5867
|
if (filters.blocked !== void 0) {
|
|
4465
5868
|
conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
|
|
4466
5869
|
}
|
|
5870
|
+
if (filters.record_kind !== void 0) {
|
|
5871
|
+
conditions.push("record_kind = ?");
|
|
5872
|
+
params.push(filters.record_kind);
|
|
5873
|
+
}
|
|
5874
|
+
if (filters.origin !== void 0) {
|
|
5875
|
+
conditions.push("origin LIKE ?");
|
|
5876
|
+
params.push(`%${filters.origin}%`);
|
|
5877
|
+
}
|
|
5878
|
+
if (filters.channel_id !== void 0) {
|
|
5879
|
+
conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
|
|
5880
|
+
params.push(`%${filters.channel_id}%`);
|
|
5881
|
+
}
|
|
5882
|
+
if (filters.sender_id !== void 0) {
|
|
5883
|
+
conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
|
|
5884
|
+
params.push(`%${filters.sender_id}%`);
|
|
5885
|
+
}
|
|
4467
5886
|
if (filters.session_id !== void 0) {
|
|
4468
5887
|
conditions.push("session_id = ?");
|
|
4469
5888
|
params.push(filters.session_id);
|
|
@@ -4564,7 +5983,7 @@ var AuditStore = class {
|
|
|
4564
5983
|
* @param id - Optional pre-generated ID (used by AuditWriter to share ID with SSE event bus).
|
|
4565
5984
|
*/
|
|
4566
5985
|
insert(record, createdAt, id) {
|
|
4567
|
-
const resolvedId = id ??
|
|
5986
|
+
const resolvedId = id ?? randomUUID3();
|
|
4568
5987
|
const now = createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4569
5988
|
this.insertStmt.run({
|
|
4570
5989
|
id: resolvedId,
|
|
@@ -4590,6 +6009,9 @@ var AuditStore = class {
|
|
|
4590
6009
|
proxy_compute_ms: record.proxy_compute_ms,
|
|
4591
6010
|
flagged_destructive: record.flagged_destructive ? 1 : 0,
|
|
4592
6011
|
dry_run: record.dry_run ? 1 : 0,
|
|
6012
|
+
record_kind: record.record_kind,
|
|
6013
|
+
origin: record.origin,
|
|
6014
|
+
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
4593
6015
|
created_at: now
|
|
4594
6016
|
});
|
|
4595
6017
|
return resolvedId;
|
|
@@ -4736,7 +6158,7 @@ var AuditStore = class {
|
|
|
4736
6158
|
};
|
|
4737
6159
|
|
|
4738
6160
|
// src/audit/writer.ts
|
|
4739
|
-
import { randomUUID as
|
|
6161
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
4740
6162
|
var AuditWriter = class {
|
|
4741
6163
|
store;
|
|
4742
6164
|
bufferSize;
|
|
@@ -4771,9 +6193,8 @@ var AuditWriter = class {
|
|
|
4771
6193
|
* is scheduled. This keeps request-path latency bounded even under bursty
|
|
4772
6194
|
* write load.
|
|
4773
6195
|
*/
|
|
4774
|
-
push(record) {
|
|
6196
|
+
push(record, id = randomUUID4()) {
|
|
4775
6197
|
if (this.closed) return;
|
|
4776
|
-
const id = randomUUID3();
|
|
4777
6198
|
this.buffer.push({ id, record });
|
|
4778
6199
|
this.onPush?.(record, id);
|
|
4779
6200
|
if (this.buffer.length >= this.bufferSize) {
|
|
@@ -4788,9 +6209,8 @@ var AuditWriter = class {
|
|
|
4788
6209
|
* A fatal-process crash still invokes the crash-drain hook, which calls
|
|
4789
6210
|
* `flush()` synchronously before exit.
|
|
4790
6211
|
*/
|
|
4791
|
-
pushImmediate(record) {
|
|
6212
|
+
pushImmediate(record, id = randomUUID4()) {
|
|
4792
6213
|
if (this.closed) return;
|
|
4793
|
-
const id = randomUUID3();
|
|
4794
6214
|
this.buffer.push({ id, record });
|
|
4795
6215
|
this.onPush?.(record, id);
|
|
4796
6216
|
this.scheduleFlushSoon();
|
|
@@ -4846,7 +6266,7 @@ var AuditWriter = class {
|
|
|
4846
6266
|
};
|
|
4847
6267
|
|
|
4848
6268
|
// src/approval/queue.ts
|
|
4849
|
-
import { randomUUID as
|
|
6269
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
4850
6270
|
var ApprovalQueue = class {
|
|
4851
6271
|
tickets = /* @__PURE__ */ new Map();
|
|
4852
6272
|
now;
|
|
@@ -4876,7 +6296,7 @@ var ApprovalQueue = class {
|
|
|
4876
6296
|
if (this.closed) throw new Error("ApprovalQueue is closed");
|
|
4877
6297
|
const now = this.now();
|
|
4878
6298
|
const ticket = {
|
|
4879
|
-
id:
|
|
6299
|
+
id: randomUUID5(),
|
|
4880
6300
|
tool_name: params.tool_name,
|
|
4881
6301
|
tool_input: params.tool_input,
|
|
4882
6302
|
matched_rule: params.matched_rule,
|
|
@@ -4955,6 +6375,7 @@ var ApprovalQueue = class {
|
|
|
4955
6375
|
};
|
|
4956
6376
|
|
|
4957
6377
|
// src/approval/router.ts
|
|
6378
|
+
var NATIVE_CHANNEL_PREFIX = "native:";
|
|
4958
6379
|
var ApprovalRouter = class {
|
|
4959
6380
|
defaultTimeoutMs;
|
|
4960
6381
|
defaultOnTimeout;
|
|
@@ -5067,6 +6488,59 @@ var ApprovalRouter = class {
|
|
|
5067
6488
|
});
|
|
5068
6489
|
return outcome;
|
|
5069
6490
|
}
|
|
6491
|
+
/**
|
|
6492
|
+
* Create a native (adapter-owned) approval ticket without holding a Promise.
|
|
6493
|
+
*
|
|
6494
|
+
* Used by the sideband governance path (issue #12, D10): when `/evaluate`
|
|
6495
|
+
* yields `require_approval`, the adapter runs the approval in its own UI
|
|
6496
|
+
* (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
|
|
6497
|
+
* timeout/escalation timers, or notify a channel — doing so would
|
|
6498
|
+
* double-notify. We still create the queue ticket and fire `onSubmit` so the
|
|
6499
|
+
* dashboard's `approval_requested` SSE event flows and the ticket is visible.
|
|
6500
|
+
*
|
|
6501
|
+
* The ticket's `channel_name` is `native:<origin>`, which marks it as
|
|
6502
|
+
* adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
|
|
6503
|
+
* be resolved through the adapter), and {@link resolveNativeTicket} is the
|
|
6504
|
+
* resolution path.
|
|
6505
|
+
*/
|
|
6506
|
+
createNativeTicket(params) {
|
|
6507
|
+
const rule = params.matched_rule;
|
|
6508
|
+
const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
|
|
6509
|
+
const ticket = this.queue.add({
|
|
6510
|
+
tool_name: params.tool_name,
|
|
6511
|
+
tool_input: params.tool_input,
|
|
6512
|
+
matched_rule: rule?.name ?? null,
|
|
6513
|
+
rule_index: rule?.index ?? null,
|
|
6514
|
+
channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
|
|
6515
|
+
session_id: params.session_id,
|
|
6516
|
+
timeout_ms: timeoutMs
|
|
6517
|
+
});
|
|
6518
|
+
this.onSubmit?.(ticket);
|
|
6519
|
+
return ticket;
|
|
6520
|
+
}
|
|
6521
|
+
/**
|
|
6522
|
+
* Resolve a native ticket created by {@link createNativeTicket}.
|
|
6523
|
+
*
|
|
6524
|
+
* Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
|
|
6525
|
+
* SSE), with no held Promise to settle. Refuses tickets that have a pending
|
|
6526
|
+
* router Promise (those are MCP-path tickets; resolving them here would leave
|
|
6527
|
+
* the held request hanging) and tickets that are not `native:`-prefixed.
|
|
6528
|
+
*
|
|
6529
|
+
* @returns `true` if resolved, `false` if not found, already resolved, not a
|
|
6530
|
+
* native ticket, or router-managed.
|
|
6531
|
+
*/
|
|
6532
|
+
resolveNativeTicket(ticketId, status, resolvedBy, options) {
|
|
6533
|
+
if (this.pending.has(ticketId)) return false;
|
|
6534
|
+
const ticket = this.queue.get(ticketId);
|
|
6535
|
+
if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
|
|
6536
|
+
const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
|
|
6537
|
+
denial_reason: options?.denial_reason
|
|
6538
|
+
});
|
|
6539
|
+
if (!resolved) return false;
|
|
6540
|
+
const updated = this.queue.get(ticketId);
|
|
6541
|
+
if (updated) this.onResolve?.(updated);
|
|
6542
|
+
return true;
|
|
6543
|
+
}
|
|
5070
6544
|
/**
|
|
5071
6545
|
* Approve a pending ticket. Resolves the held Promise so the governed
|
|
5072
6546
|
* forwarder can forward the request upstream.
|
|
@@ -5111,6 +6585,10 @@ var ApprovalRouter = class {
|
|
|
5111
6585
|
ticketId
|
|
5112
6586
|
});
|
|
5113
6587
|
}
|
|
6588
|
+
/** Look up a ticket by id (delegates to the queue). */
|
|
6589
|
+
getTicket(ticketId) {
|
|
6590
|
+
return this.queue.get(ticketId);
|
|
6591
|
+
}
|
|
5114
6592
|
/** Clean up all pending timers and resolve all pending promises. */
|
|
5115
6593
|
close() {
|
|
5116
6594
|
this.closed = true;
|
|
@@ -5373,8 +6851,8 @@ function createChannels(channels) {
|
|
|
5373
6851
|
|
|
5374
6852
|
// src/approval/slack-actions.ts
|
|
5375
6853
|
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
5376
|
-
import { Hono as
|
|
5377
|
-
import { z as
|
|
6854
|
+
import { Hono as Hono6 } from "hono";
|
|
6855
|
+
import { z as z6 } from "zod";
|
|
5378
6856
|
var MAX_TIMESTAMP_AGE_S = 300;
|
|
5379
6857
|
var REJECTION_LOG_WINDOW_MS = 6e4;
|
|
5380
6858
|
var REJECTION_LOG_SAMPLE_EVERY = 25;
|
|
@@ -5440,12 +6918,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
|
|
|
5440
6918
|
}
|
|
5441
6919
|
return false;
|
|
5442
6920
|
}
|
|
5443
|
-
var slackActionPayloadSchema =
|
|
5444
|
-
type:
|
|
5445
|
-
user:
|
|
5446
|
-
actions:
|
|
5447
|
-
channel:
|
|
5448
|
-
message:
|
|
6921
|
+
var slackActionPayloadSchema = z6.object({
|
|
6922
|
+
type: z6.string(),
|
|
6923
|
+
user: z6.object({ id: z6.string(), username: z6.string() }),
|
|
6924
|
+
actions: z6.array(z6.object({ action_id: z6.string() })),
|
|
6925
|
+
channel: z6.object({ id: z6.string() }),
|
|
6926
|
+
message: z6.object({ ts: z6.string() })
|
|
5449
6927
|
});
|
|
5450
6928
|
function parseActionPayload(rawBody) {
|
|
5451
6929
|
try {
|
|
@@ -5475,7 +6953,7 @@ Ticket \`${ticketId}\``
|
|
|
5475
6953
|
}
|
|
5476
6954
|
function createSlackActionApp(options) {
|
|
5477
6955
|
const { router, channels } = options;
|
|
5478
|
-
const app = new
|
|
6956
|
+
const app = new Hono6();
|
|
5479
6957
|
const rejectionLogBuckets = /* @__PURE__ */ new Map();
|
|
5480
6958
|
const rejectUnauthorized = (c, reason, context) => {
|
|
5481
6959
|
logRejectedSlackCallback(rejectionLogBuckets, {
|
|
@@ -5574,18 +7052,18 @@ function createSlackActionApp(options) {
|
|
|
5574
7052
|
}
|
|
5575
7053
|
|
|
5576
7054
|
// src/approval/api.ts
|
|
5577
|
-
import { Hono as
|
|
5578
|
-
import { z as
|
|
5579
|
-
var approveBody =
|
|
5580
|
-
approved_by:
|
|
7055
|
+
import { Hono as Hono7 } from "hono";
|
|
7056
|
+
import { z as z7 } from "zod";
|
|
7057
|
+
var approveBody = z7.object({
|
|
7058
|
+
approved_by: z7.string().min(1)
|
|
5581
7059
|
});
|
|
5582
|
-
var denyBody =
|
|
5583
|
-
denied_by:
|
|
5584
|
-
reason:
|
|
7060
|
+
var denyBody = z7.object({
|
|
7061
|
+
denied_by: z7.string().min(1),
|
|
7062
|
+
reason: z7.string().optional()
|
|
5585
7063
|
});
|
|
5586
|
-
var breakGlassBody =
|
|
5587
|
-
approved_by:
|
|
5588
|
-
reason:
|
|
7064
|
+
var breakGlassBody = z7.object({
|
|
7065
|
+
approved_by: z7.string().min(1),
|
|
7066
|
+
reason: z7.string().min(1)
|
|
5589
7067
|
});
|
|
5590
7068
|
var APPROVAL_STATUSES = [
|
|
5591
7069
|
"pending",
|
|
@@ -5594,25 +7072,26 @@ var APPROVAL_STATUSES = [
|
|
|
5594
7072
|
"timeout",
|
|
5595
7073
|
"break_glass",
|
|
5596
7074
|
"client_disconnected",
|
|
5597
|
-
"shutdown_cancelled"
|
|
7075
|
+
"shutdown_cancelled",
|
|
7076
|
+
"cancelled"
|
|
5598
7077
|
];
|
|
5599
7078
|
var approvalStatusSet = new Set(APPROVAL_STATUSES);
|
|
5600
|
-
var listApprovalsQuery =
|
|
5601
|
-
status:
|
|
7079
|
+
var listApprovalsQuery = z7.object({
|
|
7080
|
+
status: z7.preprocess(
|
|
5602
7081
|
(value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
|
|
5603
|
-
|
|
7082
|
+
z7.enum(APPROVAL_STATUSES).optional()
|
|
5604
7083
|
),
|
|
5605
|
-
limit:
|
|
7084
|
+
limit: z7.preprocess(
|
|
5606
7085
|
(value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
|
|
5607
|
-
|
|
7086
|
+
z7.number().int()
|
|
5608
7087
|
),
|
|
5609
|
-
offset:
|
|
7088
|
+
offset: z7.preprocess(
|
|
5610
7089
|
(value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
5611
|
-
|
|
7090
|
+
z7.number().int()
|
|
5612
7091
|
)
|
|
5613
7092
|
});
|
|
5614
7093
|
function createApprovalApp(router, queue, options) {
|
|
5615
|
-
const app = new
|
|
7094
|
+
const app = new Hono7();
|
|
5616
7095
|
const apiSecret = options?.apiSecret;
|
|
5617
7096
|
if (apiSecret) {
|
|
5618
7097
|
app.use("*", async (c, next) => {
|
|
@@ -5658,6 +7137,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5658
7137
|
if (!ticket) {
|
|
5659
7138
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5660
7139
|
}
|
|
7140
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7141
|
+
return c.json(
|
|
7142
|
+
{
|
|
7143
|
+
error: "native_ticket",
|
|
7144
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7145
|
+
},
|
|
7146
|
+
409
|
|
7147
|
+
);
|
|
7148
|
+
}
|
|
5661
7149
|
if (ticket.status !== "pending") {
|
|
5662
7150
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5663
7151
|
}
|
|
@@ -5683,6 +7171,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5683
7171
|
if (!ticket) {
|
|
5684
7172
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5685
7173
|
}
|
|
7174
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7175
|
+
return c.json(
|
|
7176
|
+
{
|
|
7177
|
+
error: "native_ticket",
|
|
7178
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7179
|
+
},
|
|
7180
|
+
409
|
|
7181
|
+
);
|
|
7182
|
+
}
|
|
5686
7183
|
if (ticket.status !== "pending") {
|
|
5687
7184
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5688
7185
|
}
|
|
@@ -5708,6 +7205,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5708
7205
|
if (!ticket) {
|
|
5709
7206
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5710
7207
|
}
|
|
7208
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7209
|
+
return c.json(
|
|
7210
|
+
{
|
|
7211
|
+
error: "native_ticket",
|
|
7212
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7213
|
+
},
|
|
7214
|
+
409
|
|
7215
|
+
);
|
|
7216
|
+
}
|
|
5711
7217
|
if (ticket.status !== "pending") {
|
|
5712
7218
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5713
7219
|
}
|
|
@@ -5723,9 +7229,9 @@ function createApprovalApp(router, queue, options) {
|
|
|
5723
7229
|
// src/dashboard/api.ts
|
|
5724
7230
|
import { readFileSync } from "fs";
|
|
5725
7231
|
import { join } from "path";
|
|
5726
|
-
import { randomUUID as
|
|
5727
|
-
import { Hono as
|
|
5728
|
-
import { z as
|
|
7232
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
7233
|
+
import { Hono as Hono8 } from "hono";
|
|
7234
|
+
import { z as z8 } from "zod";
|
|
5729
7235
|
import { cors } from "hono/cors";
|
|
5730
7236
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5731
7237
|
import { streamSSE } from "hono/streaming";
|
|
@@ -5786,7 +7292,7 @@ function recordsToCsv(records) {
|
|
|
5786
7292
|
}
|
|
5787
7293
|
|
|
5788
7294
|
// src/dashboard/session.ts
|
|
5789
|
-
import { createHash as
|
|
7295
|
+
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
5790
7296
|
var DashboardSessionStore = class {
|
|
5791
7297
|
secret;
|
|
5792
7298
|
ttlMs;
|
|
@@ -5867,8 +7373,8 @@ var DashboardSessionStore = class {
|
|
|
5867
7373
|
const id = token.slice(0, dot);
|
|
5868
7374
|
const signature = token.slice(dot + 1);
|
|
5869
7375
|
const expected = this.sign(id);
|
|
5870
|
-
const actualDigest =
|
|
5871
|
-
const expectedDigest =
|
|
7376
|
+
const actualDigest = createHash3("sha256").update(signature).digest();
|
|
7377
|
+
const expectedDigest = createHash3("sha256").update(expected).digest();
|
|
5872
7378
|
if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
|
|
5873
7379
|
return id;
|
|
5874
7380
|
}
|
|
@@ -5878,29 +7384,29 @@ var DashboardSessionStore = class {
|
|
|
5878
7384
|
};
|
|
5879
7385
|
|
|
5880
7386
|
// src/dashboard/api.ts
|
|
5881
|
-
var optionalQueryString =
|
|
7387
|
+
var optionalQueryString = z8.preprocess(
|
|
5882
7388
|
(value) => typeof value === "string" && value.length > 0 ? value : void 0,
|
|
5883
|
-
|
|
7389
|
+
z8.string().optional()
|
|
5884
7390
|
);
|
|
5885
|
-
var optionalQueryInt =
|
|
7391
|
+
var optionalQueryInt = z8.preprocess((value) => {
|
|
5886
7392
|
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
5887
7393
|
const parsed = Number.parseInt(value, 10);
|
|
5888
7394
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
5889
|
-
},
|
|
5890
|
-
var queryBoolean =
|
|
7395
|
+
}, z8.number().int().optional());
|
|
7396
|
+
var queryBoolean = z8.preprocess(
|
|
5891
7397
|
(value) => value === "true" ? true : value === "false" ? false : void 0,
|
|
5892
|
-
|
|
7398
|
+
z8.boolean().optional()
|
|
5893
7399
|
);
|
|
5894
|
-
var clampedQueryInt = (fallback, min, max) =>
|
|
7400
|
+
var clampedQueryInt = (fallback, min, max) => z8.preprocess(
|
|
5895
7401
|
(value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
|
|
5896
|
-
|
|
7402
|
+
z8.number().int()
|
|
5897
7403
|
);
|
|
5898
|
-
var feedQuerySchema =
|
|
7404
|
+
var feedQuerySchema = z8.object({
|
|
5899
7405
|
limit: clampedQueryInt(50, 1, 200),
|
|
5900
7406
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
5901
7407
|
});
|
|
5902
|
-
var auditExportQuerySchema =
|
|
5903
|
-
format:
|
|
7408
|
+
var auditExportQuerySchema = z8.object({
|
|
7409
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
5904
7410
|
limit: clampedQueryInt(1e4, 1, 1e4),
|
|
5905
7411
|
tool: optionalQueryString,
|
|
5906
7412
|
decision: optionalQueryString,
|
|
@@ -5912,9 +7418,13 @@ var auditExportQuerySchema = z7.object({
|
|
|
5912
7418
|
from: optionalQueryString,
|
|
5913
7419
|
to: optionalQueryString,
|
|
5914
7420
|
upstream_status_min: optionalQueryInt,
|
|
5915
|
-
upstream_status_max: optionalQueryInt
|
|
7421
|
+
upstream_status_max: optionalQueryInt,
|
|
7422
|
+
origin: optionalQueryString,
|
|
7423
|
+
record_kind: optionalQueryString,
|
|
7424
|
+
channel_id: optionalQueryString,
|
|
7425
|
+
sender_id: optionalQueryString
|
|
5916
7426
|
});
|
|
5917
|
-
var auditQuerySchema =
|
|
7427
|
+
var auditQuerySchema = z8.object({
|
|
5918
7428
|
limit: clampedQueryInt(50, 1, 1e3),
|
|
5919
7429
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
|
|
5920
7430
|
tool: optionalQueryString,
|
|
@@ -5928,14 +7438,18 @@ var auditQuerySchema = z7.object({
|
|
|
5928
7438
|
destructive: queryBoolean,
|
|
5929
7439
|
dry_run: queryBoolean,
|
|
5930
7440
|
upstream_status_min: optionalQueryInt,
|
|
5931
|
-
upstream_status_max: optionalQueryInt
|
|
7441
|
+
upstream_status_max: optionalQueryInt,
|
|
7442
|
+
origin: optionalQueryString,
|
|
7443
|
+
record_kind: optionalQueryString,
|
|
7444
|
+
channel_id: optionalQueryString,
|
|
7445
|
+
sender_id: optionalQueryString
|
|
5932
7446
|
});
|
|
5933
|
-
var analyticsQuerySchema =
|
|
7447
|
+
var analyticsQuerySchema = z8.object({
|
|
5934
7448
|
from: optionalQueryString,
|
|
5935
7449
|
to: optionalQueryString
|
|
5936
7450
|
});
|
|
5937
|
-
var authSessionBodySchema =
|
|
5938
|
-
secret:
|
|
7451
|
+
var authSessionBodySchema = z8.object({
|
|
7452
|
+
secret: z8.string()
|
|
5939
7453
|
});
|
|
5940
7454
|
var SESSION_COOKIE = "helio_session";
|
|
5941
7455
|
var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
@@ -5995,7 +7509,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
5995
7509
|
} = deps;
|
|
5996
7510
|
const apiSecret = options?.apiSecret;
|
|
5997
7511
|
const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
|
|
5998
|
-
const app = new
|
|
7512
|
+
const app = new Hono8();
|
|
5999
7513
|
app.use(
|
|
6000
7514
|
"*",
|
|
6001
7515
|
cors({
|
|
@@ -6135,7 +7649,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6135
7649
|
from: query.from,
|
|
6136
7650
|
to: query.to,
|
|
6137
7651
|
upstream_status_min: query.upstream_status_min,
|
|
6138
|
-
upstream_status_max: query.upstream_status_max
|
|
7652
|
+
upstream_status_max: query.upstream_status_max,
|
|
7653
|
+
origin: query.origin,
|
|
7654
|
+
record_kind: query.record_kind,
|
|
7655
|
+
channel_id: query.channel_id,
|
|
7656
|
+
sender_id: query.sender_id
|
|
6139
7657
|
};
|
|
6140
7658
|
const result = auditStore.list(filters, { limit, order: "asc" });
|
|
6141
7659
|
if (format === "csv") {
|
|
@@ -6177,7 +7695,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6177
7695
|
flagged_destructive: query.destructive,
|
|
6178
7696
|
dry_run: query.dry_run,
|
|
6179
7697
|
upstream_status_min: query.upstream_status_min,
|
|
6180
|
-
upstream_status_max: query.upstream_status_max
|
|
7698
|
+
upstream_status_max: query.upstream_status_max,
|
|
7699
|
+
origin: query.origin,
|
|
7700
|
+
record_kind: query.record_kind,
|
|
7701
|
+
channel_id: query.channel_id,
|
|
7702
|
+
sender_id: query.sender_id
|
|
6181
7703
|
};
|
|
6182
7704
|
const result = auditStore.list(filters, { limit, offset, order: "desc" });
|
|
6183
7705
|
return c.json({
|
|
@@ -6238,7 +7760,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6238
7760
|
app.get("/api/events", (c) => {
|
|
6239
7761
|
return streamSSE(c, async (stream) => {
|
|
6240
7762
|
if (closed) return;
|
|
6241
|
-
const connId =
|
|
7763
|
+
const connId = randomUUID6();
|
|
6242
7764
|
let streamClosed = false;
|
|
6243
7765
|
let stopHeartbeat = () => {
|
|
6244
7766
|
};
|
|
@@ -6267,7 +7789,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6267
7789
|
void stream.writeSSE({
|
|
6268
7790
|
event: eventType,
|
|
6269
7791
|
data: JSON.stringify(data),
|
|
6270
|
-
id:
|
|
7792
|
+
id: randomUUID6()
|
|
6271
7793
|
}).then(() => {
|
|
6272
7794
|
const conn = activeConnections.get(connId);
|
|
6273
7795
|
if (conn) conn.lastWrite = Date.now();
|
|
@@ -6368,6 +7890,8 @@ export {
|
|
|
6368
7890
|
ConfigError,
|
|
6369
7891
|
DashboardEventBus,
|
|
6370
7892
|
EvidenceStore,
|
|
7893
|
+
GovernanceConfigError,
|
|
7894
|
+
GovernanceService,
|
|
6371
7895
|
GovernedForwarder,
|
|
6372
7896
|
PolicyParseError,
|
|
6373
7897
|
QueueChannel,
|