@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/cli.js
CHANGED
|
@@ -115,11 +115,23 @@ var annotationsMatchSchema = z.object({
|
|
|
115
115
|
idempotentHint: z.boolean().optional(),
|
|
116
116
|
openWorldHint: z.boolean().optional()
|
|
117
117
|
}).strict();
|
|
118
|
+
var metadataConditionSchema = z.union([
|
|
119
|
+
z.string(),
|
|
120
|
+
z.object({
|
|
121
|
+
eq: z.string().optional(),
|
|
122
|
+
neq: z.string().optional(),
|
|
123
|
+
contains: z.string().optional(),
|
|
124
|
+
regex: z.string().optional()
|
|
125
|
+
}).strict().refine((obj) => Object.keys(obj).length > 0, {
|
|
126
|
+
message: "At least one metadata condition operator is required"
|
|
127
|
+
})
|
|
128
|
+
]);
|
|
118
129
|
var matchSchema = z.object({
|
|
119
130
|
tool: z.string().optional(),
|
|
120
131
|
annotations: annotationsMatchSchema.optional(),
|
|
121
132
|
input: z.record(z.string(), inputConditionSchema).optional(),
|
|
122
|
-
environment: z.string().optional()
|
|
133
|
+
environment: z.string().optional(),
|
|
134
|
+
metadata: z.record(z.string(), metadataConditionSchema).optional()
|
|
123
135
|
}).strict();
|
|
124
136
|
var policyActionSchema = z.enum([
|
|
125
137
|
"allow",
|
|
@@ -143,12 +155,12 @@ var spendLimitSchema = z.object({
|
|
|
143
155
|
limit: z.number(),
|
|
144
156
|
currency: z.string(),
|
|
145
157
|
window: durationSchema,
|
|
146
|
-
key: z.enum(["tool", "agent", "session"]).optional()
|
|
158
|
+
key: z.enum(["tool", "agent", "session", "sender_id"]).optional()
|
|
147
159
|
}).strict();
|
|
148
160
|
var limitsSchema = z.object({
|
|
149
161
|
max_calls: z.number().int().positive().optional(),
|
|
150
162
|
window: durationSchema.optional(),
|
|
151
|
-
key: z.enum(["tool", "agent", "session"]).optional(),
|
|
163
|
+
key: z.enum(["tool", "agent", "session", "sender_id"]).optional(),
|
|
152
164
|
max_spend: spendLimitSchema.optional()
|
|
153
165
|
}).strict();
|
|
154
166
|
var feedbackSchema = z.object({
|
|
@@ -166,11 +178,30 @@ var policyRuleSchema = z.object({
|
|
|
166
178
|
limits: limitsSchema.optional(),
|
|
167
179
|
feedback: feedbackSchema.optional()
|
|
168
180
|
}).strict();
|
|
181
|
+
var installMatchSchema = z.object({
|
|
182
|
+
name: z.string().optional(),
|
|
183
|
+
// glob, picomatch (same engine as match.tool)
|
|
184
|
+
source: z.string().optional(),
|
|
185
|
+
// exact ecosystem match (npm | pip | …)
|
|
186
|
+
metadata: z.record(z.string(), metadataConditionSchema).optional()
|
|
187
|
+
}).strict();
|
|
188
|
+
var installRuleSchema = z.object({
|
|
189
|
+
name: z.string().optional(),
|
|
190
|
+
match: installMatchSchema,
|
|
191
|
+
action: z.enum(["deny_install", "allow"]),
|
|
192
|
+
feedback: feedbackSchema.optional()
|
|
193
|
+
}).strict();
|
|
194
|
+
var installSchema = z.object({
|
|
195
|
+
default: z.enum(["allow", "deny"]).default("allow"),
|
|
196
|
+
rules: z.array(installRuleSchema).default([])
|
|
197
|
+
}).strict();
|
|
169
198
|
var policiesSchema = z.object({
|
|
170
199
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
171
200
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
172
201
|
dry_run: z.boolean().default(false),
|
|
173
202
|
rules: z.array(policyRuleSchema).default([]),
|
|
203
|
+
/** Install-time policy (issue #13 — deny_install). Optional; absent ⇒ observational. */
|
|
204
|
+
install: installSchema.optional(),
|
|
174
205
|
/**
|
|
175
206
|
* How to treat calls to a tool whose definition (annotations, schemas,
|
|
176
207
|
* description) has drifted from the baseline Helio captured on first
|
|
@@ -231,7 +262,14 @@ var auditSchema = z.object({
|
|
|
231
262
|
var sdkSchema = z.object({
|
|
232
263
|
enabled: z.boolean().default(false),
|
|
233
264
|
port: z.number().int().min(1).max(65535).default(3200),
|
|
234
|
-
host: z.string().default("127.0.0.1")
|
|
265
|
+
host: z.string().default("127.0.0.1"),
|
|
266
|
+
/**
|
|
267
|
+
* How long a sideband `/evaluate` decision waits for its `/audit` before the
|
|
268
|
+
* proxy finalizes it as `evaluation_expired` (issue #12, D4). Bounds the
|
|
269
|
+
* pending-evaluation registry; an adapter crash cannot silently drop a
|
|
270
|
+
* decided-allowed call from the trail.
|
|
271
|
+
*/
|
|
272
|
+
evaluation_ttl: durationSchema.default("10m")
|
|
235
273
|
});
|
|
236
274
|
var helioConfigBaseSchema = z.object({
|
|
237
275
|
version: z.literal("1"),
|
|
@@ -294,6 +332,22 @@ var helioConfigSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
|
|
|
294
332
|
message: `Rule sets match.environment="${rule.match.environment}" but top-level \`environment\` is not configured. Set top-level environment to enable env-scoped rules.`
|
|
295
333
|
});
|
|
296
334
|
}
|
|
335
|
+
if (!cfg.sdk.enabled) {
|
|
336
|
+
if (rule.limits?.key === "sender_id") {
|
|
337
|
+
ctx.addIssue({
|
|
338
|
+
code: "custom",
|
|
339
|
+
path: ["policies", "rules", ruleIndex, "limits", "key"],
|
|
340
|
+
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.'
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (rule.limits?.max_spend?.key === "sender_id") {
|
|
344
|
+
ctx.addIssue({
|
|
345
|
+
code: "custom",
|
|
346
|
+
path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
|
|
347
|
+
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.'
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
297
351
|
if (rule.action === "rate_limit") {
|
|
298
352
|
if (rule.limits?.max_calls === void 0) {
|
|
299
353
|
ctx.addIssue({
|
|
@@ -465,6 +519,7 @@ var PolicyParseError = class extends Error {
|
|
|
465
519
|
|
|
466
520
|
// src/policy/parser.ts
|
|
467
521
|
var INPUT_OPERATORS = ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "regex"];
|
|
522
|
+
var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
|
|
468
523
|
function compilePolicies(config) {
|
|
469
524
|
const warnings = [];
|
|
470
525
|
const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
|
|
@@ -473,10 +528,36 @@ function compilePolicies(config) {
|
|
|
473
528
|
flagDestructive: config.flag_destructive,
|
|
474
529
|
...config.dry_run && { dryRun: true },
|
|
475
530
|
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
476
|
-
rules
|
|
531
|
+
rules,
|
|
532
|
+
...config.install && { install: compileInstallPolicy(config.install) }
|
|
477
533
|
};
|
|
478
534
|
return { policy, warnings };
|
|
479
535
|
}
|
|
536
|
+
function compileInstallPolicy(install) {
|
|
537
|
+
return {
|
|
538
|
+
defaultAction: install.default,
|
|
539
|
+
rules: install.rules.map((rule, index) => {
|
|
540
|
+
const name = rule.match.name !== void 0 ? compileToolMatcher(rule.match.name, index, rule.name) : void 0;
|
|
541
|
+
const metadata = rule.match.metadata !== void 0 ? flattenMetadataConditions(rule.match.metadata, index, rule.name) : void 0;
|
|
542
|
+
return {
|
|
543
|
+
index,
|
|
544
|
+
...rule.name !== void 0 && { name: rule.name },
|
|
545
|
+
match: {
|
|
546
|
+
...name !== void 0 && { name },
|
|
547
|
+
...rule.match.source !== void 0 && { source: rule.match.source },
|
|
548
|
+
...metadata !== void 0 && { metadata }
|
|
549
|
+
},
|
|
550
|
+
action: rule.action,
|
|
551
|
+
...rule.feedback !== void 0 && {
|
|
552
|
+
feedback: {
|
|
553
|
+
message: rule.feedback.message,
|
|
554
|
+
...rule.feedback.suggestion !== void 0 && { suggestion: rule.feedback.suggestion }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
})
|
|
559
|
+
};
|
|
560
|
+
}
|
|
480
561
|
function compileRule(rule, index, warnings) {
|
|
481
562
|
const match = compileMatch(rule.match, index, rule.name);
|
|
482
563
|
const approval = compileApproval(rule.approval);
|
|
@@ -509,7 +590,10 @@ function compileMatch(match, ruleIndex, ruleName) {
|
|
|
509
590
|
...match.input !== void 0 && {
|
|
510
591
|
input: flattenInputConditions(match.input, ruleIndex, ruleName)
|
|
511
592
|
},
|
|
512
|
-
...match.environment !== void 0 && { environment: match.environment }
|
|
593
|
+
...match.environment !== void 0 && { environment: match.environment },
|
|
594
|
+
...match.metadata !== void 0 && {
|
|
595
|
+
metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
|
|
596
|
+
}
|
|
513
597
|
};
|
|
514
598
|
}
|
|
515
599
|
function compileToolMatcher(pattern, ruleIndex, ruleName) {
|
|
@@ -561,6 +645,43 @@ function flattenInputConditions(input, ruleIndex, ruleName) {
|
|
|
561
645
|
}
|
|
562
646
|
return conditions;
|
|
563
647
|
}
|
|
648
|
+
function flattenMetadataConditions(metadata, ruleIndex, ruleName) {
|
|
649
|
+
const conditions = [];
|
|
650
|
+
for (const [key, raw] of Object.entries(metadata)) {
|
|
651
|
+
if (typeof raw === "string") {
|
|
652
|
+
conditions.push({ key, operator: "eq", value: raw });
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
for (const op of METADATA_OPERATORS) {
|
|
656
|
+
const value = raw[op];
|
|
657
|
+
if (value === void 0) continue;
|
|
658
|
+
if (op === "regex") {
|
|
659
|
+
if (!safeRegex(value)) {
|
|
660
|
+
throw new PolicyParseError(
|
|
661
|
+
`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.`,
|
|
662
|
+
ruleIndex,
|
|
663
|
+
ruleName
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
let compiledRegex;
|
|
667
|
+
try {
|
|
668
|
+
compiledRegex = new RegExp(value);
|
|
669
|
+
} catch (err) {
|
|
670
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
671
|
+
throw new PolicyParseError(
|
|
672
|
+
`invalid regex "${value}" for metadata key "${key}": ${msg}`,
|
|
673
|
+
ruleIndex,
|
|
674
|
+
ruleName
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
conditions.push({ key, operator: op, value, regex: compiledRegex });
|
|
678
|
+
} else {
|
|
679
|
+
conditions.push({ key, operator: op, value });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return conditions;
|
|
684
|
+
}
|
|
564
685
|
function compileApproval(approval) {
|
|
565
686
|
if (!approval) return void 0;
|
|
566
687
|
return {
|
|
@@ -2291,12 +2412,31 @@ function matchEnvironment(required, ctx) {
|
|
|
2291
2412
|
if (ctx.environment === void 0) return false;
|
|
2292
2413
|
return ctx.environment === required;
|
|
2293
2414
|
}
|
|
2415
|
+
function matchMetadata(conditions, ctx) {
|
|
2416
|
+
if (conditions.length === 0) return true;
|
|
2417
|
+
if (ctx.metadata === void 0) return false;
|
|
2418
|
+
for (const condition of conditions) {
|
|
2419
|
+
const value = ctx.metadata[condition.key];
|
|
2420
|
+
const matched = evaluateCondition(
|
|
2421
|
+
{
|
|
2422
|
+
path: condition.key,
|
|
2423
|
+
operator: condition.operator,
|
|
2424
|
+
value: condition.value,
|
|
2425
|
+
regex: condition.regex
|
|
2426
|
+
},
|
|
2427
|
+
value
|
|
2428
|
+
);
|
|
2429
|
+
if (!matched) return false;
|
|
2430
|
+
}
|
|
2431
|
+
return true;
|
|
2432
|
+
}
|
|
2294
2433
|
function matchRule(rule, ctx) {
|
|
2295
2434
|
const { match } = rule;
|
|
2296
2435
|
if (match.tool !== void 0 && !matchTool(match.tool, ctx)) return false;
|
|
2297
2436
|
if (match.annotations !== void 0 && !matchAnnotations(match.annotations, ctx)) return false;
|
|
2298
2437
|
if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
|
|
2299
2438
|
if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
|
|
2439
|
+
if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
|
|
2300
2440
|
return true;
|
|
2301
2441
|
}
|
|
2302
2442
|
|
|
@@ -2319,6 +2459,197 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2319
2459
|
};
|
|
2320
2460
|
}
|
|
2321
2461
|
|
|
2462
|
+
// src/evidence/grounding.ts
|
|
2463
|
+
function checkEvidence(store, sessionId, requirements) {
|
|
2464
|
+
if (requirements.length === 0) {
|
|
2465
|
+
return { satisfied: true, missing: [], expired: [], found: [] };
|
|
2466
|
+
}
|
|
2467
|
+
const found = [];
|
|
2468
|
+
const missing = [];
|
|
2469
|
+
const expired = [];
|
|
2470
|
+
for (const key of requirements) {
|
|
2471
|
+
const valid = store.getEvidence(sessionId, key);
|
|
2472
|
+
if (valid) {
|
|
2473
|
+
found.push(key);
|
|
2474
|
+
} else if (store.hasSeenEvidence(sessionId, key)) {
|
|
2475
|
+
expired.push(key);
|
|
2476
|
+
} else {
|
|
2477
|
+
missing.push(key);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return {
|
|
2481
|
+
satisfied: missing.length === 0 && expired.length === 0,
|
|
2482
|
+
missing,
|
|
2483
|
+
expired,
|
|
2484
|
+
found
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
2488
|
+
if (requirements.length === 0) {
|
|
2489
|
+
return { satisfied: true, missing: [] };
|
|
2490
|
+
}
|
|
2491
|
+
const requireSuccess = options.requireSuccess ?? true;
|
|
2492
|
+
const missing = [];
|
|
2493
|
+
for (const toolName of requirements) {
|
|
2494
|
+
const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
|
|
2495
|
+
if (!satisfied) {
|
|
2496
|
+
missing.push(toolName);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return {
|
|
2500
|
+
satisfied: missing.length === 0,
|
|
2501
|
+
missing
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/policy/decision-pipeline.ts
|
|
2506
|
+
function decide(input) {
|
|
2507
|
+
const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
|
|
2508
|
+
const annotations = input.baselineAnnotations;
|
|
2509
|
+
const driftEvent = input.driftEvent;
|
|
2510
|
+
const driftMode = policy.onToolDrift ?? "block";
|
|
2511
|
+
const metadata = buildMetadataView(input.metadata, input.agentId);
|
|
2512
|
+
let decision = evaluatePolicy(policy, {
|
|
2513
|
+
toolName,
|
|
2514
|
+
annotations,
|
|
2515
|
+
toolArguments,
|
|
2516
|
+
environment,
|
|
2517
|
+
metadata
|
|
2518
|
+
});
|
|
2519
|
+
if (driftEvent && driftMode === "log") {
|
|
2520
|
+
const currentDecision = evaluatePolicy(policy, {
|
|
2521
|
+
toolName,
|
|
2522
|
+
annotations: input.currentAnnotations,
|
|
2523
|
+
toolArguments,
|
|
2524
|
+
environment,
|
|
2525
|
+
metadata
|
|
2526
|
+
});
|
|
2527
|
+
decision = stricterDecision(decision, currentDecision);
|
|
2528
|
+
}
|
|
2529
|
+
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2530
|
+
const currentDestructive = driftEvent && driftMode === "log" ? input.currentAnnotations?.destructiveHint ?? true : false;
|
|
2531
|
+
const isDestructive = baselineDestructive || currentDestructive;
|
|
2532
|
+
let flaggedDestructive = false;
|
|
2533
|
+
if (isDestructive && !decision.matchedRule && policy.flagDestructive) {
|
|
2534
|
+
flaggedDestructive = true;
|
|
2535
|
+
if (policy.flagDestructive === "log") {
|
|
2536
|
+
console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
|
|
2537
|
+
} else {
|
|
2538
|
+
decision = {
|
|
2539
|
+
action: "require_approval",
|
|
2540
|
+
matchedRule: void 0,
|
|
2541
|
+
reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
let driftBlocked = false;
|
|
2546
|
+
if (driftEvent && driftMode !== "log") {
|
|
2547
|
+
driftBlocked = driftMode === "block";
|
|
2548
|
+
decision = {
|
|
2549
|
+
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2550
|
+
matchedRule: void 0,
|
|
2551
|
+
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
const originalAction = decision.action;
|
|
2555
|
+
let evidenceResult;
|
|
2556
|
+
let dependencyResult;
|
|
2557
|
+
let evidenceBlocked = false;
|
|
2558
|
+
let sessionBlocked = false;
|
|
2559
|
+
const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
|
|
2560
|
+
if (requiresGroundedSession && !sessionId) {
|
|
2561
|
+
sessionBlocked = true;
|
|
2562
|
+
evidenceBlocked = true;
|
|
2563
|
+
decision = {
|
|
2564
|
+
action: "deny",
|
|
2565
|
+
matchedRule: decision.matchedRule,
|
|
2566
|
+
reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
2570
|
+
const rule = decision.matchedRule;
|
|
2571
|
+
if (rule.evidence?.requires.length) {
|
|
2572
|
+
evidenceResult = checkEvidence(evidenceStore, sessionId, rule.evidence.requires);
|
|
2573
|
+
if (!evidenceResult.satisfied) {
|
|
2574
|
+
evidenceBlocked = true;
|
|
2575
|
+
const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
|
|
2576
|
+
decision = {
|
|
2577
|
+
action: "deny",
|
|
2578
|
+
matchedRule: rule,
|
|
2579
|
+
reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (!evidenceBlocked && rule.requires?.length) {
|
|
2584
|
+
dependencyResult = checkDependencies(evidenceStore, sessionId, rule.requires, {
|
|
2585
|
+
requireSuccess: rule.requiresSuccess ?? true
|
|
2586
|
+
});
|
|
2587
|
+
if (!dependencyResult.satisfied) {
|
|
2588
|
+
evidenceBlocked = true;
|
|
2589
|
+
decision = {
|
|
2590
|
+
action: "deny",
|
|
2591
|
+
matchedRule: rule,
|
|
2592
|
+
reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const isPerRuleDryRun = originalAction === "dry_run";
|
|
2598
|
+
const isGlobalDryRun = policy.dryRun === true;
|
|
2599
|
+
const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
|
|
2600
|
+
return {
|
|
2601
|
+
decision,
|
|
2602
|
+
originalAction,
|
|
2603
|
+
driftEvent,
|
|
2604
|
+
driftMode,
|
|
2605
|
+
driftBlocked,
|
|
2606
|
+
flaggedDestructive,
|
|
2607
|
+
evidenceResult,
|
|
2608
|
+
dependencyResult,
|
|
2609
|
+
evidenceBlocked,
|
|
2610
|
+
sessionBlocked,
|
|
2611
|
+
isDryRun
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
var ACTION_SEVERITY = {
|
|
2615
|
+
deny: 5,
|
|
2616
|
+
require_approval: 4,
|
|
2617
|
+
dry_run: 3,
|
|
2618
|
+
spend_limit: 2,
|
|
2619
|
+
rate_limit: 1,
|
|
2620
|
+
allow: 0
|
|
2621
|
+
};
|
|
2622
|
+
function stricterDecision(a, b) {
|
|
2623
|
+
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
2624
|
+
}
|
|
2625
|
+
function buildMetadataView(metadata, agentId) {
|
|
2626
|
+
if (agentId === void 0) return metadata;
|
|
2627
|
+
return { ...metadata ?? {}, agent_id: agentId };
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// src/util/canonical-json.ts
|
|
2631
|
+
function canonicalize(value) {
|
|
2632
|
+
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2633
|
+
return encoded ?? "";
|
|
2634
|
+
}
|
|
2635
|
+
function sortKeysDeep(value) {
|
|
2636
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2637
|
+
if (value !== null && typeof value === "object") {
|
|
2638
|
+
const source = value;
|
|
2639
|
+
const out = {};
|
|
2640
|
+
for (const key of Object.keys(source).sort()) {
|
|
2641
|
+
Object.defineProperty(out, key, {
|
|
2642
|
+
value: sortKeysDeep(source[key]),
|
|
2643
|
+
enumerable: true,
|
|
2644
|
+
writable: true,
|
|
2645
|
+
configurable: true
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
return out;
|
|
2649
|
+
}
|
|
2650
|
+
return value;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2322
2653
|
// src/policy/annotation-cache.ts
|
|
2323
2654
|
var ASPECT_FIELDS = [
|
|
2324
2655
|
"annotations",
|
|
@@ -2421,6 +2752,74 @@ var ToolAnnotationCache = class {
|
|
|
2421
2752
|
this.currentAnnotations = currentAnnotations;
|
|
2422
2753
|
return { updated: true, baselined, drifted, reverted };
|
|
2423
2754
|
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Incrementally merge a single tool definition into the cache (issue #12, D6).
|
|
2757
|
+
*
|
|
2758
|
+
* Unlike {@link update}, this touches only the named tool: it adds to (never
|
|
2759
|
+
* rebuilds) the `present` set and `currentAnnotations` map. The sideband
|
|
2760
|
+
* governance path feeds adapter-origin tools one definition at a time (each
|
|
2761
|
+
* `/evaluate` carries at most one), so routing them through the whole-list
|
|
2762
|
+
* `update()` would wipe every other tool's current-annotation snapshot on
|
|
2763
|
+
* each call and silently degrade the stricter-of-both log-mode drift
|
|
2764
|
+
* evaluation. The MCP whole-list path is unaffected — it keeps calling
|
|
2765
|
+
* `update()`. Each origin owns its own cache instance, so the accumulate
|
|
2766
|
+
* semantics here never mix with update()'s replace semantics.
|
|
2767
|
+
*
|
|
2768
|
+
* `toolDefinition` must already be in MCP shape (`inputSchema`/`outputSchema`
|
|
2769
|
+
* camelCase); the governance service maps the wire `tool` object before
|
|
2770
|
+
* calling. Returns the same result shape as `update()` (for one tool).
|
|
2771
|
+
*/
|
|
2772
|
+
updateSingle(toolDefinition) {
|
|
2773
|
+
if (typeof toolDefinition !== "object" || toolDefinition === null) {
|
|
2774
|
+
return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2775
|
+
}
|
|
2776
|
+
const t = toolDefinition;
|
|
2777
|
+
const name = t["name"];
|
|
2778
|
+
if (typeof name !== "string") {
|
|
2779
|
+
return { updated: false, baselined: [], drifted: [], reverted: [] };
|
|
2780
|
+
}
|
|
2781
|
+
const baselined = [];
|
|
2782
|
+
const drifted = [];
|
|
2783
|
+
const reverted = [];
|
|
2784
|
+
this.present.add(name);
|
|
2785
|
+
const annotations = extractAnnotations(t);
|
|
2786
|
+
this.currentAnnotations.set(name, annotations);
|
|
2787
|
+
const definitionKey = canonicalize(t);
|
|
2788
|
+
const baseline = this.baselines.get(name);
|
|
2789
|
+
if (!baseline) {
|
|
2790
|
+
this.baselines.set(name, { definition: t, definitionKey, annotations });
|
|
2791
|
+
baselined.push(name);
|
|
2792
|
+
if (this.driftedTools.has(name)) {
|
|
2793
|
+
this.driftedTools.delete(name);
|
|
2794
|
+
reverted.push(name);
|
|
2795
|
+
}
|
|
2796
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2797
|
+
}
|
|
2798
|
+
if (definitionKey === baseline.definitionKey) {
|
|
2799
|
+
if (this.driftedTools.has(name)) {
|
|
2800
|
+
this.driftedTools.delete(name);
|
|
2801
|
+
reverted.push(name);
|
|
2802
|
+
}
|
|
2803
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2804
|
+
}
|
|
2805
|
+
const changes = [];
|
|
2806
|
+
for (const field of ASPECT_FIELDS) {
|
|
2807
|
+
const baselineValue = baseline.definition[field];
|
|
2808
|
+
const currentValue = t[field];
|
|
2809
|
+
if (canonicalize(baselineValue) !== canonicalize(currentValue)) {
|
|
2810
|
+
changes.push({ aspect: field, baseline: baselineValue, current: currentValue });
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
if (changes.length === 0) {
|
|
2814
|
+
changes.push({ aspect: "other", baseline: baseline.definition, current: t });
|
|
2815
|
+
}
|
|
2816
|
+
const event = { toolName: name, changes };
|
|
2817
|
+
const existing = this.driftedTools.get(name);
|
|
2818
|
+
const isNewDrift = !existing || canonicalize(existing.changes) !== canonicalize(changes);
|
|
2819
|
+
this.driftedTools.set(name, event);
|
|
2820
|
+
if (isNewDrift) drifted.push(event);
|
|
2821
|
+
return { updated: true, baselined, drifted, reverted };
|
|
2822
|
+
}
|
|
2424
2823
|
/**
|
|
2425
2824
|
* Get the **baseline** annotations for a tool — the definition first seen,
|
|
2426
2825
|
* not the latest upstream claim. Returns `undefined` if the tool has no
|
|
@@ -2454,27 +2853,6 @@ function extractAnnotations(tool) {
|
|
|
2454
2853
|
const annotations = tool["annotations"];
|
|
2455
2854
|
return annotations && typeof annotations === "object" ? annotations : void 0;
|
|
2456
2855
|
}
|
|
2457
|
-
function canonicalize(value) {
|
|
2458
|
-
const encoded = JSON.stringify(sortKeysDeep(value));
|
|
2459
|
-
return encoded ?? "";
|
|
2460
|
-
}
|
|
2461
|
-
function sortKeysDeep(value) {
|
|
2462
|
-
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
2463
|
-
if (value !== null && typeof value === "object") {
|
|
2464
|
-
const source = value;
|
|
2465
|
-
const out = {};
|
|
2466
|
-
for (const key of Object.keys(source).sort()) {
|
|
2467
|
-
Object.defineProperty(out, key, {
|
|
2468
|
-
value: sortKeysDeep(source[key]),
|
|
2469
|
-
enumerable: true,
|
|
2470
|
-
writable: true,
|
|
2471
|
-
configurable: true
|
|
2472
|
-
});
|
|
2473
|
-
}
|
|
2474
|
-
return out;
|
|
2475
|
-
}
|
|
2476
|
-
return value;
|
|
2477
|
-
}
|
|
2478
2856
|
function extractTools(body) {
|
|
2479
2857
|
if (typeof body !== "object" || body === null) return null;
|
|
2480
2858
|
const b = body;
|
|
@@ -2486,49 +2864,6 @@ function extractTools(body) {
|
|
|
2486
2864
|
return tools;
|
|
2487
2865
|
}
|
|
2488
2866
|
|
|
2489
|
-
// src/evidence/grounding.ts
|
|
2490
|
-
function checkEvidence(store, sessionId, requirements) {
|
|
2491
|
-
if (requirements.length === 0) {
|
|
2492
|
-
return { satisfied: true, missing: [], expired: [], found: [] };
|
|
2493
|
-
}
|
|
2494
|
-
const found = [];
|
|
2495
|
-
const missing = [];
|
|
2496
|
-
const expired = [];
|
|
2497
|
-
for (const key of requirements) {
|
|
2498
|
-
const valid = store.getEvidence(sessionId, key);
|
|
2499
|
-
if (valid) {
|
|
2500
|
-
found.push(key);
|
|
2501
|
-
} else if (store.hasSeenEvidence(sessionId, key)) {
|
|
2502
|
-
expired.push(key);
|
|
2503
|
-
} else {
|
|
2504
|
-
missing.push(key);
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2507
|
-
return {
|
|
2508
|
-
satisfied: missing.length === 0 && expired.length === 0,
|
|
2509
|
-
missing,
|
|
2510
|
-
expired,
|
|
2511
|
-
found
|
|
2512
|
-
};
|
|
2513
|
-
}
|
|
2514
|
-
function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
2515
|
-
if (requirements.length === 0) {
|
|
2516
|
-
return { satisfied: true, missing: [] };
|
|
2517
|
-
}
|
|
2518
|
-
const requireSuccess = options.requireSuccess ?? true;
|
|
2519
|
-
const missing = [];
|
|
2520
|
-
for (const toolName of requirements) {
|
|
2521
|
-
const satisfied = requireSuccess ? store.hasSuccessfulTool(sessionId, toolName) : store.hasCompletedTool(sessionId, toolName);
|
|
2522
|
-
if (!satisfied) {
|
|
2523
|
-
missing.push(toolName);
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
return {
|
|
2527
|
-
satisfied: missing.length === 0,
|
|
2528
|
-
missing
|
|
2529
|
-
};
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
2867
|
// src/feedback/self-repair.ts
|
|
2533
2868
|
function ruleInfo(rule) {
|
|
2534
2869
|
return {
|
|
@@ -2740,6 +3075,7 @@ var GovernedForwarder = class {
|
|
|
2740
3075
|
spendLimiter;
|
|
2741
3076
|
annotationCache = new ToolAnnotationCache();
|
|
2742
3077
|
agentKeyWarned = false;
|
|
3078
|
+
senderKeyWarned = false;
|
|
2743
3079
|
constructor(inner, policy, options) {
|
|
2744
3080
|
this.inner = inner;
|
|
2745
3081
|
this.policy = policy;
|
|
@@ -2901,7 +3237,10 @@ var GovernedForwarder = class {
|
|
|
2901
3237
|
approval_wait_ms: 0,
|
|
2902
3238
|
proxy_compute_ms: 0,
|
|
2903
3239
|
flagged_destructive: false,
|
|
2904
|
-
dry_run: false
|
|
3240
|
+
dry_run: false,
|
|
3241
|
+
record_kind: "drift_event",
|
|
3242
|
+
origin: "mcp",
|
|
3243
|
+
metadata: null
|
|
2905
3244
|
});
|
|
2906
3245
|
}
|
|
2907
3246
|
async handleToolsCall(request) {
|
|
@@ -2913,99 +3252,28 @@ var GovernedForwarder = class {
|
|
|
2913
3252
|
return this.inner.forward(request);
|
|
2914
3253
|
}
|
|
2915
3254
|
const toolArguments = params?.["arguments"] && typeof params["arguments"] === "object" ? params["arguments"] : void 0;
|
|
2916
|
-
const
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
3255
|
+
const {
|
|
3256
|
+
decision,
|
|
3257
|
+
driftEvent,
|
|
3258
|
+
driftMode,
|
|
3259
|
+
driftBlocked,
|
|
3260
|
+
flaggedDestructive,
|
|
3261
|
+
evidenceResult,
|
|
3262
|
+
dependencyResult,
|
|
3263
|
+
evidenceBlocked,
|
|
3264
|
+
sessionBlocked,
|
|
3265
|
+
isDryRun
|
|
3266
|
+
} = decide({
|
|
3267
|
+
toolName,
|
|
2922
3268
|
toolArguments,
|
|
2923
|
-
|
|
3269
|
+
sessionId: request.sessionId,
|
|
3270
|
+
policy: this.policy,
|
|
3271
|
+
environment: this.environment,
|
|
3272
|
+
evidenceStore: this.evidenceStore,
|
|
3273
|
+
baselineAnnotations: this.annotationCache.get(toolName),
|
|
3274
|
+
currentAnnotations: this.annotationCache.getCurrent(toolName),
|
|
3275
|
+
driftEvent: this.annotationCache.getDrift(toolName)
|
|
2924
3276
|
});
|
|
2925
|
-
if (driftEvent && driftMode === "log") {
|
|
2926
|
-
const currentDecision = evaluatePolicy(this.policy, {
|
|
2927
|
-
toolName,
|
|
2928
|
-
annotations: this.annotationCache.getCurrent(toolName),
|
|
2929
|
-
toolArguments,
|
|
2930
|
-
environment: this.environment
|
|
2931
|
-
});
|
|
2932
|
-
decision = stricterDecision(decision, currentDecision);
|
|
2933
|
-
}
|
|
2934
|
-
const baselineDestructive = annotations?.destructiveHint ?? true;
|
|
2935
|
-
const currentDestructive = driftEvent && driftMode === "log" ? this.annotationCache.getCurrent(toolName)?.destructiveHint ?? true : false;
|
|
2936
|
-
const isDestructive = baselineDestructive || currentDestructive;
|
|
2937
|
-
let flaggedDestructive = false;
|
|
2938
|
-
if (isDestructive && !decision.matchedRule && this.policy.flagDestructive) {
|
|
2939
|
-
flaggedDestructive = true;
|
|
2940
|
-
if (this.policy.flagDestructive === "log") {
|
|
2941
|
-
console.error(`[helio] Destructive tool detected: ${toolName} (no matching rule)`);
|
|
2942
|
-
} else {
|
|
2943
|
-
decision = {
|
|
2944
|
-
action: "require_approval",
|
|
2945
|
-
matchedRule: void 0,
|
|
2946
|
-
reason: `Destructive tool "${toolName}" auto-escalated by flag_destructive policy`
|
|
2947
|
-
};
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
let driftBlocked = false;
|
|
2951
|
-
if (driftEvent && driftMode !== "log") {
|
|
2952
|
-
driftBlocked = driftMode === "block";
|
|
2953
|
-
decision = {
|
|
2954
|
-
action: driftMode === "block" ? "deny" : "require_approval",
|
|
2955
|
-
matchedRule: void 0,
|
|
2956
|
-
reason: `Tool "${toolName}" definition drifted from baseline (${driftEvent.changes.map((change) => change.aspect).join(", ")})`
|
|
2957
|
-
};
|
|
2958
|
-
}
|
|
2959
|
-
const originalAction = decision.action;
|
|
2960
|
-
let evidenceResult;
|
|
2961
|
-
let dependencyResult;
|
|
2962
|
-
let evidenceBlocked = false;
|
|
2963
|
-
let sessionBlocked = false;
|
|
2964
|
-
const requiresGroundedSession = decision.action !== "deny" && !!decision.matchedRule && ((decision.matchedRule.evidence?.requires.length ?? 0) > 0 || (decision.matchedRule.requires?.length ?? 0) > 0);
|
|
2965
|
-
if (requiresGroundedSession && !request.sessionId) {
|
|
2966
|
-
sessionBlocked = true;
|
|
2967
|
-
evidenceBlocked = true;
|
|
2968
|
-
decision = {
|
|
2969
|
-
action: "deny",
|
|
2970
|
-
matchedRule: decision.matchedRule,
|
|
2971
|
-
reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
|
|
2972
|
-
};
|
|
2973
|
-
}
|
|
2974
|
-
if (decision.action !== "deny" && this.evidenceStore && request.sessionId && decision.matchedRule) {
|
|
2975
|
-
const rule = decision.matchedRule;
|
|
2976
|
-
if (rule.evidence?.requires.length) {
|
|
2977
|
-
evidenceResult = checkEvidence(
|
|
2978
|
-
this.evidenceStore,
|
|
2979
|
-
request.sessionId,
|
|
2980
|
-
rule.evidence.requires
|
|
2981
|
-
);
|
|
2982
|
-
if (!evidenceResult.satisfied) {
|
|
2983
|
-
evidenceBlocked = true;
|
|
2984
|
-
const problemKeys = [...evidenceResult.missing, ...evidenceResult.expired];
|
|
2985
|
-
decision = {
|
|
2986
|
-
action: "deny",
|
|
2987
|
-
matchedRule: rule,
|
|
2988
|
-
reason: `Required evidence not satisfied: ${problemKeys.join(", ")}`
|
|
2989
|
-
};
|
|
2990
|
-
}
|
|
2991
|
-
}
|
|
2992
|
-
if (!evidenceBlocked && rule.requires?.length) {
|
|
2993
|
-
dependencyResult = checkDependencies(this.evidenceStore, request.sessionId, rule.requires, {
|
|
2994
|
-
requireSuccess: rule.requiresSuccess ?? true
|
|
2995
|
-
});
|
|
2996
|
-
if (!dependencyResult.satisfied) {
|
|
2997
|
-
evidenceBlocked = true;
|
|
2998
|
-
decision = {
|
|
2999
|
-
action: "deny",
|
|
3000
|
-
matchedRule: rule,
|
|
3001
|
-
reason: `Required tool calls not completed: ${dependencyResult.missing.join(", ")}`
|
|
3002
|
-
};
|
|
3003
|
-
}
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
const isPerRuleDryRun = originalAction === "dry_run";
|
|
3007
|
-
const isGlobalDryRun = this.policy.dryRun === true;
|
|
3008
|
-
const isDryRun = (isPerRuleDryRun || isGlobalDryRun) && !sessionBlocked;
|
|
3009
3277
|
let result;
|
|
3010
3278
|
let approvalOutcome;
|
|
3011
3279
|
let approvalWaitMs = 0;
|
|
@@ -3302,6 +3570,14 @@ var GovernedForwarder = class {
|
|
|
3302
3570
|
);
|
|
3303
3571
|
}
|
|
3304
3572
|
return `tool:${toolName}`;
|
|
3573
|
+
case "sender_id":
|
|
3574
|
+
if (!this.senderKeyWarned) {
|
|
3575
|
+
this.senderKeyWarned = true;
|
|
3576
|
+
console.error(
|
|
3577
|
+
'[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
return `tool:${toolName}`;
|
|
3305
3581
|
case "tool":
|
|
3306
3582
|
default:
|
|
3307
3583
|
return `tool:${toolName}`;
|
|
@@ -3403,7 +3679,10 @@ var GovernedForwarder = class {
|
|
|
3403
3679
|
approval_wait_ms: approvalWaitMs,
|
|
3404
3680
|
proxy_compute_ms: proxyComputeMs,
|
|
3405
3681
|
flagged_destructive: flaggedDestructive,
|
|
3406
|
-
dry_run: isDryRun ?? false
|
|
3682
|
+
dry_run: isDryRun ?? false,
|
|
3683
|
+
record_kind: "tool_call",
|
|
3684
|
+
origin: "mcp",
|
|
3685
|
+
metadata: null
|
|
3407
3686
|
};
|
|
3408
3687
|
const isEnforcementDecision = !isDryRun && (!wasForwarded || approvalOutcome !== void 0);
|
|
3409
3688
|
if (isEnforcementDecision) {
|
|
@@ -3509,17 +3788,6 @@ function collectAllowedEvidenceKeys(policy) {
|
|
|
3509
3788
|
}
|
|
3510
3789
|
return [...keys];
|
|
3511
3790
|
}
|
|
3512
|
-
var ACTION_SEVERITY = {
|
|
3513
|
-
deny: 5,
|
|
3514
|
-
require_approval: 4,
|
|
3515
|
-
dry_run: 3,
|
|
3516
|
-
spend_limit: 2,
|
|
3517
|
-
rate_limit: 1,
|
|
3518
|
-
allow: 0
|
|
3519
|
-
};
|
|
3520
|
-
function stricterDecision(a, b) {
|
|
3521
|
-
return ACTION_SEVERITY[b.action] > ACTION_SEVERITY[a.action] ? b : a;
|
|
3522
|
-
}
|
|
3523
3791
|
function makeErrorResult(request, code, message, data) {
|
|
3524
3792
|
const body = {
|
|
3525
3793
|
jsonrpc: "2.0",
|
|
@@ -3652,6 +3920,46 @@ var RateLimiter = class {
|
|
|
3652
3920
|
resetAtMs
|
|
3653
3921
|
};
|
|
3654
3922
|
}
|
|
3923
|
+
/**
|
|
3924
|
+
* Unconditionally record a call against the rate limit.
|
|
3925
|
+
*
|
|
3926
|
+
* Unlike check(), this always appends the timestamp — even when the bucket
|
|
3927
|
+
* is already at/over the limit — because the call it represents has already
|
|
3928
|
+
* executed. The sideband splits decision from execution: /evaluate peeks
|
|
3929
|
+
* (non-destructive), and /audit calls record() once the external call ran,
|
|
3930
|
+
* so refusing to record at the limit (as check() does) would let real calls
|
|
3931
|
+
* escape accounting and under-count subsequent peeks. (issue #12, D3.)
|
|
3932
|
+
*
|
|
3933
|
+
* Warnings fire only while the post-append count stays within the limit —
|
|
3934
|
+
* exact parity with check(), which never warns on its over-limit path — so a
|
|
3935
|
+
* burst of over-limit audits cannot flood the dashboard's limit_warning feed.
|
|
3936
|
+
*/
|
|
3937
|
+
record(params) {
|
|
3938
|
+
const { key, maxCalls, windowMs } = params;
|
|
3939
|
+
const now = this.now();
|
|
3940
|
+
const windowStart = now - windowMs;
|
|
3941
|
+
let bucket = this.buckets.get(key);
|
|
3942
|
+
if (!bucket) {
|
|
3943
|
+
bucket = { timestamps: [], maxCalls, windowMs };
|
|
3944
|
+
this.buckets.set(key, bucket);
|
|
3945
|
+
}
|
|
3946
|
+
bucket.maxCalls = maxCalls;
|
|
3947
|
+
bucket.windowMs = windowMs;
|
|
3948
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
|
|
3949
|
+
bucket.timestamps.push(now);
|
|
3950
|
+
const current = bucket.timestamps.length;
|
|
3951
|
+
const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
|
|
3952
|
+
if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
|
|
3953
|
+
this.onWarning({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
|
|
3954
|
+
}
|
|
3955
|
+
return {
|
|
3956
|
+
allowed: current <= maxCalls,
|
|
3957
|
+
current,
|
|
3958
|
+
limit: maxCalls,
|
|
3959
|
+
windowMs,
|
|
3960
|
+
resetAtMs
|
|
3961
|
+
};
|
|
3962
|
+
}
|
|
3655
3963
|
/**
|
|
3656
3964
|
* Check the rate limit without recording the call (non-destructive).
|
|
3657
3965
|
*
|
|
@@ -3866,6 +4174,57 @@ var SpendLimiter = class {
|
|
|
3866
4174
|
resetAtMs
|
|
3867
4175
|
};
|
|
3868
4176
|
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Unconditionally record a spend against the limit.
|
|
4179
|
+
*
|
|
4180
|
+
* Unlike check(), this always appends the amount — even when it pushes the
|
|
4181
|
+
* window past the limit — because the spend it represents has already been
|
|
4182
|
+
* incurred. The sideband peeks at /evaluate and commits here at /audit once
|
|
4183
|
+
* the external call ran (issue #12, D3).
|
|
4184
|
+
*
|
|
4185
|
+
* Throws on a negative or non-finite amount: such amounts are rejected at
|
|
4186
|
+
* /evaluate, so one reaching record() is a logic bug we surface loudly rather
|
|
4187
|
+
* than silently corrupt the sliding-window sum. Warnings fire only while the
|
|
4188
|
+
* post-append spend stays within the limit (parity with check()).
|
|
4189
|
+
*/
|
|
4190
|
+
record(params) {
|
|
4191
|
+
const { key, amount, limit, windowMs } = params;
|
|
4192
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
4193
|
+
throw new RangeError(
|
|
4194
|
+
`SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
|
|
4195
|
+
);
|
|
4196
|
+
}
|
|
4197
|
+
const now = this.now();
|
|
4198
|
+
const windowStart = now - windowMs;
|
|
4199
|
+
let bucket = this.buckets.get(key);
|
|
4200
|
+
if (!bucket) {
|
|
4201
|
+
bucket = { entries: [], limit, currency: "", windowMs };
|
|
4202
|
+
this.buckets.set(key, bucket);
|
|
4203
|
+
}
|
|
4204
|
+
bucket.limit = limit;
|
|
4205
|
+
bucket.windowMs = windowMs;
|
|
4206
|
+
bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
|
|
4207
|
+
bucket.entries.push({ timestamp: now, amount });
|
|
4208
|
+
const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
|
|
4209
|
+
const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
|
|
4210
|
+
if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
|
|
4211
|
+
this.onWarning({
|
|
4212
|
+
key,
|
|
4213
|
+
current_spend: currentSpend,
|
|
4214
|
+
limit,
|
|
4215
|
+
currency: bucket.currency,
|
|
4216
|
+
window_ms: windowMs,
|
|
4217
|
+
reset_at_ms: resetAtMs
|
|
4218
|
+
});
|
|
4219
|
+
}
|
|
4220
|
+
return {
|
|
4221
|
+
allowed: currentSpend <= limit,
|
|
4222
|
+
currentSpend,
|
|
4223
|
+
limit,
|
|
4224
|
+
windowMs,
|
|
4225
|
+
resetAtMs
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
3869
4228
|
/**
|
|
3870
4229
|
* Check the spend limit without recording the spend (non-destructive).
|
|
3871
4230
|
*
|
|
@@ -4111,6 +4470,9 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
4111
4470
|
proxy_compute_ms REAL NOT NULL,
|
|
4112
4471
|
flagged_destructive INTEGER NOT NULL DEFAULT 0,
|
|
4113
4472
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
4473
|
+
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
4474
|
+
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
4475
|
+
metadata TEXT,
|
|
4114
4476
|
created_at TEXT NOT NULL
|
|
4115
4477
|
);
|
|
4116
4478
|
`;
|
|
@@ -4121,6 +4483,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_policy_decision ON audit_records (policy_d
|
|
|
4121
4483
|
CREATE INDEX IF NOT EXISTS idx_audit_session_id ON audit_records (session_id);
|
|
4122
4484
|
CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_reason);
|
|
4123
4485
|
CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
|
|
4486
|
+
CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
|
|
4487
|
+
CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
4124
4488
|
`;
|
|
4125
4489
|
var INSERT_SQL = `
|
|
4126
4490
|
INSERT INTO audit_records (
|
|
@@ -4129,14 +4493,14 @@ INSERT INTO audit_records (
|
|
|
4129
4493
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
4130
4494
|
upstream_http_status,
|
|
4131
4495
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
4132
|
-
flagged_destructive, dry_run, created_at
|
|
4496
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
4133
4497
|
) VALUES (
|
|
4134
4498
|
@id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
|
|
4135
4499
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
4136
4500
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
4137
4501
|
@upstream_http_status,
|
|
4138
4502
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
4139
|
-
@flagged_destructive, @dry_run, @created_at
|
|
4503
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
4140
4504
|
)
|
|
4141
4505
|
`;
|
|
4142
4506
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -4146,7 +4510,10 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
4146
4510
|
"total_duration_ms",
|
|
4147
4511
|
"approval_wait_ms",
|
|
4148
4512
|
"proxy_compute_ms",
|
|
4149
|
-
"upstream_http_status"
|
|
4513
|
+
"upstream_http_status",
|
|
4514
|
+
"record_kind",
|
|
4515
|
+
"origin",
|
|
4516
|
+
"metadata"
|
|
4150
4517
|
];
|
|
4151
4518
|
function deserializeRow(row) {
|
|
4152
4519
|
return {
|
|
@@ -4173,6 +4540,9 @@ function deserializeRow(row) {
|
|
|
4173
4540
|
proxy_compute_ms: row.proxy_compute_ms,
|
|
4174
4541
|
flagged_destructive: row.flagged_destructive === 1,
|
|
4175
4542
|
dry_run: row.dry_run === 1,
|
|
4543
|
+
record_kind: row.record_kind,
|
|
4544
|
+
origin: row.origin,
|
|
4545
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
4176
4546
|
created_at: row.created_at
|
|
4177
4547
|
};
|
|
4178
4548
|
}
|
|
@@ -4194,6 +4564,22 @@ function buildWhereClause(filters) {
|
|
|
4194
4564
|
if (filters.blocked !== void 0) {
|
|
4195
4565
|
conditions.push(filters.blocked ? "block_reason IS NOT NULL" : "block_reason IS NULL");
|
|
4196
4566
|
}
|
|
4567
|
+
if (filters.record_kind !== void 0) {
|
|
4568
|
+
conditions.push("record_kind = ?");
|
|
4569
|
+
params.push(filters.record_kind);
|
|
4570
|
+
}
|
|
4571
|
+
if (filters.origin !== void 0) {
|
|
4572
|
+
conditions.push("origin LIKE ?");
|
|
4573
|
+
params.push(`%${filters.origin}%`);
|
|
4574
|
+
}
|
|
4575
|
+
if (filters.channel_id !== void 0) {
|
|
4576
|
+
conditions.push("json_extract(metadata, '$.channel_id') LIKE ?");
|
|
4577
|
+
params.push(`%${filters.channel_id}%`);
|
|
4578
|
+
}
|
|
4579
|
+
if (filters.sender_id !== void 0) {
|
|
4580
|
+
conditions.push("json_extract(metadata, '$.sender_id') LIKE ?");
|
|
4581
|
+
params.push(`%${filters.sender_id}%`);
|
|
4582
|
+
}
|
|
4197
4583
|
if (filters.session_id !== void 0) {
|
|
4198
4584
|
conditions.push("session_id = ?");
|
|
4199
4585
|
params.push(filters.session_id);
|
|
@@ -4320,6 +4706,9 @@ var AuditStore = class {
|
|
|
4320
4706
|
proxy_compute_ms: record.proxy_compute_ms,
|
|
4321
4707
|
flagged_destructive: record.flagged_destructive ? 1 : 0,
|
|
4322
4708
|
dry_run: record.dry_run ? 1 : 0,
|
|
4709
|
+
record_kind: record.record_kind,
|
|
4710
|
+
origin: record.origin,
|
|
4711
|
+
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
4323
4712
|
created_at: now
|
|
4324
4713
|
});
|
|
4325
4714
|
return resolvedId;
|
|
@@ -4501,9 +4890,8 @@ var AuditWriter = class {
|
|
|
4501
4890
|
* is scheduled. This keeps request-path latency bounded even under bursty
|
|
4502
4891
|
* write load.
|
|
4503
4892
|
*/
|
|
4504
|
-
push(record) {
|
|
4893
|
+
push(record, id = randomUUID3()) {
|
|
4505
4894
|
if (this.closed) return;
|
|
4506
|
-
const id = randomUUID3();
|
|
4507
4895
|
this.buffer.push({ id, record });
|
|
4508
4896
|
this.onPush?.(record, id);
|
|
4509
4897
|
if (this.buffer.length >= this.bufferSize) {
|
|
@@ -4518,9 +4906,8 @@ var AuditWriter = class {
|
|
|
4518
4906
|
* A fatal-process crash still invokes the crash-drain hook, which calls
|
|
4519
4907
|
* `flush()` synchronously before exit.
|
|
4520
4908
|
*/
|
|
4521
|
-
pushImmediate(record) {
|
|
4909
|
+
pushImmediate(record, id = randomUUID3()) {
|
|
4522
4910
|
if (this.closed) return;
|
|
4523
|
-
const id = randomUUID3();
|
|
4524
4911
|
this.buffer.push({ id, record });
|
|
4525
4912
|
this.onPush?.(record, id);
|
|
4526
4913
|
this.scheduleFlushSoon();
|
|
@@ -4866,8 +5253,9 @@ var EvidenceStore = class _EvidenceStore {
|
|
|
4866
5253
|
};
|
|
4867
5254
|
|
|
4868
5255
|
// src/evidence/api.ts
|
|
4869
|
-
import { Hono as
|
|
4870
|
-
import {
|
|
5256
|
+
import { Hono as Hono5 } from "hono";
|
|
5257
|
+
import { bodyLimit } from "hono/body-limit";
|
|
5258
|
+
import { z as z5 } from "zod";
|
|
4871
5259
|
|
|
4872
5260
|
// src/auth/bearer.ts
|
|
4873
5261
|
import { createHash, timingSafeEqual } from "crypto";
|
|
@@ -4879,22 +5267,194 @@ function verifyBearer(authHeader, expected) {
|
|
|
4879
5267
|
return timingSafeEqual(actualDigest, expectedDigest);
|
|
4880
5268
|
}
|
|
4881
5269
|
|
|
4882
|
-
// src/
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
5270
|
+
// src/sideband/governance-api.ts
|
|
5271
|
+
import { Hono as Hono4 } from "hono";
|
|
5272
|
+
import { z as z4 } from "zod";
|
|
5273
|
+
import { createHash as createHash2 } from "crypto";
|
|
5274
|
+
var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
|
|
5275
|
+
var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
|
|
5276
|
+
var toolDefinitionSchema = z4.object({
|
|
5277
|
+
name: z4.string().min(1),
|
|
5278
|
+
description: z4.string().optional(),
|
|
5279
|
+
input_schema: z4.unknown().optional(),
|
|
5280
|
+
output_schema: z4.unknown().optional(),
|
|
5281
|
+
title: z4.string().optional(),
|
|
5282
|
+
annotations: z4.record(z4.string(), z4.unknown()).optional()
|
|
5283
|
+
});
|
|
5284
|
+
var evaluateBody = z4.object({
|
|
5285
|
+
origin: originSchema,
|
|
5286
|
+
adapter_version: z4.string().max(64).optional(),
|
|
5287
|
+
agent_id: z4.string().nullish(),
|
|
5288
|
+
session_id: z4.string().nullish(),
|
|
5289
|
+
tool: toolDefinitionSchema,
|
|
5290
|
+
arguments: z4.record(z4.string(), z4.unknown()).optional(),
|
|
5291
|
+
metadata: metadataSchema
|
|
5292
|
+
});
|
|
5293
|
+
var installScanBody = z4.object({
|
|
5294
|
+
origin: originSchema,
|
|
5295
|
+
agent_id: z4.string().nullish(),
|
|
5296
|
+
session_id: z4.string().nullish(),
|
|
5297
|
+
package: z4.object({
|
|
5298
|
+
name: z4.string().min(1),
|
|
5299
|
+
version: z4.string().optional(),
|
|
5300
|
+
source: z4.string().max(64).optional(),
|
|
5301
|
+
spec: z4.string().optional(),
|
|
5302
|
+
url: z4.string().optional()
|
|
5303
|
+
}),
|
|
5304
|
+
metadata: metadataSchema
|
|
5305
|
+
});
|
|
5306
|
+
var evidenceEntrySchema = z4.object({
|
|
4886
5307
|
evidence_key: z4.string().min(1),
|
|
4887
5308
|
evidence_data: z4.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
4888
5309
|
ttl_seconds: z4.number().int().positive().optional()
|
|
4889
5310
|
});
|
|
4890
|
-
var
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
5311
|
+
var auditBody = z4.object({
|
|
5312
|
+
evaluation_id: z4.string().min(1),
|
|
5313
|
+
status: z4.enum(["success", "error", "not_executed"]),
|
|
5314
|
+
error: z4.string().optional(),
|
|
5315
|
+
duration_ms: z4.number().optional(),
|
|
5316
|
+
result: z4.unknown().optional(),
|
|
5317
|
+
actual_amount: z4.number().optional(),
|
|
5318
|
+
// No `.max()` / size refinement here on purpose (issue #11): caps are
|
|
5319
|
+
// enforced per-entry in GovernanceService.populateEvidence as soft-drops, so
|
|
5320
|
+
// an over-cap entry never 400s away the audit row for a call that already ran.
|
|
5321
|
+
evidence: z4.array(evidenceEntrySchema).optional()
|
|
4894
5322
|
});
|
|
4895
|
-
|
|
5323
|
+
var resolveBody = z4.object({
|
|
5324
|
+
resolution: z4.enum(["approved", "denied", "timeout", "cancelled"]),
|
|
5325
|
+
resolved_by: z4.string().optional(),
|
|
5326
|
+
reason: z4.string().optional(),
|
|
5327
|
+
scope: z4.enum(["once", "always"]).optional()
|
|
5328
|
+
});
|
|
5329
|
+
var MAX_METADATA_BYTES = 4 * 1024;
|
|
5330
|
+
function createGovernanceApp(service) {
|
|
4896
5331
|
const app = new Hono4();
|
|
4897
|
-
const
|
|
5332
|
+
const unavailable = () => ({ error: "governance_unavailable" });
|
|
5333
|
+
app.post("/evaluate", async (c) => {
|
|
5334
|
+
if (!service) return c.json(unavailable(), 503);
|
|
5335
|
+
const parsed = await parseJson(c);
|
|
5336
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
5337
|
+
const result = evaluateBody.safeParse(parsed.body);
|
|
5338
|
+
if (!result.success) {
|
|
5339
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
5340
|
+
}
|
|
5341
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
5342
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
5343
|
+
}
|
|
5344
|
+
const r = service.evaluate({
|
|
5345
|
+
origin: result.data.origin,
|
|
5346
|
+
adapter_version: result.data.adapter_version,
|
|
5347
|
+
agent_id: result.data.agent_id ?? null,
|
|
5348
|
+
session_id: result.data.session_id ?? null,
|
|
5349
|
+
tool: result.data.tool,
|
|
5350
|
+
arguments: result.data.arguments,
|
|
5351
|
+
metadata: result.data.metadata ?? null
|
|
5352
|
+
});
|
|
5353
|
+
return c.json(r.body, asStatus(r.status));
|
|
5354
|
+
});
|
|
5355
|
+
app.post("/audit", async (c) => {
|
|
5356
|
+
if (!service) return c.json(unavailable(), 503);
|
|
5357
|
+
const parsed = await parseJson(c);
|
|
5358
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
5359
|
+
const result = auditBody.safeParse(parsed.body);
|
|
5360
|
+
if (!result.success) {
|
|
5361
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
5362
|
+
}
|
|
5363
|
+
const hash = auditPayloadHash(result.data);
|
|
5364
|
+
const r = service.audit(result.data, hash);
|
|
5365
|
+
return c.json(r.body, asStatus(r.status));
|
|
5366
|
+
});
|
|
5367
|
+
app.post("/install-scan", async (c) => {
|
|
5368
|
+
if (!service) return c.json(unavailable(), 503);
|
|
5369
|
+
const parsed = await parseJson(c);
|
|
5370
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
5371
|
+
const result = installScanBody.safeParse(parsed.body);
|
|
5372
|
+
if (!result.success) {
|
|
5373
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
5374
|
+
}
|
|
5375
|
+
if (metadataTooLarge(result.data.metadata)) {
|
|
5376
|
+
return c.json({ error: "metadata_too_large" }, 413);
|
|
5377
|
+
}
|
|
5378
|
+
const r = service.installScan({
|
|
5379
|
+
origin: result.data.origin,
|
|
5380
|
+
agent_id: result.data.agent_id ?? null,
|
|
5381
|
+
session_id: result.data.session_id ?? null,
|
|
5382
|
+
package: result.data.package,
|
|
5383
|
+
metadata: result.data.metadata ?? null
|
|
5384
|
+
});
|
|
5385
|
+
return c.json(r.body, asStatus(r.status));
|
|
5386
|
+
});
|
|
5387
|
+
app.post("/approval/:id/resolve", async (c) => {
|
|
5388
|
+
if (!service) return c.json(unavailable(), 503);
|
|
5389
|
+
const parsed = await parseJson(c);
|
|
5390
|
+
if ("error" in parsed) return c.json(parsed.error, 400);
|
|
5391
|
+
const result = resolveBody.safeParse(parsed.body);
|
|
5392
|
+
if (!result.success) {
|
|
5393
|
+
return c.json({ error: "Validation error", details: formatZodErrors(result.error) }, 400);
|
|
5394
|
+
}
|
|
5395
|
+
if ((result.data.resolution === "approved" || result.data.resolution === "denied") && !result.data.resolved_by) {
|
|
5396
|
+
return c.json({ error: "resolved_by is required for approved/denied" }, 400);
|
|
5397
|
+
}
|
|
5398
|
+
const r = service.resolveApproval(c.req.param("id"), result.data);
|
|
5399
|
+
return c.json(r.body, asStatus(r.status));
|
|
5400
|
+
});
|
|
5401
|
+
return app;
|
|
5402
|
+
}
|
|
5403
|
+
function isGovernancePath(path) {
|
|
5404
|
+
return path === "/evaluate" || path === "/audit" || path === "/install-scan" || path.startsWith("/approval/");
|
|
5405
|
+
}
|
|
5406
|
+
async function parseJson(c) {
|
|
5407
|
+
try {
|
|
5408
|
+
return { body: await c.req.json() };
|
|
5409
|
+
} catch {
|
|
5410
|
+
return { error: { error: "Invalid JSON" } };
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5413
|
+
function metadataTooLarge(metadata) {
|
|
5414
|
+
if (metadata == null) return false;
|
|
5415
|
+
return Buffer.byteLength(canonicalize(metadata), "utf8") > MAX_METADATA_BYTES;
|
|
5416
|
+
}
|
|
5417
|
+
function auditPayloadHash(data) {
|
|
5418
|
+
const semantic = {
|
|
5419
|
+
status: data.status,
|
|
5420
|
+
error: data.error ?? null,
|
|
5421
|
+
duration_ms: data.duration_ms ?? null,
|
|
5422
|
+
result: data.result ?? null,
|
|
5423
|
+
actual_amount: data.actual_amount ?? null,
|
|
5424
|
+
evidence: canonicalEvidence(data.evidence)
|
|
5425
|
+
};
|
|
5426
|
+
return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
|
|
5427
|
+
}
|
|
5428
|
+
function canonicalEvidence(evidence) {
|
|
5429
|
+
if (!evidence || evidence.length === 0) return null;
|
|
5430
|
+
return evidence.map((e) => ({
|
|
5431
|
+
evidence_key: e.evidence_key,
|
|
5432
|
+
evidence_data: e.evidence_data ?? null,
|
|
5433
|
+
ttl_seconds: e.ttl_seconds ?? null
|
|
5434
|
+
})).map((norm) => ({ sortKey: canonicalize(norm), norm })).sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0).map((x) => x.norm);
|
|
5435
|
+
}
|
|
5436
|
+
function asStatus(status) {
|
|
5437
|
+
return status;
|
|
5438
|
+
}
|
|
5439
|
+
|
|
5440
|
+
// src/evidence/api.ts
|
|
5441
|
+
var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
5442
|
+
var postEvidenceBody = z5.object({
|
|
5443
|
+
session_id: z5.string().min(1),
|
|
5444
|
+
tool_name: z5.string().min(1),
|
|
5445
|
+
evidence_key: z5.string().min(1),
|
|
5446
|
+
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
5447
|
+
ttl_seconds: z5.number().int().positive().optional()
|
|
5448
|
+
});
|
|
5449
|
+
var postContextBody = z5.object({
|
|
5450
|
+
session_id: z5.string().min(1),
|
|
5451
|
+
key: z5.string().min(1),
|
|
5452
|
+
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
5453
|
+
});
|
|
5454
|
+
function createSidebandApp(store, options = {}) {
|
|
5455
|
+
const app = new Hono5();
|
|
5456
|
+
const sdkToken = options.token && options.token.length > 0 ? options.token : void 0;
|
|
5457
|
+
const adapterToken = options.adapterToken && options.adapterToken.length > 0 ? options.adapterToken : void 0;
|
|
4898
5458
|
app.use("*", async (c, next) => {
|
|
4899
5459
|
const origin = c.req.header("origin");
|
|
4900
5460
|
if (origin) {
|
|
@@ -4905,20 +5465,26 @@ function createSidebandApp(store, options = {}) {
|
|
|
4905
5465
|
}
|
|
4906
5466
|
await next();
|
|
4907
5467
|
});
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
}
|
|
5468
|
+
app.use(
|
|
5469
|
+
"*",
|
|
5470
|
+
bodyLimit({
|
|
5471
|
+
maxSize: SIDEBAND_BODY_LIMIT_BYTES,
|
|
5472
|
+
onError: (c) => c.json({ error: "request_body_too_large" }, 413)
|
|
5473
|
+
})
|
|
5474
|
+
);
|
|
5475
|
+
app.use("*", async (c, next) => {
|
|
5476
|
+
if (c.req.path === "/healthz") {
|
|
4918
5477
|
await next();
|
|
4919
|
-
|
|
4920
|
-
|
|
5478
|
+
return;
|
|
5479
|
+
}
|
|
5480
|
+
const expected = isGovernancePath(c.req.path) ? adapterToken : sdkToken;
|
|
5481
|
+
if (expected && !verifyBearer(c.req.header("authorization"), expected)) {
|
|
5482
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
5483
|
+
}
|
|
5484
|
+
await next();
|
|
5485
|
+
});
|
|
4921
5486
|
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
5487
|
+
app.route("/", createGovernanceApp(options.governance));
|
|
4922
5488
|
app.post("/evidence", async (c) => {
|
|
4923
5489
|
let body;
|
|
4924
5490
|
try {
|
|
@@ -4981,8 +5547,862 @@ function createSidebandApp(store, options = {}) {
|
|
|
4981
5547
|
return app;
|
|
4982
5548
|
}
|
|
4983
5549
|
|
|
4984
|
-
// src/
|
|
5550
|
+
// src/sideband/governance-service.ts
|
|
4985
5551
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
5552
|
+
|
|
5553
|
+
// src/sideband/errors.ts
|
|
5554
|
+
var GovernanceConfigError = class extends Error {
|
|
5555
|
+
constructor(message) {
|
|
5556
|
+
super(message);
|
|
5557
|
+
this.name = "GovernanceConfigError";
|
|
5558
|
+
}
|
|
5559
|
+
};
|
|
5560
|
+
|
|
5561
|
+
// src/sideband/governance-service.ts
|
|
5562
|
+
var MAX_ORIGINS = 32;
|
|
5563
|
+
var MAX_TOOLS_PER_ORIGIN = 1024;
|
|
5564
|
+
var MAX_TOOL_INPUT_BYTES = 64 * 1024;
|
|
5565
|
+
var MAX_PENDING_COUNT = 1e4;
|
|
5566
|
+
var MAX_PENDING_BYTES = 64 * 1024 * 1024;
|
|
5567
|
+
var MAX_SENDER_KEYS = 5e4;
|
|
5568
|
+
var MAX_EVIDENCE_ENTRIES = 16;
|
|
5569
|
+
var MAX_EVIDENCE_BYTES = 64 * 1024;
|
|
5570
|
+
var SWEEP_INTERVAL_MS2 = 3e4;
|
|
5571
|
+
var GovernanceService = class {
|
|
5572
|
+
policy;
|
|
5573
|
+
environment;
|
|
5574
|
+
evidenceStore;
|
|
5575
|
+
approvalRouter;
|
|
5576
|
+
rateLimiter;
|
|
5577
|
+
spendLimiter;
|
|
5578
|
+
auditWriter;
|
|
5579
|
+
approvalTimeoutMs;
|
|
5580
|
+
ttlMs;
|
|
5581
|
+
now;
|
|
5582
|
+
maxPending;
|
|
5583
|
+
maxPendingBytes;
|
|
5584
|
+
maxSenderKeys;
|
|
5585
|
+
/** Distinct sender_id limit keys with live state (reservation registry, issue #13). */
|
|
5586
|
+
senderKeys = /* @__PURE__ */ new Set();
|
|
5587
|
+
pending = /* @__PURE__ */ new Map();
|
|
5588
|
+
tombstones = /* @__PURE__ */ new Map();
|
|
5589
|
+
caches = /* @__PURE__ */ new Map();
|
|
5590
|
+
/** Native approval ticket id → its pending evaluation id, for on-access
|
|
5591
|
+
* deadline enforcement on the resolve path. */
|
|
5592
|
+
ticketToEvaluation = /* @__PURE__ */ new Map();
|
|
5593
|
+
pendingBytes = 0;
|
|
5594
|
+
sweepTimer = null;
|
|
5595
|
+
closed = false;
|
|
5596
|
+
constructor(options) {
|
|
5597
|
+
this.policy = options.policy;
|
|
5598
|
+
this.environment = options.environment;
|
|
5599
|
+
this.evidenceStore = options.evidenceStore;
|
|
5600
|
+
this.approvalRouter = options.approvalRouter;
|
|
5601
|
+
this.rateLimiter = options.rateLimiter;
|
|
5602
|
+
this.spendLimiter = options.spendLimiter;
|
|
5603
|
+
this.auditWriter = options.auditWriter;
|
|
5604
|
+
this.approvalTimeoutMs = options.approvalTimeoutMs ?? 3e5;
|
|
5605
|
+
this.ttlMs = options.ttlMs ?? 6e5;
|
|
5606
|
+
this.now = options.now ?? Date.now;
|
|
5607
|
+
this.maxPending = options.maxPending ?? MAX_PENDING_COUNT;
|
|
5608
|
+
this.maxPendingBytes = options.maxPendingBytes ?? MAX_PENDING_BYTES;
|
|
5609
|
+
this.maxSenderKeys = options.maxSenderKeys ?? MAX_SENDER_KEYS;
|
|
5610
|
+
this.assertApprovalRouter(this.policy);
|
|
5611
|
+
const sweepMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS2;
|
|
5612
|
+
if (sweepMs > 0) {
|
|
5613
|
+
this.sweepTimer = setInterval(() => {
|
|
5614
|
+
this.sweep();
|
|
5615
|
+
}, sweepMs);
|
|
5616
|
+
this.sweepTimer.unref();
|
|
5617
|
+
}
|
|
5618
|
+
}
|
|
5619
|
+
/** Swap the compiled policy on hot-reload (mirrors GovernedForwarder). */
|
|
5620
|
+
updatePolicy(policy) {
|
|
5621
|
+
this.assertApprovalRouter(policy);
|
|
5622
|
+
this.policy = policy;
|
|
5623
|
+
}
|
|
5624
|
+
// -------------------------------------------------------------------------
|
|
5625
|
+
// POST /evaluate
|
|
5626
|
+
// -------------------------------------------------------------------------
|
|
5627
|
+
evaluate(req) {
|
|
5628
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
5629
|
+
if (reserved) {
|
|
5630
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
5631
|
+
}
|
|
5632
|
+
const inputBytes = byteLength(req.arguments ?? {});
|
|
5633
|
+
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
5634
|
+
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
5635
|
+
}
|
|
5636
|
+
const entryBytes = inputBytes + byteLength(req.metadata ?? {});
|
|
5637
|
+
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
5638
|
+
return { status: 400, body: { error: "origin_limit_exceeded" } };
|
|
5639
|
+
}
|
|
5640
|
+
if (this.pending.size >= this.maxPending || this.pendingBytes + entryBytes > this.maxPendingBytes) {
|
|
5641
|
+
return { status: 503, body: { error: "evaluation_backlog_full" } };
|
|
5642
|
+
}
|
|
5643
|
+
const cache = this.cacheFor(req.origin);
|
|
5644
|
+
const toolName = req.tool.name;
|
|
5645
|
+
const hasDefinition = definitionProvided(req.tool);
|
|
5646
|
+
if (hasDefinition) {
|
|
5647
|
+
if (!cache.has(toolName) && cache.size >= MAX_TOOLS_PER_ORIGIN) {
|
|
5648
|
+
return { status: 400, body: { error: "tool_baseline_limit" } };
|
|
5649
|
+
}
|
|
5650
|
+
cache.updateSingle(toMcpToolDef(req.tool));
|
|
5651
|
+
}
|
|
5652
|
+
const pipeline = decide({
|
|
5653
|
+
toolName,
|
|
5654
|
+
toolArguments: req.arguments,
|
|
5655
|
+
sessionId: req.session_id ?? void 0,
|
|
5656
|
+
policy: this.policy,
|
|
5657
|
+
environment: this.environment,
|
|
5658
|
+
evidenceStore: this.evidenceStore,
|
|
5659
|
+
baselineAnnotations: cache.get(toolName),
|
|
5660
|
+
currentAnnotations: cache.getCurrent(toolName),
|
|
5661
|
+
driftEvent: cache.getDrift(toolName),
|
|
5662
|
+
metadata: req.metadata ?? void 0,
|
|
5663
|
+
agentId: req.agent_id ?? void 0
|
|
5664
|
+
});
|
|
5665
|
+
const { decision } = pipeline;
|
|
5666
|
+
const evaluationId = randomUUID4();
|
|
5667
|
+
const timestampIso = new Date(this.now()).toISOString();
|
|
5668
|
+
let wire;
|
|
5669
|
+
let limitPlan;
|
|
5670
|
+
let limitsBlock;
|
|
5671
|
+
const senderId = senderIdOf(req.metadata);
|
|
5672
|
+
if (pipeline.isDryRun) {
|
|
5673
|
+
wire = "dry_run";
|
|
5674
|
+
} else if (decision.action === "deny") {
|
|
5675
|
+
wire = "deny";
|
|
5676
|
+
} else if (decision.action === "require_approval") {
|
|
5677
|
+
wire = "require_approval";
|
|
5678
|
+
} else if (decision.action === "rate_limit") {
|
|
5679
|
+
const planned = this.planRate(decision, toolName, req.session_id, senderId);
|
|
5680
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
5681
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
5682
|
+
}
|
|
5683
|
+
limitPlan = planned?.plan;
|
|
5684
|
+
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
5685
|
+
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
5686
|
+
} else if (decision.action === "spend_limit") {
|
|
5687
|
+
const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
|
|
5688
|
+
if (planned?.plan && !this.reserveSenderKey(planned.plan.key)) {
|
|
5689
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
5690
|
+
}
|
|
5691
|
+
limitPlan = planned?.plan;
|
|
5692
|
+
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
5693
|
+
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
5694
|
+
} else {
|
|
5695
|
+
wire = "allow";
|
|
5696
|
+
}
|
|
5697
|
+
const matchedRuleName = decision.matchedRule?.name ?? null;
|
|
5698
|
+
const matchedRuleIndex = decision.matchedRule?.index ?? null;
|
|
5699
|
+
const responseBody = {
|
|
5700
|
+
evaluation_id: evaluationId,
|
|
5701
|
+
decision: wire,
|
|
5702
|
+
reason: decision.reason,
|
|
5703
|
+
matched_rule: matchedRuleName,
|
|
5704
|
+
matched_rule_index: matchedRuleIndex
|
|
5705
|
+
};
|
|
5706
|
+
if (isBlocking(wire)) {
|
|
5707
|
+
responseBody["feedback"] = buildFeedback(decision.matchedRule, decision.reason);
|
|
5708
|
+
}
|
|
5709
|
+
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
5710
|
+
if (wire === "dry_run") {
|
|
5711
|
+
responseBody["dry_run"] = {
|
|
5712
|
+
would_forward: decision.action === "allow" && !pipeline.evidenceBlocked,
|
|
5713
|
+
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
5714
|
+
limits_ok: true
|
|
5715
|
+
};
|
|
5716
|
+
}
|
|
5717
|
+
if (pipeline.driftEvent) {
|
|
5718
|
+
responseBody["tool_drift"] = { changes: pipeline.driftEvent.changes };
|
|
5719
|
+
}
|
|
5720
|
+
if (isTerminalAtEvaluate(wire)) {
|
|
5721
|
+
const auditId = this.writeAudit({
|
|
5722
|
+
timestampIso,
|
|
5723
|
+
origin: req.origin,
|
|
5724
|
+
agentId: req.agent_id,
|
|
5725
|
+
sessionId: req.session_id,
|
|
5726
|
+
toolName,
|
|
5727
|
+
toolInput: req.arguments ?? {},
|
|
5728
|
+
metadata: req.metadata,
|
|
5729
|
+
action: decision.action,
|
|
5730
|
+
wire,
|
|
5731
|
+
matchedRuleName,
|
|
5732
|
+
matchedRuleIndex,
|
|
5733
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5734
|
+
dryRun: wire === "dry_run",
|
|
5735
|
+
recordKind: "tool_call",
|
|
5736
|
+
limitsChain: limitsBlock
|
|
5737
|
+
});
|
|
5738
|
+
this.tombstones.set(evaluationId, {
|
|
5739
|
+
auditRecordId: auditId,
|
|
5740
|
+
payloadHash: null,
|
|
5741
|
+
finalizedBy: "evaluate",
|
|
5742
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5743
|
+
});
|
|
5744
|
+
return { status: 200, body: responseBody };
|
|
5745
|
+
}
|
|
5746
|
+
let approvalTicketId;
|
|
5747
|
+
let ticketTimeoutAtMs;
|
|
5748
|
+
if (wire === "require_approval") {
|
|
5749
|
+
const router = this.approvalRouter;
|
|
5750
|
+
if (!router) {
|
|
5751
|
+
throw new GovernanceConfigError(
|
|
5752
|
+
"[helio] invariant violation: require_approval decision without an approvalRouter"
|
|
5753
|
+
);
|
|
5754
|
+
}
|
|
5755
|
+
const timeoutMs = decision.matchedRule?.approval?.timeoutMs ?? this.approvalTimeoutMs;
|
|
5756
|
+
const ticket = router.createNativeTicket({
|
|
5757
|
+
tool_name: toolName,
|
|
5758
|
+
tool_input: req.arguments ?? {},
|
|
5759
|
+
matched_rule: decision.matchedRule,
|
|
5760
|
+
session_id: req.session_id,
|
|
5761
|
+
origin: req.origin,
|
|
5762
|
+
timeout_ms: timeoutMs
|
|
5763
|
+
});
|
|
5764
|
+
approvalTicketId = ticket.id;
|
|
5765
|
+
ticketTimeoutAtMs = this.now() + timeoutMs;
|
|
5766
|
+
responseBody["approval"] = {
|
|
5767
|
+
id: ticket.id,
|
|
5768
|
+
timeout_ms: timeoutMs,
|
|
5769
|
+
resolve_path: `/approval/${ticket.id}/resolve`
|
|
5770
|
+
};
|
|
5771
|
+
}
|
|
5772
|
+
const entry = {
|
|
5773
|
+
evaluationId,
|
|
5774
|
+
origin: req.origin,
|
|
5775
|
+
agentId: req.agent_id,
|
|
5776
|
+
sessionId: req.session_id,
|
|
5777
|
+
toolName,
|
|
5778
|
+
toolInput: req.arguments ?? {},
|
|
5779
|
+
metadata: req.metadata,
|
|
5780
|
+
action: decision.action,
|
|
5781
|
+
matchedRuleName,
|
|
5782
|
+
matchedRuleIndex,
|
|
5783
|
+
flaggedDestructive: pipeline.flaggedDestructive,
|
|
5784
|
+
limitPlan,
|
|
5785
|
+
approvalTicketId,
|
|
5786
|
+
timestampIso,
|
|
5787
|
+
createdAtMs: this.now(),
|
|
5788
|
+
evaluationExpiresAtMs: this.now() + this.ttlMs,
|
|
5789
|
+
ticketTimeoutAtMs,
|
|
5790
|
+
bytes: entryBytes
|
|
5791
|
+
};
|
|
5792
|
+
this.pending.set(evaluationId, entry);
|
|
5793
|
+
this.pendingBytes += entryBytes;
|
|
5794
|
+
if (approvalTicketId) this.ticketToEvaluation.set(approvalTicketId, evaluationId);
|
|
5795
|
+
return { status: 200, body: responseBody };
|
|
5796
|
+
}
|
|
5797
|
+
// -------------------------------------------------------------------------
|
|
5798
|
+
// POST /audit
|
|
5799
|
+
// -------------------------------------------------------------------------
|
|
5800
|
+
audit(req, payloadHash) {
|
|
5801
|
+
const id = req.evaluation_id;
|
|
5802
|
+
const tomb = this.tombstones.get(id);
|
|
5803
|
+
if (tomb) {
|
|
5804
|
+
if (tomb.finalizedBy === "expired") {
|
|
5805
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5806
|
+
}
|
|
5807
|
+
if (tomb.finalizedBy === "evaluate") {
|
|
5808
|
+
return {
|
|
5809
|
+
status: 200,
|
|
5810
|
+
body: {
|
|
5811
|
+
ok: true,
|
|
5812
|
+
audit_record_id: tomb.auditRecordId,
|
|
5813
|
+
already_finalized: true,
|
|
5814
|
+
finalized_by: "evaluate"
|
|
5815
|
+
}
|
|
5816
|
+
};
|
|
5817
|
+
}
|
|
5818
|
+
if (tomb.payloadHash === payloadHash) {
|
|
5819
|
+
return {
|
|
5820
|
+
status: 200,
|
|
5821
|
+
body: { ok: true, audit_record_id: tomb.auditRecordId, already_finalized: true }
|
|
5822
|
+
};
|
|
5823
|
+
}
|
|
5824
|
+
return { status: 409, body: { error: "evaluation_conflict" } };
|
|
5825
|
+
}
|
|
5826
|
+
const entry = this.pending.get(id);
|
|
5827
|
+
if (!entry) {
|
|
5828
|
+
return { status: 404, body: { error: "evaluation_unknown" } };
|
|
5829
|
+
}
|
|
5830
|
+
if (this.enforceDeadlines(entry) === "expired") {
|
|
5831
|
+
return { status: 404, body: { error: "evaluation_expired" } };
|
|
5832
|
+
}
|
|
5833
|
+
let approvalStatus = null;
|
|
5834
|
+
let approvedBy = null;
|
|
5835
|
+
if (entry.approvalTicketId) {
|
|
5836
|
+
const ticket = this.getTicketStatus(entry.approvalTicketId);
|
|
5837
|
+
const status = ticket?.status;
|
|
5838
|
+
if (!status || status === "pending") {
|
|
5839
|
+
return { status: 409, body: { error: "approval_unresolved" } };
|
|
5840
|
+
}
|
|
5841
|
+
approvalStatus = status;
|
|
5842
|
+
approvedBy = ticket.resolved_by ?? null;
|
|
5843
|
+
}
|
|
5844
|
+
if (req.actual_amount !== void 0) {
|
|
5845
|
+
if (!Number.isFinite(req.actual_amount) || req.actual_amount < 0) {
|
|
5846
|
+
return { status: 400, body: { error: "invalid_actual_amount" } };
|
|
5847
|
+
}
|
|
5848
|
+
if (entry.limitPlan?.kind !== "spend") {
|
|
5849
|
+
return { status: 400, body: { error: "no_spend_rule" } };
|
|
5850
|
+
}
|
|
5851
|
+
}
|
|
5852
|
+
const callHappened = req.status === "success" || req.status === "error";
|
|
5853
|
+
let limitsChain;
|
|
5854
|
+
if (callHappened && entry.limitPlan) {
|
|
5855
|
+
limitsChain = this.commitLimit(entry.limitPlan, req.actual_amount);
|
|
5856
|
+
}
|
|
5857
|
+
if (callHappened && this.evidenceStore && entry.sessionId) {
|
|
5858
|
+
this.evidenceStore.recordToolCall(entry.sessionId, entry.toolName, req.status === "success");
|
|
5859
|
+
}
|
|
5860
|
+
const evidenceOutcomes = this.populateEvidence(req, entry);
|
|
5861
|
+
const auditId = this.writeAudit({
|
|
5862
|
+
timestampIso: entry.timestampIso,
|
|
5863
|
+
origin: entry.origin,
|
|
5864
|
+
agentId: entry.agentId,
|
|
5865
|
+
sessionId: entry.sessionId,
|
|
5866
|
+
toolName: entry.toolName,
|
|
5867
|
+
toolInput: entry.toolInput,
|
|
5868
|
+
metadata: entry.metadata,
|
|
5869
|
+
action: entry.action,
|
|
5870
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
5871
|
+
matchedRuleName: entry.matchedRuleName,
|
|
5872
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
5873
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
5874
|
+
dryRun: false,
|
|
5875
|
+
recordKind: "tool_call",
|
|
5876
|
+
limitsChain,
|
|
5877
|
+
approvalStatus,
|
|
5878
|
+
approvedBy,
|
|
5879
|
+
upstreamError: req.status === "error" ? req.error ?? "tool call failed" : null,
|
|
5880
|
+
upstreamResponse: req.result ?? null,
|
|
5881
|
+
upstreamLatencyMs: req.duration_ms ?? null
|
|
5882
|
+
});
|
|
5883
|
+
this.discardPending(entry);
|
|
5884
|
+
this.tombstones.set(id, {
|
|
5885
|
+
auditRecordId: auditId,
|
|
5886
|
+
payloadHash,
|
|
5887
|
+
finalizedBy: "audit",
|
|
5888
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5889
|
+
});
|
|
5890
|
+
const body = { ok: true, audit_record_id: auditId };
|
|
5891
|
+
if (evidenceOutcomes) body["evidence"] = evidenceOutcomes;
|
|
5892
|
+
return { status: 201, body };
|
|
5893
|
+
}
|
|
5894
|
+
/**
|
|
5895
|
+
* Write the optional `/audit` evidence entries for a successful call
|
|
5896
|
+
* (issue #11), returning a per-entry outcome list — or `undefined`
|
|
5897
|
+
* when there is nothing to report (non-success status, or no evidence
|
|
5898
|
+
* supplied). Caps are enforced here, NOT in route validation, so an over-cap
|
|
5899
|
+
* entry soft-drops without discarding the audit row: entries past
|
|
5900
|
+
* `MAX_EVIDENCE_ENTRIES` → `too_many`; oversized `evidence_data` →
|
|
5901
|
+
* `too_large`; no evidence store on the service → `evidence_unavailable`;
|
|
5902
|
+
* a sessionless evaluation → `no_session`; the store's own rejections
|
|
5903
|
+
* (`key_not_in_policy_allowlist`, `closed`) pass through as the per-entry
|
|
5904
|
+
* reason. None of these fail the audit.
|
|
5905
|
+
*/
|
|
5906
|
+
populateEvidence(req, entry) {
|
|
5907
|
+
if (req.status !== "success" || !req.evidence || req.evidence.length === 0) {
|
|
5908
|
+
return void 0;
|
|
5909
|
+
}
|
|
5910
|
+
const outcomes = [];
|
|
5911
|
+
for (let i = 0; i < req.evidence.length; i++) {
|
|
5912
|
+
const e = req.evidence[i];
|
|
5913
|
+
if (!e) continue;
|
|
5914
|
+
if (i >= MAX_EVIDENCE_ENTRIES) {
|
|
5915
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_many" });
|
|
5916
|
+
continue;
|
|
5917
|
+
}
|
|
5918
|
+
const bytes = Buffer.byteLength(canonicalize(e.evidence_data ?? null), "utf8");
|
|
5919
|
+
if (bytes > MAX_EVIDENCE_BYTES) {
|
|
5920
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "too_large" });
|
|
5921
|
+
continue;
|
|
5922
|
+
}
|
|
5923
|
+
if (!this.evidenceStore) {
|
|
5924
|
+
outcomes.push({
|
|
5925
|
+
evidence_key: e.evidence_key,
|
|
5926
|
+
stored: false,
|
|
5927
|
+
reason: "evidence_unavailable"
|
|
5928
|
+
});
|
|
5929
|
+
continue;
|
|
5930
|
+
}
|
|
5931
|
+
if (!entry.sessionId) {
|
|
5932
|
+
outcomes.push({ evidence_key: e.evidence_key, stored: false, reason: "no_session" });
|
|
5933
|
+
continue;
|
|
5934
|
+
}
|
|
5935
|
+
const result = this.evidenceStore.putEvidence(entry.sessionId, {
|
|
5936
|
+
evidence_key: e.evidence_key,
|
|
5937
|
+
data: e.evidence_data,
|
|
5938
|
+
tool_name: entry.toolName,
|
|
5939
|
+
ttl_seconds: e.ttl_seconds
|
|
5940
|
+
});
|
|
5941
|
+
outcomes.push(
|
|
5942
|
+
result.stored ? { evidence_key: e.evidence_key, stored: true } : { evidence_key: e.evidence_key, stored: false, reason: result.reason }
|
|
5943
|
+
);
|
|
5944
|
+
}
|
|
5945
|
+
return outcomes;
|
|
5946
|
+
}
|
|
5947
|
+
// -------------------------------------------------------------------------
|
|
5948
|
+
// POST /install-scan — evaluates install-time policy (issue #13)
|
|
5949
|
+
// -------------------------------------------------------------------------
|
|
5950
|
+
installScan(req) {
|
|
5951
|
+
const reserved = reservedMetadataKey(req.metadata);
|
|
5952
|
+
if (reserved) {
|
|
5953
|
+
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
5954
|
+
}
|
|
5955
|
+
const evaluationId = randomUUID4();
|
|
5956
|
+
const toolName = `install:${req.package.source ?? "pkg"}:${req.package.name}`;
|
|
5957
|
+
const verdict = this.evaluateInstall(req);
|
|
5958
|
+
const denied = verdict.decision === "deny";
|
|
5959
|
+
const auditId = this.writeAudit({
|
|
5960
|
+
timestampIso: new Date(this.now()).toISOString(),
|
|
5961
|
+
origin: req.origin,
|
|
5962
|
+
agentId: req.agent_id,
|
|
5963
|
+
sessionId: req.session_id,
|
|
5964
|
+
toolName,
|
|
5965
|
+
toolInput: { ...req.package },
|
|
5966
|
+
metadata: req.metadata,
|
|
5967
|
+
// policy_decision is 'deny' (NOT 'deny_install') so the dashboard renders a
|
|
5968
|
+
// blocked install as a block, not an allow. The install context lives in
|
|
5969
|
+
// record_kind + block_reason.
|
|
5970
|
+
action: denied ? "deny" : "allow",
|
|
5971
|
+
wire: denied ? "deny" : "allow",
|
|
5972
|
+
matchedRuleName: verdict.matchedRule?.name ?? null,
|
|
5973
|
+
matchedRuleIndex: verdict.matchedRule?.index ?? null,
|
|
5974
|
+
flaggedDestructive: false,
|
|
5975
|
+
dryRun: false,
|
|
5976
|
+
recordKind: "install_scan"
|
|
5977
|
+
});
|
|
5978
|
+
this.tombstones.set(evaluationId, {
|
|
5979
|
+
auditRecordId: auditId,
|
|
5980
|
+
payloadHash: null,
|
|
5981
|
+
finalizedBy: "evaluate",
|
|
5982
|
+
expiresAtMs: this.now() + this.ttlMs
|
|
5983
|
+
});
|
|
5984
|
+
const body = {
|
|
5985
|
+
evaluation_id: evaluationId,
|
|
5986
|
+
decision: verdict.decision,
|
|
5987
|
+
reason: verdict.reason,
|
|
5988
|
+
matched_rule: verdict.matchedRule?.name ?? null,
|
|
5989
|
+
matched_rule_index: verdict.matchedRule?.index ?? null
|
|
5990
|
+
};
|
|
5991
|
+
if (denied) {
|
|
5992
|
+
body["feedback"] = buildFeedback(verdict.matchedRule, verdict.reason);
|
|
5993
|
+
}
|
|
5994
|
+
return { status: 200, body };
|
|
5995
|
+
}
|
|
5996
|
+
/** First-match-wins evaluation of the compiled install policy (issue #13). */
|
|
5997
|
+
evaluateInstall(req) {
|
|
5998
|
+
const install = this.policy.install;
|
|
5999
|
+
if (!install) {
|
|
6000
|
+
return { decision: "allow", reason: "no install-time rules defined" };
|
|
6001
|
+
}
|
|
6002
|
+
const metadataView = req.agent_id != null ? { ...req.metadata ?? {}, agent_id: req.agent_id } : req.metadata ?? void 0;
|
|
6003
|
+
for (const rule of install.rules) {
|
|
6004
|
+
if (matchInstallRule(rule, req.package, metadataView)) {
|
|
6005
|
+
const label = rule.name ? `"${rule.name}"` : `install_rule[${String(rule.index)}]`;
|
|
6006
|
+
return {
|
|
6007
|
+
decision: rule.action === "deny_install" ? "deny" : "allow",
|
|
6008
|
+
matchedRule: rule,
|
|
6009
|
+
reason: `Matched ${label} \u2192 ${rule.action}`
|
|
6010
|
+
};
|
|
6011
|
+
}
|
|
6012
|
+
}
|
|
6013
|
+
return {
|
|
6014
|
+
decision: install.defaultAction,
|
|
6015
|
+
reason: `No matching install rule; default ${install.defaultAction}`
|
|
6016
|
+
};
|
|
6017
|
+
}
|
|
6018
|
+
// -------------------------------------------------------------------------
|
|
6019
|
+
// POST /approval/:id/resolve
|
|
6020
|
+
// -------------------------------------------------------------------------
|
|
6021
|
+
resolveApproval(ticketId, req) {
|
|
6022
|
+
if (!this.approvalRouter) {
|
|
6023
|
+
return { status: 503, body: { error: "governance_unavailable" } };
|
|
6024
|
+
}
|
|
6025
|
+
const ticket = this.getTicketStatus(ticketId);
|
|
6026
|
+
if (!ticket) {
|
|
6027
|
+
return { status: 404, body: { error: "ticket_not_found" } };
|
|
6028
|
+
}
|
|
6029
|
+
if (!ticket.channel_name.startsWith("native:")) {
|
|
6030
|
+
return { status: 409, body: { error: "not_a_native_ticket" } };
|
|
6031
|
+
}
|
|
6032
|
+
const evaluationId = this.ticketToEvaluation.get(ticketId);
|
|
6033
|
+
const entry = evaluationId ? this.pending.get(evaluationId) : void 0;
|
|
6034
|
+
if (entry) this.enforceDeadlines(entry);
|
|
6035
|
+
const current = this.getTicketStatus(ticketId);
|
|
6036
|
+
if (!current || current.status !== "pending") {
|
|
6037
|
+
return { status: 409, body: { error: "already_resolved", status: current?.status } };
|
|
6038
|
+
}
|
|
6039
|
+
const resolved = this.approvalRouter.resolveNativeTicket(
|
|
6040
|
+
ticketId,
|
|
6041
|
+
req.resolution,
|
|
6042
|
+
req.resolved_by,
|
|
6043
|
+
{ denial_reason: req.resolution === "denied" ? req.reason : void 0 }
|
|
6044
|
+
);
|
|
6045
|
+
if (!resolved) {
|
|
6046
|
+
return { status: 409, body: { error: "already_resolved" } };
|
|
6047
|
+
}
|
|
6048
|
+
return { status: 200, body: { ok: true } };
|
|
6049
|
+
}
|
|
6050
|
+
// -------------------------------------------------------------------------
|
|
6051
|
+
// Sweep — GC backstop for callers that never return
|
|
6052
|
+
// -------------------------------------------------------------------------
|
|
6053
|
+
sweep() {
|
|
6054
|
+
for (const entry of [...this.pending.values()]) {
|
|
6055
|
+
this.enforceDeadlines(entry);
|
|
6056
|
+
}
|
|
6057
|
+
const now = this.now();
|
|
6058
|
+
for (const [id, tomb] of this.tombstones) {
|
|
6059
|
+
if (tomb.expiresAtMs <= now) this.tombstones.delete(id);
|
|
6060
|
+
}
|
|
6061
|
+
this.pruneSenderKeys();
|
|
6062
|
+
}
|
|
6063
|
+
/**
|
|
6064
|
+
* Reserve a cardinality slot for a sender-keyed limit (issue #13).
|
|
6065
|
+
*
|
|
6066
|
+
* Only `sender:*` keys are gated — tool/session families are bounded by upstream
|
|
6067
|
+
* cardinality, and the MCP path never reaches here, so structural traffic cannot
|
|
6068
|
+
* be starved. A key already backed by live state (registry or a live limiter
|
|
6069
|
+
* bucket) costs no new slot. At capacity we lazily prune dead keys before failing
|
|
6070
|
+
* closed, so an emptied bucket frees its slot without waiting for the sweep.
|
|
6071
|
+
*/
|
|
6072
|
+
reserveSenderKey(key) {
|
|
6073
|
+
if (!key.startsWith("sender:")) return true;
|
|
6074
|
+
if (this.senderKeys.has(key)) return true;
|
|
6075
|
+
if (this.hasLiveBucket(key)) {
|
|
6076
|
+
this.senderKeys.add(key);
|
|
6077
|
+
return true;
|
|
6078
|
+
}
|
|
6079
|
+
if (this.senderKeys.size >= this.maxSenderKeys) {
|
|
6080
|
+
this.pruneSenderKeys();
|
|
6081
|
+
if (this.senderKeys.size >= this.maxSenderKeys) return false;
|
|
6082
|
+
}
|
|
6083
|
+
this.senderKeys.add(key);
|
|
6084
|
+
return true;
|
|
6085
|
+
}
|
|
6086
|
+
/** Drop registry keys with no pending evaluation AND no live limiter bucket. */
|
|
6087
|
+
pruneSenderKeys() {
|
|
6088
|
+
if (this.senderKeys.size === 0) return;
|
|
6089
|
+
const inUse = /* @__PURE__ */ new Set();
|
|
6090
|
+
for (const entry of this.pending.values()) {
|
|
6091
|
+
if (entry.limitPlan && entry.limitPlan.key.startsWith("sender:")) {
|
|
6092
|
+
inUse.add(entry.limitPlan.key);
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
for (const key of this.senderKeys) {
|
|
6096
|
+
if (inUse.has(key)) continue;
|
|
6097
|
+
if (this.hasLiveBucket(key)) continue;
|
|
6098
|
+
this.senderKeys.delete(key);
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* Whether either limiter still holds a live bucket for `key`. Uses the public
|
|
6103
|
+
* `getKeyState()` — never the limiters' private maps — and its lazy eviction of
|
|
6104
|
+
* an emptied bucket IS the prune-on-touch mechanism.
|
|
6105
|
+
*/
|
|
6106
|
+
hasLiveBucket(key) {
|
|
6107
|
+
return this.rateLimiter?.getKeyState(key) !== void 0 || this.spendLimiter?.getKeyState(key) !== void 0;
|
|
6108
|
+
}
|
|
6109
|
+
close() {
|
|
6110
|
+
if (this.closed) return;
|
|
6111
|
+
this.closed = true;
|
|
6112
|
+
if (this.sweepTimer) {
|
|
6113
|
+
clearInterval(this.sweepTimer);
|
|
6114
|
+
this.sweepTimer = null;
|
|
6115
|
+
}
|
|
6116
|
+
this.pending.clear();
|
|
6117
|
+
this.tombstones.clear();
|
|
6118
|
+
this.caches.clear();
|
|
6119
|
+
this.senderKeys.clear();
|
|
6120
|
+
this.pendingBytes = 0;
|
|
6121
|
+
}
|
|
6122
|
+
// -------------------------------------------------------------------------
|
|
6123
|
+
// Internals
|
|
6124
|
+
// -------------------------------------------------------------------------
|
|
6125
|
+
/** Apply crossed deadlines to one pending entry. Returns its post-state. */
|
|
6126
|
+
enforceDeadlines(entry) {
|
|
6127
|
+
const now = this.now();
|
|
6128
|
+
if (now >= entry.evaluationExpiresAtMs) {
|
|
6129
|
+
if (entry.approvalTicketId) {
|
|
6130
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
6131
|
+
}
|
|
6132
|
+
const auditId = this.writeAudit({
|
|
6133
|
+
timestampIso: entry.timestampIso,
|
|
6134
|
+
origin: entry.origin,
|
|
6135
|
+
agentId: entry.agentId,
|
|
6136
|
+
sessionId: entry.sessionId,
|
|
6137
|
+
toolName: entry.toolName,
|
|
6138
|
+
toolInput: entry.toolInput,
|
|
6139
|
+
metadata: entry.metadata,
|
|
6140
|
+
action: entry.action,
|
|
6141
|
+
wire: entry.action === "require_approval" ? "require_approval" : "allow",
|
|
6142
|
+
matchedRuleName: entry.matchedRuleName,
|
|
6143
|
+
matchedRuleIndex: entry.matchedRuleIndex,
|
|
6144
|
+
flaggedDestructive: entry.flaggedDestructive,
|
|
6145
|
+
dryRun: false,
|
|
6146
|
+
recordKind: "evaluation_expired",
|
|
6147
|
+
sidebandUnreported: true
|
|
6148
|
+
});
|
|
6149
|
+
this.discardPending(entry);
|
|
6150
|
+
this.tombstones.set(entry.evaluationId, {
|
|
6151
|
+
auditRecordId: auditId,
|
|
6152
|
+
payloadHash: null,
|
|
6153
|
+
finalizedBy: "expired",
|
|
6154
|
+
expiresAtMs: now + this.ttlMs
|
|
6155
|
+
});
|
|
6156
|
+
console.error(
|
|
6157
|
+
`[helio] Sideband evaluation ${entry.evaluationId} expired without /audit (origin=${entry.origin}, tool=${entry.toolName}) \u2014 recorded as evaluation_expired`
|
|
6158
|
+
);
|
|
6159
|
+
return "expired";
|
|
6160
|
+
}
|
|
6161
|
+
if (entry.approvalTicketId && entry.ticketTimeoutAtMs !== void 0 && now >= entry.ticketTimeoutAtMs) {
|
|
6162
|
+
this.approvalRouter?.resolveNativeTicket(entry.approvalTicketId, "timeout");
|
|
6163
|
+
}
|
|
6164
|
+
return "active";
|
|
6165
|
+
}
|
|
6166
|
+
cacheFor(origin) {
|
|
6167
|
+
let cache = this.caches.get(origin);
|
|
6168
|
+
if (!cache) {
|
|
6169
|
+
cache = new ToolAnnotationCache();
|
|
6170
|
+
this.caches.set(origin, cache);
|
|
6171
|
+
}
|
|
6172
|
+
return cache;
|
|
6173
|
+
}
|
|
6174
|
+
discardPending(entry) {
|
|
6175
|
+
if (this.pending.delete(entry.evaluationId)) {
|
|
6176
|
+
this.pendingBytes -= entry.bytes;
|
|
6177
|
+
}
|
|
6178
|
+
if (entry.approvalTicketId) this.ticketToEvaluation.delete(entry.approvalTicketId);
|
|
6179
|
+
}
|
|
6180
|
+
getTicketStatus(ticketId) {
|
|
6181
|
+
return this.approvalRouter?.getTicket(ticketId);
|
|
6182
|
+
}
|
|
6183
|
+
planRate(decision, toolName, sessionId, senderId) {
|
|
6184
|
+
const limits = decision.matchedRule?.limits;
|
|
6185
|
+
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
6186
|
+
return { allowed: true };
|
|
6187
|
+
}
|
|
6188
|
+
const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
|
|
6189
|
+
const peek = this.rateLimiter.peek({
|
|
6190
|
+
key,
|
|
6191
|
+
maxCalls: limits.maxCalls,
|
|
6192
|
+
windowMs: limits.windowMs
|
|
6193
|
+
});
|
|
6194
|
+
return {
|
|
6195
|
+
plan: { kind: "rate", key, limits },
|
|
6196
|
+
block: {
|
|
6197
|
+
current: peek.current,
|
|
6198
|
+
limit: peek.limit,
|
|
6199
|
+
window_ms: peek.windowMs,
|
|
6200
|
+
reset_at_ms: peek.resetAtMs
|
|
6201
|
+
},
|
|
6202
|
+
allowed: peek.allowed
|
|
6203
|
+
};
|
|
6204
|
+
}
|
|
6205
|
+
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
6206
|
+
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
6207
|
+
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
6208
|
+
const key = buildLimitKey(maxSpend.key, toolName, sessionId, senderId);
|
|
6209
|
+
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
6210
|
+
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
6211
|
+
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
6212
|
+
}
|
|
6213
|
+
const peek = this.spendLimiter.peek({
|
|
6214
|
+
key,
|
|
6215
|
+
amount: rawAmount,
|
|
6216
|
+
limit: maxSpend.limit,
|
|
6217
|
+
windowMs: maxSpend.windowMs
|
|
6218
|
+
});
|
|
6219
|
+
return {
|
|
6220
|
+
plan: {
|
|
6221
|
+
kind: "spend",
|
|
6222
|
+
key,
|
|
6223
|
+
limits: decision.matchedRule.limits,
|
|
6224
|
+
amount: rawAmount,
|
|
6225
|
+
currency: maxSpend.currency
|
|
6226
|
+
},
|
|
6227
|
+
block: {
|
|
6228
|
+
current_spend: peek.currentSpend,
|
|
6229
|
+
limit: peek.limit,
|
|
6230
|
+
currency: maxSpend.currency,
|
|
6231
|
+
window_ms: peek.windowMs,
|
|
6232
|
+
reset_at_ms: peek.resetAtMs
|
|
6233
|
+
},
|
|
6234
|
+
allowed: peek.allowed
|
|
6235
|
+
};
|
|
6236
|
+
}
|
|
6237
|
+
/** Commit a limit plan at /audit time and return the evidence_chain block. */
|
|
6238
|
+
commitLimit(plan, actualAmount) {
|
|
6239
|
+
if (plan.kind === "rate" && this.rateLimiter && plan.limits.maxCalls && plan.limits.windowMs) {
|
|
6240
|
+
const r = this.rateLimiter.record({
|
|
6241
|
+
key: plan.key,
|
|
6242
|
+
maxCalls: plan.limits.maxCalls,
|
|
6243
|
+
windowMs: plan.limits.windowMs
|
|
6244
|
+
});
|
|
6245
|
+
return {
|
|
6246
|
+
rate_limit: {
|
|
6247
|
+
allowed: r.allowed,
|
|
6248
|
+
current: r.current,
|
|
6249
|
+
limit: r.limit,
|
|
6250
|
+
window_ms: r.windowMs,
|
|
6251
|
+
reset_at_ms: r.resetAtMs
|
|
6252
|
+
}
|
|
6253
|
+
};
|
|
6254
|
+
}
|
|
6255
|
+
if (plan.kind === "spend" && this.spendLimiter && plan.limits.maxSpend) {
|
|
6256
|
+
const amount = actualAmount ?? plan.amount ?? 0;
|
|
6257
|
+
const r = this.spendLimiter.record({
|
|
6258
|
+
key: plan.key,
|
|
6259
|
+
amount,
|
|
6260
|
+
limit: plan.limits.maxSpend.limit,
|
|
6261
|
+
windowMs: plan.limits.maxSpend.windowMs
|
|
6262
|
+
});
|
|
6263
|
+
this.spendLimiter.setCurrency(plan.key, plan.limits.maxSpend.currency);
|
|
6264
|
+
return {
|
|
6265
|
+
spend_limit: {
|
|
6266
|
+
allowed: r.allowed,
|
|
6267
|
+
current_spend: r.currentSpend,
|
|
6268
|
+
limit: r.limit,
|
|
6269
|
+
window_ms: r.windowMs,
|
|
6270
|
+
reset_at_ms: r.resetAtMs
|
|
6271
|
+
}
|
|
6272
|
+
};
|
|
6273
|
+
}
|
|
6274
|
+
return void 0;
|
|
6275
|
+
}
|
|
6276
|
+
writeAudit(args) {
|
|
6277
|
+
const id = randomUUID4();
|
|
6278
|
+
if (!this.auditWriter) return id;
|
|
6279
|
+
const blockReason = deriveBlockReason(args);
|
|
6280
|
+
let evidenceChain = args.limitsChain ?? null;
|
|
6281
|
+
if (args.sidebandUnreported) {
|
|
6282
|
+
evidenceChain = { ...evidenceChain ?? {}, sideband: { unreported: true } };
|
|
6283
|
+
}
|
|
6284
|
+
const record = {
|
|
6285
|
+
timestamp: args.timestampIso,
|
|
6286
|
+
session_id: args.sessionId,
|
|
6287
|
+
agent_id: args.agentId,
|
|
6288
|
+
environment: this.environment ?? null,
|
|
6289
|
+
tool_name: args.toolName,
|
|
6290
|
+
tool_input: args.toolInput,
|
|
6291
|
+
policy_decision: args.action,
|
|
6292
|
+
block_reason: blockReason,
|
|
6293
|
+
matched_rule: args.matchedRuleName,
|
|
6294
|
+
matched_rule_index: args.matchedRuleIndex,
|
|
6295
|
+
evidence_chain: evidenceChain,
|
|
6296
|
+
approval_status: args.approvalStatus ?? null,
|
|
6297
|
+
approved_by: args.approvedBy ?? null,
|
|
6298
|
+
upstream_response: args.upstreamResponse ?? null,
|
|
6299
|
+
upstream_error: args.upstreamError ?? null,
|
|
6300
|
+
upstream_http_status: null,
|
|
6301
|
+
upstream_latency_ms: args.upstreamLatencyMs ?? null,
|
|
6302
|
+
total_duration_ms: 0,
|
|
6303
|
+
approval_wait_ms: 0,
|
|
6304
|
+
proxy_compute_ms: 0,
|
|
6305
|
+
flagged_destructive: args.flaggedDestructive,
|
|
6306
|
+
dry_run: args.dryRun,
|
|
6307
|
+
record_kind: args.recordKind,
|
|
6308
|
+
origin: args.origin,
|
|
6309
|
+
metadata: args.metadata
|
|
6310
|
+
};
|
|
6311
|
+
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
6312
|
+
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
6313
|
+
else this.auditWriter.push(record, id);
|
|
6314
|
+
return id;
|
|
6315
|
+
}
|
|
6316
|
+
assertApprovalRouter(policy) {
|
|
6317
|
+
if (!policyCanRequireApproval(policy) || this.approvalRouter) return;
|
|
6318
|
+
throw new GovernanceConfigError(
|
|
6319
|
+
"[helio] GovernanceService misconfiguration: approval-capable policy (a require_approval rule, or flag_destructive/on_tool_drift set to require_approval) requires an approvalRouter"
|
|
6320
|
+
);
|
|
6321
|
+
}
|
|
6322
|
+
};
|
|
6323
|
+
function deriveBlockReason(args) {
|
|
6324
|
+
if (args.recordKind === "evaluation_expired") return null;
|
|
6325
|
+
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
6326
|
+
if (args.dryRun) return null;
|
|
6327
|
+
if (args.approvalStatus === "denied") return "approval_denied";
|
|
6328
|
+
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
6329
|
+
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
6330
|
+
switch (args.wire) {
|
|
6331
|
+
case "deny":
|
|
6332
|
+
return "policy_denied";
|
|
6333
|
+
case "rate_limited":
|
|
6334
|
+
return "rate_limited";
|
|
6335
|
+
case "spend_limited":
|
|
6336
|
+
return "spend_limited";
|
|
6337
|
+
default:
|
|
6338
|
+
return null;
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
function buildFeedback(rule, reason) {
|
|
6342
|
+
const message = rule?.feedback?.message ?? reason;
|
|
6343
|
+
const suggestion = rule?.feedback?.suggestion;
|
|
6344
|
+
return suggestion ? { message, suggestion } : { message };
|
|
6345
|
+
}
|
|
6346
|
+
function isBlocking(wire) {
|
|
6347
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited";
|
|
6348
|
+
}
|
|
6349
|
+
function isTerminalAtEvaluate(wire) {
|
|
6350
|
+
return wire === "deny" || wire === "rate_limited" || wire === "spend_limited" || wire === "dry_run";
|
|
6351
|
+
}
|
|
6352
|
+
function policyCanRequireApproval(policy) {
|
|
6353
|
+
if (policy.flagDestructive === "require_approval" || policy.onToolDrift === "require_approval") {
|
|
6354
|
+
return true;
|
|
6355
|
+
}
|
|
6356
|
+
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
6357
|
+
}
|
|
6358
|
+
function buildLimitKey(keyType, toolName, sessionId, senderId) {
|
|
6359
|
+
switch (keyType) {
|
|
6360
|
+
case "session":
|
|
6361
|
+
return `session:${sessionId ?? "unknown"}`;
|
|
6362
|
+
case "sender_id":
|
|
6363
|
+
return `sender:${senderId ?? "unknown"}`;
|
|
6364
|
+
case "agent":
|
|
6365
|
+
case "tool":
|
|
6366
|
+
default:
|
|
6367
|
+
return `tool:${toolName}`;
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
function senderIdOf(metadata) {
|
|
6371
|
+
const v = metadata?.["sender_id"];
|
|
6372
|
+
return typeof v === "string" ? v : null;
|
|
6373
|
+
}
|
|
6374
|
+
function matchInstallRule(rule, pkg2, metadataView) {
|
|
6375
|
+
if (rule.match.name && !rule.match.name.test(pkg2.name)) return false;
|
|
6376
|
+
if (rule.match.source !== void 0 && rule.match.source !== pkg2.source) return false;
|
|
6377
|
+
if (rule.match.metadata && !matchMetadata(rule.match.metadata, { metadata: metadataView })) {
|
|
6378
|
+
return false;
|
|
6379
|
+
}
|
|
6380
|
+
return true;
|
|
6381
|
+
}
|
|
6382
|
+
function reservedMetadataKey(metadata) {
|
|
6383
|
+
if (metadata && Object.prototype.hasOwnProperty.call(metadata, "agent_id")) {
|
|
6384
|
+
return "agent_id";
|
|
6385
|
+
}
|
|
6386
|
+
return null;
|
|
6387
|
+
}
|
|
6388
|
+
function definitionProvided(tool) {
|
|
6389
|
+
return tool.description !== void 0 || tool.input_schema !== void 0 || tool.output_schema !== void 0 || tool.title !== void 0 || tool.annotations !== void 0;
|
|
6390
|
+
}
|
|
6391
|
+
function toMcpToolDef(tool) {
|
|
6392
|
+
const def = { name: tool.name };
|
|
6393
|
+
if (tool.description !== void 0) def["description"] = tool.description;
|
|
6394
|
+
if (tool.input_schema !== void 0) def["inputSchema"] = tool.input_schema;
|
|
6395
|
+
if (tool.output_schema !== void 0) def["outputSchema"] = tool.output_schema;
|
|
6396
|
+
if (tool.title !== void 0) def["title"] = tool.title;
|
|
6397
|
+
if (tool.annotations !== void 0) def["annotations"] = tool.annotations;
|
|
6398
|
+
return def;
|
|
6399
|
+
}
|
|
6400
|
+
function byteLength(value) {
|
|
6401
|
+
return Buffer.byteLength(canonicalize(value), "utf8");
|
|
6402
|
+
}
|
|
6403
|
+
|
|
6404
|
+
// src/approval/queue.ts
|
|
6405
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
4986
6406
|
var ApprovalQueue = class {
|
|
4987
6407
|
tickets = /* @__PURE__ */ new Map();
|
|
4988
6408
|
now;
|
|
@@ -5012,7 +6432,7 @@ var ApprovalQueue = class {
|
|
|
5012
6432
|
if (this.closed) throw new Error("ApprovalQueue is closed");
|
|
5013
6433
|
const now = this.now();
|
|
5014
6434
|
const ticket = {
|
|
5015
|
-
id:
|
|
6435
|
+
id: randomUUID5(),
|
|
5016
6436
|
tool_name: params.tool_name,
|
|
5017
6437
|
tool_input: params.tool_input,
|
|
5018
6438
|
matched_rule: params.matched_rule,
|
|
@@ -5091,6 +6511,7 @@ var ApprovalQueue = class {
|
|
|
5091
6511
|
};
|
|
5092
6512
|
|
|
5093
6513
|
// src/approval/router.ts
|
|
6514
|
+
var NATIVE_CHANNEL_PREFIX = "native:";
|
|
5094
6515
|
var ApprovalRouter = class {
|
|
5095
6516
|
defaultTimeoutMs;
|
|
5096
6517
|
defaultOnTimeout;
|
|
@@ -5203,6 +6624,59 @@ var ApprovalRouter = class {
|
|
|
5203
6624
|
});
|
|
5204
6625
|
return outcome;
|
|
5205
6626
|
}
|
|
6627
|
+
/**
|
|
6628
|
+
* Create a native (adapter-owned) approval ticket without holding a Promise.
|
|
6629
|
+
*
|
|
6630
|
+
* Used by the sideband governance path (issue #12, D10): when `/evaluate`
|
|
6631
|
+
* yields `require_approval`, the adapter runs the approval in its own UI
|
|
6632
|
+
* (e.g. OpenClaw's Telegram dialog), so Helio must NOT block, start
|
|
6633
|
+
* timeout/escalation timers, or notify a channel — doing so would
|
|
6634
|
+
* double-notify. We still create the queue ticket and fire `onSubmit` so the
|
|
6635
|
+
* dashboard's `approval_requested` SSE event flows and the ticket is visible.
|
|
6636
|
+
*
|
|
6637
|
+
* The ticket's `channel_name` is `native:<origin>`, which marks it as
|
|
6638
|
+
* adapter-owned: the dashboard approve/deny endpoints refuse it (it can only
|
|
6639
|
+
* be resolved through the adapter), and {@link resolveNativeTicket} is the
|
|
6640
|
+
* resolution path.
|
|
6641
|
+
*/
|
|
6642
|
+
createNativeTicket(params) {
|
|
6643
|
+
const rule = params.matched_rule;
|
|
6644
|
+
const timeoutMs = params.timeout_ms ?? rule?.approval?.timeoutMs ?? this.defaultTimeoutMs;
|
|
6645
|
+
const ticket = this.queue.add({
|
|
6646
|
+
tool_name: params.tool_name,
|
|
6647
|
+
tool_input: params.tool_input,
|
|
6648
|
+
matched_rule: rule?.name ?? null,
|
|
6649
|
+
rule_index: rule?.index ?? null,
|
|
6650
|
+
channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
|
|
6651
|
+
session_id: params.session_id,
|
|
6652
|
+
timeout_ms: timeoutMs
|
|
6653
|
+
});
|
|
6654
|
+
this.onSubmit?.(ticket);
|
|
6655
|
+
return ticket;
|
|
6656
|
+
}
|
|
6657
|
+
/**
|
|
6658
|
+
* Resolve a native ticket created by {@link createNativeTicket}.
|
|
6659
|
+
*
|
|
6660
|
+
* Resolves the queue ticket and fires `onResolve` (→ `approval_resolved`
|
|
6661
|
+
* SSE), with no held Promise to settle. Refuses tickets that have a pending
|
|
6662
|
+
* router Promise (those are MCP-path tickets; resolving them here would leave
|
|
6663
|
+
* the held request hanging) and tickets that are not `native:`-prefixed.
|
|
6664
|
+
*
|
|
6665
|
+
* @returns `true` if resolved, `false` if not found, already resolved, not a
|
|
6666
|
+
* native ticket, or router-managed.
|
|
6667
|
+
*/
|
|
6668
|
+
resolveNativeTicket(ticketId, status, resolvedBy, options) {
|
|
6669
|
+
if (this.pending.has(ticketId)) return false;
|
|
6670
|
+
const ticket = this.queue.get(ticketId);
|
|
6671
|
+
if (!ticket || !ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) return false;
|
|
6672
|
+
const resolved = this.queue.resolve(ticketId, status, resolvedBy, {
|
|
6673
|
+
denial_reason: options?.denial_reason
|
|
6674
|
+
});
|
|
6675
|
+
if (!resolved) return false;
|
|
6676
|
+
const updated = this.queue.get(ticketId);
|
|
6677
|
+
if (updated) this.onResolve?.(updated);
|
|
6678
|
+
return true;
|
|
6679
|
+
}
|
|
5206
6680
|
/**
|
|
5207
6681
|
* Approve a pending ticket. Resolves the held Promise so the governed
|
|
5208
6682
|
* forwarder can forward the request upstream.
|
|
@@ -5247,6 +6721,10 @@ var ApprovalRouter = class {
|
|
|
5247
6721
|
ticketId
|
|
5248
6722
|
});
|
|
5249
6723
|
}
|
|
6724
|
+
/** Look up a ticket by id (delegates to the queue). */
|
|
6725
|
+
getTicket(ticketId) {
|
|
6726
|
+
return this.queue.get(ticketId);
|
|
6727
|
+
}
|
|
5250
6728
|
/** Clean up all pending timers and resolve all pending promises. */
|
|
5251
6729
|
close() {
|
|
5252
6730
|
this.closed = true;
|
|
@@ -5509,8 +6987,8 @@ function createChannels(channels) {
|
|
|
5509
6987
|
|
|
5510
6988
|
// src/approval/slack-actions.ts
|
|
5511
6989
|
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
5512
|
-
import { Hono as
|
|
5513
|
-
import { z as
|
|
6990
|
+
import { Hono as Hono6 } from "hono";
|
|
6991
|
+
import { z as z6 } from "zod";
|
|
5514
6992
|
var MAX_TIMESTAMP_AGE_S = 300;
|
|
5515
6993
|
var REJECTION_LOG_WINDOW_MS = 6e4;
|
|
5516
6994
|
var REJECTION_LOG_SAMPLE_EVERY = 25;
|
|
@@ -5576,12 +7054,12 @@ function verifySlackSignature(secrets, timestamp, rawBody, signature) {
|
|
|
5576
7054
|
}
|
|
5577
7055
|
return false;
|
|
5578
7056
|
}
|
|
5579
|
-
var slackActionPayloadSchema =
|
|
5580
|
-
type:
|
|
5581
|
-
user:
|
|
5582
|
-
actions:
|
|
5583
|
-
channel:
|
|
5584
|
-
message:
|
|
7057
|
+
var slackActionPayloadSchema = z6.object({
|
|
7058
|
+
type: z6.string(),
|
|
7059
|
+
user: z6.object({ id: z6.string(), username: z6.string() }),
|
|
7060
|
+
actions: z6.array(z6.object({ action_id: z6.string() })),
|
|
7061
|
+
channel: z6.object({ id: z6.string() }),
|
|
7062
|
+
message: z6.object({ ts: z6.string() })
|
|
5585
7063
|
});
|
|
5586
7064
|
function parseActionPayload(rawBody) {
|
|
5587
7065
|
try {
|
|
@@ -5611,7 +7089,7 @@ Ticket \`${ticketId}\``
|
|
|
5611
7089
|
}
|
|
5612
7090
|
function createSlackActionApp(options) {
|
|
5613
7091
|
const { router, channels } = options;
|
|
5614
|
-
const app = new
|
|
7092
|
+
const app = new Hono6();
|
|
5615
7093
|
const rejectionLogBuckets = /* @__PURE__ */ new Map();
|
|
5616
7094
|
const rejectUnauthorized = (c, reason, context) => {
|
|
5617
7095
|
logRejectedSlackCallback(rejectionLogBuckets, {
|
|
@@ -5710,18 +7188,18 @@ function createSlackActionApp(options) {
|
|
|
5710
7188
|
}
|
|
5711
7189
|
|
|
5712
7190
|
// src/approval/api.ts
|
|
5713
|
-
import { Hono as
|
|
5714
|
-
import { z as
|
|
5715
|
-
var approveBody =
|
|
5716
|
-
approved_by:
|
|
7191
|
+
import { Hono as Hono7 } from "hono";
|
|
7192
|
+
import { z as z7 } from "zod";
|
|
7193
|
+
var approveBody = z7.object({
|
|
7194
|
+
approved_by: z7.string().min(1)
|
|
5717
7195
|
});
|
|
5718
|
-
var denyBody =
|
|
5719
|
-
denied_by:
|
|
5720
|
-
reason:
|
|
7196
|
+
var denyBody = z7.object({
|
|
7197
|
+
denied_by: z7.string().min(1),
|
|
7198
|
+
reason: z7.string().optional()
|
|
5721
7199
|
});
|
|
5722
|
-
var breakGlassBody =
|
|
5723
|
-
approved_by:
|
|
5724
|
-
reason:
|
|
7200
|
+
var breakGlassBody = z7.object({
|
|
7201
|
+
approved_by: z7.string().min(1),
|
|
7202
|
+
reason: z7.string().min(1)
|
|
5725
7203
|
});
|
|
5726
7204
|
var APPROVAL_STATUSES = [
|
|
5727
7205
|
"pending",
|
|
@@ -5730,25 +7208,26 @@ var APPROVAL_STATUSES = [
|
|
|
5730
7208
|
"timeout",
|
|
5731
7209
|
"break_glass",
|
|
5732
7210
|
"client_disconnected",
|
|
5733
|
-
"shutdown_cancelled"
|
|
7211
|
+
"shutdown_cancelled",
|
|
7212
|
+
"cancelled"
|
|
5734
7213
|
];
|
|
5735
7214
|
var approvalStatusSet = new Set(APPROVAL_STATUSES);
|
|
5736
|
-
var listApprovalsQuery =
|
|
5737
|
-
status:
|
|
7215
|
+
var listApprovalsQuery = z7.object({
|
|
7216
|
+
status: z7.preprocess(
|
|
5738
7217
|
(value) => typeof value === "string" && approvalStatusSet.has(value) ? value : void 0,
|
|
5739
|
-
|
|
7218
|
+
z7.enum(APPROVAL_STATUSES).optional()
|
|
5740
7219
|
),
|
|
5741
|
-
limit:
|
|
7220
|
+
limit: z7.preprocess(
|
|
5742
7221
|
(value) => clampInt(typeof value === "string" ? value : void 0, 50, 1, 1e3),
|
|
5743
|
-
|
|
7222
|
+
z7.number().int()
|
|
5744
7223
|
),
|
|
5745
|
-
offset:
|
|
7224
|
+
offset: z7.preprocess(
|
|
5746
7225
|
(value) => clampInt(typeof value === "string" ? value : void 0, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
5747
|
-
|
|
7226
|
+
z7.number().int()
|
|
5748
7227
|
)
|
|
5749
7228
|
});
|
|
5750
7229
|
function createApprovalApp(router, queue, options) {
|
|
5751
|
-
const app = new
|
|
7230
|
+
const app = new Hono7();
|
|
5752
7231
|
const apiSecret = options?.apiSecret;
|
|
5753
7232
|
if (apiSecret) {
|
|
5754
7233
|
app.use("*", async (c, next) => {
|
|
@@ -5794,6 +7273,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5794
7273
|
if (!ticket) {
|
|
5795
7274
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5796
7275
|
}
|
|
7276
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7277
|
+
return c.json(
|
|
7278
|
+
{
|
|
7279
|
+
error: "native_ticket",
|
|
7280
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7281
|
+
},
|
|
7282
|
+
409
|
|
7283
|
+
);
|
|
7284
|
+
}
|
|
5797
7285
|
if (ticket.status !== "pending") {
|
|
5798
7286
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5799
7287
|
}
|
|
@@ -5819,6 +7307,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5819
7307
|
if (!ticket) {
|
|
5820
7308
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5821
7309
|
}
|
|
7310
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7311
|
+
return c.json(
|
|
7312
|
+
{
|
|
7313
|
+
error: "native_ticket",
|
|
7314
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7315
|
+
},
|
|
7316
|
+
409
|
|
7317
|
+
);
|
|
7318
|
+
}
|
|
5822
7319
|
if (ticket.status !== "pending") {
|
|
5823
7320
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5824
7321
|
}
|
|
@@ -5844,6 +7341,15 @@ function createApprovalApp(router, queue, options) {
|
|
|
5844
7341
|
if (!ticket) {
|
|
5845
7342
|
return c.json({ error: "Ticket not found" }, 404);
|
|
5846
7343
|
}
|
|
7344
|
+
if (ticket.channel_name.startsWith(NATIVE_CHANNEL_PREFIX)) {
|
|
7345
|
+
return c.json(
|
|
7346
|
+
{
|
|
7347
|
+
error: "native_ticket",
|
|
7348
|
+
resolve_in: ticket.channel_name.slice(NATIVE_CHANNEL_PREFIX.length)
|
|
7349
|
+
},
|
|
7350
|
+
409
|
|
7351
|
+
);
|
|
7352
|
+
}
|
|
5847
7353
|
if (ticket.status !== "pending") {
|
|
5848
7354
|
return c.json({ error: "Ticket already resolved", status: ticket.status }, 409);
|
|
5849
7355
|
}
|
|
@@ -5859,9 +7365,9 @@ function createApprovalApp(router, queue, options) {
|
|
|
5859
7365
|
// src/dashboard/api.ts
|
|
5860
7366
|
import { readFileSync } from "fs";
|
|
5861
7367
|
import { join } from "path";
|
|
5862
|
-
import { randomUUID as
|
|
5863
|
-
import { Hono as
|
|
5864
|
-
import { z as
|
|
7368
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
7369
|
+
import { Hono as Hono8 } from "hono";
|
|
7370
|
+
import { z as z8 } from "zod";
|
|
5865
7371
|
import { cors } from "hono/cors";
|
|
5866
7372
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5867
7373
|
import { streamSSE } from "hono/streaming";
|
|
@@ -5922,7 +7428,7 @@ function recordsToCsv(records) {
|
|
|
5922
7428
|
}
|
|
5923
7429
|
|
|
5924
7430
|
// src/dashboard/session.ts
|
|
5925
|
-
import { createHash as
|
|
7431
|
+
import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
5926
7432
|
var DashboardSessionStore = class {
|
|
5927
7433
|
secret;
|
|
5928
7434
|
ttlMs;
|
|
@@ -6003,8 +7509,8 @@ var DashboardSessionStore = class {
|
|
|
6003
7509
|
const id = token.slice(0, dot);
|
|
6004
7510
|
const signature = token.slice(dot + 1);
|
|
6005
7511
|
const expected = this.sign(id);
|
|
6006
|
-
const actualDigest =
|
|
6007
|
-
const expectedDigest =
|
|
7512
|
+
const actualDigest = createHash3("sha256").update(signature).digest();
|
|
7513
|
+
const expectedDigest = createHash3("sha256").update(expected).digest();
|
|
6008
7514
|
if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
|
|
6009
7515
|
return id;
|
|
6010
7516
|
}
|
|
@@ -6014,29 +7520,29 @@ var DashboardSessionStore = class {
|
|
|
6014
7520
|
};
|
|
6015
7521
|
|
|
6016
7522
|
// src/dashboard/api.ts
|
|
6017
|
-
var optionalQueryString =
|
|
7523
|
+
var optionalQueryString = z8.preprocess(
|
|
6018
7524
|
(value) => typeof value === "string" && value.length > 0 ? value : void 0,
|
|
6019
|
-
|
|
7525
|
+
z8.string().optional()
|
|
6020
7526
|
);
|
|
6021
|
-
var optionalQueryInt =
|
|
7527
|
+
var optionalQueryInt = z8.preprocess((value) => {
|
|
6022
7528
|
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
6023
7529
|
const parsed = Number.parseInt(value, 10);
|
|
6024
7530
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
6025
|
-
},
|
|
6026
|
-
var queryBoolean =
|
|
7531
|
+
}, z8.number().int().optional());
|
|
7532
|
+
var queryBoolean = z8.preprocess(
|
|
6027
7533
|
(value) => value === "true" ? true : value === "false" ? false : void 0,
|
|
6028
|
-
|
|
7534
|
+
z8.boolean().optional()
|
|
6029
7535
|
);
|
|
6030
|
-
var clampedQueryInt = (fallback, min, max) =>
|
|
7536
|
+
var clampedQueryInt = (fallback, min, max) => z8.preprocess(
|
|
6031
7537
|
(value) => clampInt(typeof value === "string" ? value : void 0, fallback, min, max),
|
|
6032
|
-
|
|
7538
|
+
z8.number().int()
|
|
6033
7539
|
);
|
|
6034
|
-
var feedQuerySchema =
|
|
7540
|
+
var feedQuerySchema = z8.object({
|
|
6035
7541
|
limit: clampedQueryInt(50, 1, 200),
|
|
6036
7542
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
|
|
6037
7543
|
});
|
|
6038
|
-
var auditExportQuerySchema =
|
|
6039
|
-
format:
|
|
7544
|
+
var auditExportQuerySchema = z8.object({
|
|
7545
|
+
format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
|
|
6040
7546
|
limit: clampedQueryInt(1e4, 1, 1e4),
|
|
6041
7547
|
tool: optionalQueryString,
|
|
6042
7548
|
decision: optionalQueryString,
|
|
@@ -6048,9 +7554,13 @@ var auditExportQuerySchema = z7.object({
|
|
|
6048
7554
|
from: optionalQueryString,
|
|
6049
7555
|
to: optionalQueryString,
|
|
6050
7556
|
upstream_status_min: optionalQueryInt,
|
|
6051
|
-
upstream_status_max: optionalQueryInt
|
|
7557
|
+
upstream_status_max: optionalQueryInt,
|
|
7558
|
+
origin: optionalQueryString,
|
|
7559
|
+
record_kind: optionalQueryString,
|
|
7560
|
+
channel_id: optionalQueryString,
|
|
7561
|
+
sender_id: optionalQueryString
|
|
6052
7562
|
});
|
|
6053
|
-
var auditQuerySchema =
|
|
7563
|
+
var auditQuerySchema = z8.object({
|
|
6054
7564
|
limit: clampedQueryInt(50, 1, 1e3),
|
|
6055
7565
|
offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
|
|
6056
7566
|
tool: optionalQueryString,
|
|
@@ -6064,14 +7574,18 @@ var auditQuerySchema = z7.object({
|
|
|
6064
7574
|
destructive: queryBoolean,
|
|
6065
7575
|
dry_run: queryBoolean,
|
|
6066
7576
|
upstream_status_min: optionalQueryInt,
|
|
6067
|
-
upstream_status_max: optionalQueryInt
|
|
7577
|
+
upstream_status_max: optionalQueryInt,
|
|
7578
|
+
origin: optionalQueryString,
|
|
7579
|
+
record_kind: optionalQueryString,
|
|
7580
|
+
channel_id: optionalQueryString,
|
|
7581
|
+
sender_id: optionalQueryString
|
|
6068
7582
|
});
|
|
6069
|
-
var analyticsQuerySchema =
|
|
7583
|
+
var analyticsQuerySchema = z8.object({
|
|
6070
7584
|
from: optionalQueryString,
|
|
6071
7585
|
to: optionalQueryString
|
|
6072
7586
|
});
|
|
6073
|
-
var authSessionBodySchema =
|
|
6074
|
-
secret:
|
|
7587
|
+
var authSessionBodySchema = z8.object({
|
|
7588
|
+
secret: z8.string()
|
|
6075
7589
|
});
|
|
6076
7590
|
var SESSION_COOKIE = "helio_session";
|
|
6077
7591
|
var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
|
|
@@ -6131,7 +7645,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6131
7645
|
} = deps;
|
|
6132
7646
|
const apiSecret = options?.apiSecret;
|
|
6133
7647
|
const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
|
|
6134
|
-
const app = new
|
|
7648
|
+
const app = new Hono8();
|
|
6135
7649
|
app.use(
|
|
6136
7650
|
"*",
|
|
6137
7651
|
cors({
|
|
@@ -6271,7 +7785,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6271
7785
|
from: query.from,
|
|
6272
7786
|
to: query.to,
|
|
6273
7787
|
upstream_status_min: query.upstream_status_min,
|
|
6274
|
-
upstream_status_max: query.upstream_status_max
|
|
7788
|
+
upstream_status_max: query.upstream_status_max,
|
|
7789
|
+
origin: query.origin,
|
|
7790
|
+
record_kind: query.record_kind,
|
|
7791
|
+
channel_id: query.channel_id,
|
|
7792
|
+
sender_id: query.sender_id
|
|
6275
7793
|
};
|
|
6276
7794
|
const result = auditStore.list(filters, { limit, order: "asc" });
|
|
6277
7795
|
if (format === "csv") {
|
|
@@ -6313,7 +7831,11 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6313
7831
|
flagged_destructive: query.destructive,
|
|
6314
7832
|
dry_run: query.dry_run,
|
|
6315
7833
|
upstream_status_min: query.upstream_status_min,
|
|
6316
|
-
upstream_status_max: query.upstream_status_max
|
|
7834
|
+
upstream_status_max: query.upstream_status_max,
|
|
7835
|
+
origin: query.origin,
|
|
7836
|
+
record_kind: query.record_kind,
|
|
7837
|
+
channel_id: query.channel_id,
|
|
7838
|
+
sender_id: query.sender_id
|
|
6317
7839
|
};
|
|
6318
7840
|
const result = auditStore.list(filters, { limit, offset, order: "desc" });
|
|
6319
7841
|
return c.json({
|
|
@@ -6374,7 +7896,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6374
7896
|
app.get("/api/events", (c) => {
|
|
6375
7897
|
return streamSSE(c, async (stream) => {
|
|
6376
7898
|
if (closed) return;
|
|
6377
|
-
const connId =
|
|
7899
|
+
const connId = randomUUID6();
|
|
6378
7900
|
let streamClosed = false;
|
|
6379
7901
|
let stopHeartbeat = () => {
|
|
6380
7902
|
};
|
|
@@ -6403,7 +7925,7 @@ function createDashboardAppWithLifecycle(deps, options) {
|
|
|
6403
7925
|
void stream.writeSSE({
|
|
6404
7926
|
event: eventType,
|
|
6405
7927
|
data: JSON.stringify(data),
|
|
6406
|
-
id:
|
|
7928
|
+
id: randomUUID6()
|
|
6407
7929
|
}).then(() => {
|
|
6408
7930
|
const conn = activeConnections.get(connId);
|
|
6409
7931
|
if (conn) conn.lastWrite = Date.now();
|
|
@@ -6774,7 +8296,9 @@ async function startCommand(configPath, options) {
|
|
|
6774
8296
|
flagged_destructive: record.flagged_destructive,
|
|
6775
8297
|
dry_run: record.dry_run,
|
|
6776
8298
|
matched_rule: record.matched_rule,
|
|
6777
|
-
matched_rule_index: record.matched_rule_index
|
|
8299
|
+
matched_rule_index: record.matched_rule_index,
|
|
8300
|
+
record_kind: record.record_kind,
|
|
8301
|
+
origin: record.origin
|
|
6778
8302
|
});
|
|
6779
8303
|
}
|
|
6780
8304
|
});
|
|
@@ -6853,6 +8377,8 @@ async function startCommand(configPath, options) {
|
|
|
6853
8377
|
let sidebandHandle;
|
|
6854
8378
|
let sidebandToken;
|
|
6855
8379
|
let sidebandTokenSource;
|
|
8380
|
+
let adapterToken;
|
|
8381
|
+
let governanceService;
|
|
6856
8382
|
if (config.sdk.enabled) {
|
|
6857
8383
|
sidebandToken = process.env["HELIO_SDK_TOKEN"];
|
|
6858
8384
|
if (!sidebandToken || sidebandToken.length === 0) {
|
|
@@ -6862,7 +8388,27 @@ async function startCommand(configPath, options) {
|
|
|
6862
8388
|
} else {
|
|
6863
8389
|
sidebandTokenSource = "env";
|
|
6864
8390
|
}
|
|
6865
|
-
|
|
8391
|
+
adapterToken = process.env["HELIO_ADAPTER_TOKEN"];
|
|
8392
|
+
if (!adapterToken || adapterToken.length === 0) {
|
|
8393
|
+
adapterToken = randomBytes2(32).toString("hex");
|
|
8394
|
+
process.env["HELIO_ADAPTER_TOKEN"] = adapterToken;
|
|
8395
|
+
}
|
|
8396
|
+
governanceService = new GovernanceService({
|
|
8397
|
+
policy,
|
|
8398
|
+
environment: config.environment,
|
|
8399
|
+
evidenceStore,
|
|
8400
|
+
approvalRouter,
|
|
8401
|
+
rateLimiter,
|
|
8402
|
+
spendLimiter,
|
|
8403
|
+
auditWriter,
|
|
8404
|
+
approvalTimeoutMs: parseDuration(config.approval.timeout),
|
|
8405
|
+
ttlMs: parseDuration(config.sdk.evaluation_ttl)
|
|
8406
|
+
});
|
|
8407
|
+
const sidebandApp = createSidebandApp(evidenceStore, {
|
|
8408
|
+
token: sidebandToken,
|
|
8409
|
+
adapterToken,
|
|
8410
|
+
governance: governanceService
|
|
8411
|
+
});
|
|
6866
8412
|
sidebandHandle = startSidebandServer(sidebandApp, config.sdk.port, config.sdk.host);
|
|
6867
8413
|
}
|
|
6868
8414
|
let dashboardHandle;
|
|
@@ -6913,6 +8459,12 @@ async function startCommand(configPath, options) {
|
|
|
6913
8459
|
${sidebandToken}`
|
|
6914
8460
|
);
|
|
6915
8461
|
}
|
|
8462
|
+
if (adapterToken) {
|
|
8463
|
+
console.error(
|
|
8464
|
+
`Adapter token (governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
|
|
8465
|
+
${adapterToken}`
|
|
8466
|
+
);
|
|
8467
|
+
}
|
|
6916
8468
|
}
|
|
6917
8469
|
if (dashboardHandle) {
|
|
6918
8470
|
console.error(
|
|
@@ -6940,6 +8492,7 @@ async function startCommand(configPath, options) {
|
|
|
6940
8492
|
initialConfig: config,
|
|
6941
8493
|
onPolicyReload: (newPolicy, reloadWarnings, restartRequiredPaths) => {
|
|
6942
8494
|
governedForwarder.updatePolicy(newPolicy);
|
|
8495
|
+
governanceService?.updatePolicy(newPolicy);
|
|
6943
8496
|
const count = newPolicy.rules.length;
|
|
6944
8497
|
console.error(
|
|
6945
8498
|
`[helio] Policy reloaded: ${String(count)} rule${count !== 1 ? "s" : ""} (default: ${newPolicy.defaultAction})`
|
|
@@ -6983,7 +8536,8 @@ async function startCommand(configPath, options) {
|
|
|
6983
8536
|
spendLimiter,
|
|
6984
8537
|
closeDashboardApp,
|
|
6985
8538
|
dashboardHandle,
|
|
6986
|
-
eventBus
|
|
8539
|
+
eventBus,
|
|
8540
|
+
governanceService
|
|
6987
8541
|
);
|
|
6988
8542
|
}
|
|
6989
8543
|
async function initCommand(outputPath, force) {
|
|
@@ -7091,7 +8645,7 @@ function writeCsv(records) {
|
|
|
7091
8645
|
console.log(values.join(","));
|
|
7092
8646
|
}
|
|
7093
8647
|
}
|
|
7094
|
-
function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus) {
|
|
8648
|
+
function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter, configWatcher, sidebandHandle, evidenceStore, approvalRouter, approvalQueue, rateLimiter, spendLimiter, closeDashboardApp, dashboardHandle, eventBus, governanceService) {
|
|
7095
8649
|
let isShuttingDown = false;
|
|
7096
8650
|
const shutdown = () => {
|
|
7097
8651
|
if (isShuttingDown) return;
|
|
@@ -7107,6 +8661,7 @@ function registerShutdown(handle, annotationPrime, closeForwarder, auditWriter,
|
|
|
7107
8661
|
if (configWatcher) configWatcher.close();
|
|
7108
8662
|
if (closeDashboardApp) closeDashboardApp();
|
|
7109
8663
|
if (eventBus) eventBus.close();
|
|
8664
|
+
if (governanceService) governanceService.close();
|
|
7110
8665
|
if (rateLimiter) rateLimiter.close();
|
|
7111
8666
|
if (spendLimiter) spendLimiter.close();
|
|
7112
8667
|
if (approvalRouter) approvalRouter.close();
|