@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.9
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/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +23 -25
- package/out/analytics/sessionEvents.js +98 -23
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +122 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +67 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +332 -0
- package/out/cli/commands/serve.js +875 -52
- package/out/cli/commands/store.js +12 -4
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +17 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +161 -0
- package/out/cli/deploy/layers/identity.js +85 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +155 -16
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +80 -3
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +110 -26
- package/out/engines/custom.js +8 -0
- package/out/evidence/sessionShip.js +610 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +17 -1
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +309 -0
- package/out/pack/lease-schema.js +270 -0
- package/out/pack/lease.js +640 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +131 -0
- package/out/runner/protocol.js +108 -0
- package/out/runner/remoteEngine.js +130 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +570 -0
- package/out/sandbox/processDriver.js +315 -0
- package/out/sandbox/sandboxRunner.js +191 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
- package/out/scripts/analytics/setup-analytics.ps1 +37 -13
- package/out/scripts/analytics/setup-analytics.sh +93 -13
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +455 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane.test.js +67 -0
- package/out/test/deploy-init.test.js +40 -0
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +217 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +169 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +395 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/serve-hardening.test.js +45 -6
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +560 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- package/package.json +1 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.effectiveDenyMode = effectiveDenyMode;
|
|
7
|
+
exports.createGovernor = createGovernor;
|
|
8
|
+
exports.withGovernor = withGovernor;
|
|
9
|
+
const caps_1 = require("./caps");
|
|
10
|
+
const lease_1 = require("./lease");
|
|
11
|
+
/**
|
|
12
|
+
* What a deny mode MEANS on this deployment, as opposed to what it was written as.
|
|
13
|
+
*
|
|
14
|
+
* Without a sandbox driver there is no non-cooperative way to stop a turn already executing in
|
|
15
|
+
* this process, so `kill` degrades to `drain`. The degradation is real and unavoidable at this
|
|
16
|
+
* layer; what is not acceptable is reporting `kill` while doing `drain`.
|
|
17
|
+
*/
|
|
18
|
+
function effectiveDenyMode(mode, supportsInFlightTermination) {
|
|
19
|
+
if (mode === 'kill' && !supportsInFlightTermination) {
|
|
20
|
+
return 'drain';
|
|
21
|
+
}
|
|
22
|
+
return mode;
|
|
23
|
+
}
|
|
24
|
+
function createGovernor(opts) {
|
|
25
|
+
const meter = new caps_1.CapMeter(opts.caps);
|
|
26
|
+
// Not const: every lease poll re-merges the control plane's caps over the local ones.
|
|
27
|
+
let unbounded = (0, caps_1.capsAreUnbounded)(opts.caps);
|
|
28
|
+
let timer;
|
|
29
|
+
/** Latches once the lease kills us: no further turn may sneak through between polls. */
|
|
30
|
+
let killed;
|
|
31
|
+
/** Last lease seen, so per-session deny checks need no fetch on the turn path. */
|
|
32
|
+
let currentLease;
|
|
33
|
+
/**
|
|
34
|
+
* sessionKey → the verified user it belongs to, learned from `admitTurn`.
|
|
35
|
+
*
|
|
36
|
+
* Only the turn path knows the resolved identity, but the lease WATCHER is what ends running
|
|
37
|
+
* work — so without remembering it, a `user`-scoped kill could refuse the next turn and still
|
|
38
|
+
* leave the user's live sandbox running.
|
|
39
|
+
*/
|
|
40
|
+
const sessionUsers = new Map();
|
|
41
|
+
/** True unless the deployment has no way to end a running turn — see GovernableSandboxes. */
|
|
42
|
+
const canTerminateInFlight = opts.sandboxes.supportsInFlightTermination !== false;
|
|
43
|
+
/** Session rules extended to the users behind them, so id rotation does not escape. */
|
|
44
|
+
let followedUsers = new Map();
|
|
45
|
+
/** Kill rules already reported as degraded, so the warning is loud once, not every poll. */
|
|
46
|
+
const degradedKillsReported = new Set();
|
|
47
|
+
const audit = (event, fields) => {
|
|
48
|
+
try {
|
|
49
|
+
opts.audit?.(event, fields);
|
|
50
|
+
}
|
|
51
|
+
catch { /* evidence must never break enforcement */ }
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Bounded on purpose: a long-lived serve sees unbounded distinct session keys (every anonymous
|
|
55
|
+
* gateway sender gets a fresh uuid), and an unbounded map here would be a slow leak in the one
|
|
56
|
+
* component that must never be the reason an instance falls over. Oldest-first eviction costs
|
|
57
|
+
* only the ability to kill an already-idle session by user scope.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_TRACKED_SESSION_USERS = 10_000;
|
|
60
|
+
const rememberSessionUser = (sessionKey, userId) => {
|
|
61
|
+
if (sessionUsers.size >= MAX_TRACKED_SESSION_USERS && !sessionUsers.has(sessionKey)) {
|
|
62
|
+
const oldest = sessionUsers.keys().next();
|
|
63
|
+
if (!oldest.done) {
|
|
64
|
+
sessionUsers.delete(oldest.value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
sessionUsers.set(sessionKey, userId);
|
|
68
|
+
};
|
|
69
|
+
const enforce = async (sessionKey, decision) => {
|
|
70
|
+
// The MERGED policy, not the local one: a control plane that escalates revoke → destroy
|
|
71
|
+
// must be obeyed on the next breach, not on the next restart.
|
|
72
|
+
const action = meter.policy().onBreach ?? 'revoke-inference';
|
|
73
|
+
try {
|
|
74
|
+
if (action === 'destroy-sandbox') {
|
|
75
|
+
// The SANDBOX is destroyed; the session's meter deliberately survives it.
|
|
76
|
+
// `meter.forget` here reset turns and the duration clock, so a breached session got
|
|
77
|
+
// a brand-new budget and `maxTurnsPerSession: 10` degraded into "one refused turn
|
|
78
|
+
// every ten, forever" — a cap that renews on breach is not a cap.
|
|
79
|
+
await opts.sandboxes.destroySession(sessionKey);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Narrower: the session keeps its sandbox (and its state, for inspection) but loses
|
|
83
|
+
// the model route, so it cannot spend another token.
|
|
84
|
+
await opts.sandboxes.revokeRoute(sessionKey, 'inference');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
// A tier-0 sandbox cannot revoke a single route; falling back to destroy keeps the cap
|
|
89
|
+
// honest instead of silently letting the session continue.
|
|
90
|
+
opts.logger?.warn(`[Governor] ${action} failed for ${sessionKey}, destroying instead: ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
await opts.sandboxes.destroySession(sessionKey).catch(() => undefined);
|
|
92
|
+
// Same rule as the deliberate destroy above: the meter outlives the sandbox, or the
|
|
93
|
+
// fallback path quietly hands back the budget the primary path refused.
|
|
94
|
+
}
|
|
95
|
+
opts.logger?.warn(`[Governor] cap breach on ${sessionKey}: ${decision.reason} — ${action}`);
|
|
96
|
+
audit('cap_breach', { sessionKey, cap: decision.cap, reason: decision.reason, action });
|
|
97
|
+
};
|
|
98
|
+
const killAll = async (reason) => {
|
|
99
|
+
const live = opts.sandboxes.sandboxes();
|
|
100
|
+
for (const c of live) {
|
|
101
|
+
await opts.sandboxes.destroySession(c.sessionKey).catch(() => undefined);
|
|
102
|
+
// The meter deliberately SURVIVES the kill, for exactly the reason `enforce` gives when
|
|
103
|
+
// it destroys a sandbox on a cap breach: a cap that renews when the sandbox goes away is
|
|
104
|
+
// not a cap. `meter.forget` here meant a kill-then-restore (a billing hold lifted, a
|
|
105
|
+
// drill, an operator toggling the switch) handed every session a brand-new turn and
|
|
106
|
+
// duration budget — so the cheapest way past a cap was to trip the kill switch. The two
|
|
107
|
+
// paths now agree: destroying a sandbox never returns budget.
|
|
108
|
+
}
|
|
109
|
+
if (live.length > 0) {
|
|
110
|
+
opts.logger?.warn(`[Governor] kill switch: destroyed ${live.length} live sandbox(s) — ${reason}`);
|
|
111
|
+
}
|
|
112
|
+
else if (!canTerminateInFlight) {
|
|
113
|
+
// Without a driver there is nothing to destroy, and the kill switch reduces to refusing
|
|
114
|
+
// the next turn. Say so rather than logging a successful stop of zero sandboxes.
|
|
115
|
+
opts.logger?.warn(`[Governor] kill switch: no sandbox driver, so nothing running can be stopped — ` +
|
|
116
|
+
`in-flight turns will finish and only NEW turns are refused (${reason}). Run with --sandbox for true in-flight kill.`);
|
|
117
|
+
}
|
|
118
|
+
audit('kill_switch', { reason, destroyed: live.length, inFlightTerminated: canTerminateInFlight });
|
|
119
|
+
return live.length;
|
|
120
|
+
};
|
|
121
|
+
const checkLease = async () => {
|
|
122
|
+
if (!opts.getLease) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const lease = await opts.getLease();
|
|
127
|
+
// Adopt caps the control plane has pushed since boot. Same tighten-only merge as at
|
|
128
|
+
// construction, re-applied every poll: an operator who lowers a cap in the console
|
|
129
|
+
// must not have to restart the fleet for it to bite. Without this the instance
|
|
130
|
+
// reported the new policy while enforcing the boot-time one.
|
|
131
|
+
const merged = (0, caps_1.mergeCaps)(lease?.caps, opts.caps);
|
|
132
|
+
meter.setCaps(merged);
|
|
133
|
+
unbounded = (0, caps_1.capsAreUnbounded)(merged);
|
|
134
|
+
// Cache the deny rules so `admitTurn` stays synchronous and cheap: enforcement latency
|
|
135
|
+
// is the poll interval, exactly like the kill switch, rather than a fetch per turn.
|
|
136
|
+
currentLease = lease;
|
|
137
|
+
// Recomputed each poll from what the turn path has learned, so a session stop reaches the
|
|
138
|
+
// user's other sessions even after they rotate `session_id`.
|
|
139
|
+
followedUsers = (0, lease_1.resolveFollowedUsers)(lease, sessionUsers);
|
|
140
|
+
// A `kill` rule this deployment cannot honour must not be reported as honoured. Warn
|
|
141
|
+
// once per rule, before sweeping, so an operator learns immediately that the rule they
|
|
142
|
+
// just published means `drain` here.
|
|
143
|
+
if (!canTerminateInFlight) {
|
|
144
|
+
for (const rule of lease?.deny ?? []) {
|
|
145
|
+
if (rule.mode !== 'kill') {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const id = `${rule.scope}:${rule.match}`;
|
|
149
|
+
if (degradedKillsReported.has(id)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
degradedKillsReported.add(id);
|
|
153
|
+
opts.logger?.warn(`[Governor] deny(kill) on ${id} DEGRADED TO DRAIN: this deployment has no sandbox driver, so a turn ` +
|
|
154
|
+
'already executing cannot be stopped. It will run to completion; only the next turn is refused. ' +
|
|
155
|
+
'Run serve with --sandbox for a kill that ends work mid-flight.');
|
|
156
|
+
audit('deny_kill_degraded', { scope: rule.scope, match: rule.match, effectiveMode: 'drain' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// `kill` rules end work already running. `drain` deliberately does not — it lets the
|
|
160
|
+
// in-flight turn finish and only refuses the next one.
|
|
161
|
+
for (const sandbox of opts.sandboxes.sandboxes()) {
|
|
162
|
+
const v = (0, lease_1.denyVerdict)(lease, {
|
|
163
|
+
sessionKey: sandbox.sessionKey,
|
|
164
|
+
pack: opts.pack ?? '',
|
|
165
|
+
userHash: opts.userHash,
|
|
166
|
+
// Remembered from this session's turns, so a `user`-scoped kill reaches a
|
|
167
|
+
// gateway session whose key carries no `u<hash>--` prefix to read it off.
|
|
168
|
+
userId: sessionUsers.get(sandbox.sessionKey),
|
|
169
|
+
followedUsers,
|
|
170
|
+
});
|
|
171
|
+
if (v.denied && effectiveDenyMode(v.mode, canTerminateInFlight) === 'kill') {
|
|
172
|
+
await opts.sandboxes.destroySession(sandbox.sessionKey).catch(() => undefined);
|
|
173
|
+
sessionUsers.delete(sandbox.sessionKey);
|
|
174
|
+
// The meter survives, same rule as `killAll` and `enforce`: a stop order must not
|
|
175
|
+
// hand the session a fresh budget when it is lifted.
|
|
176
|
+
opts.logger?.warn(`[Governor] deny(kill) destroyed ${sandbox.sandboxId} — ${v.reason}`);
|
|
177
|
+
audit('session_killed', { sessionKey: sandbox.sessionKey, sandboxId: sandbox.sandboxId, reason: v.reason });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const verdict = (0, lease_1.leaseIsDead)(lease);
|
|
181
|
+
if (verdict.dead) {
|
|
182
|
+
killed = verdict.reason ?? 'lease revoked';
|
|
183
|
+
await killAll(killed);
|
|
184
|
+
}
|
|
185
|
+
else if (killed) {
|
|
186
|
+
// A restored lease lifts the latch — an operator can turn the fleet back on
|
|
187
|
+
// without restarting every instance.
|
|
188
|
+
opts.logger?.info('[Governor] lease restored — accepting turns again');
|
|
189
|
+
audit('kill_switch_lifted', {});
|
|
190
|
+
killed = undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Fail-open on transport, exactly like the lease source: a control-plane outage must
|
|
195
|
+
// not take a customer's fleet down.
|
|
196
|
+
opts.logger?.warn(`[Governor] lease check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
async admitTurn(sessionKey, userId) {
|
|
201
|
+
if (killed) {
|
|
202
|
+
return { allowed: false, reason: `stopped by the control plane: ${killed}` };
|
|
203
|
+
}
|
|
204
|
+
if (userId) {
|
|
205
|
+
rememberSessionUser(sessionKey, userId);
|
|
206
|
+
}
|
|
207
|
+
// Scoped stop orders refuse NEW work in both modes; the difference between drain and
|
|
208
|
+
// kill is only what happened to the turn already running, decided at the last poll.
|
|
209
|
+
const denied = (0, lease_1.denyVerdict)(currentLease, {
|
|
210
|
+
sessionKey,
|
|
211
|
+
pack: opts.pack ?? '',
|
|
212
|
+
userHash: opts.userHash,
|
|
213
|
+
userId: userId ?? sessionUsers.get(sessionKey),
|
|
214
|
+
// A session stop follows the user, so sending a fresh `session_id` does not escape it.
|
|
215
|
+
// Recomputed here as well as on the poll because the identity behind THIS session may
|
|
216
|
+
// only just have become known — waiting a poll would admit exactly the turn we are
|
|
217
|
+
// trying to refuse.
|
|
218
|
+
followedUsers: (0, lease_1.resolveFollowedUsers)(currentLease, sessionUsers),
|
|
219
|
+
});
|
|
220
|
+
if (denied.denied) {
|
|
221
|
+
return { allowed: false, reason: denied.reason };
|
|
222
|
+
}
|
|
223
|
+
// What is running, not just whether it may run: signature + pin, for the no-sandbox
|
|
224
|
+
// path where nothing else asks. Before the caps check, because an unsigned or
|
|
225
|
+
// unpinned pack must be refused whether or not the session has budget left.
|
|
226
|
+
if (opts.admitPack) {
|
|
227
|
+
// The lease the poll last saw — the SAME view the kill and deny checks just used,
|
|
228
|
+
// and no fetch on the turn path.
|
|
229
|
+
const packVerdict = opts.admitPack(sessionKey, currentLease);
|
|
230
|
+
if (!packVerdict.allowed) {
|
|
231
|
+
return { allowed: false, reason: packVerdict.reason ?? 'refused by pack admission' };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (unbounded) {
|
|
235
|
+
return { allowed: true };
|
|
236
|
+
}
|
|
237
|
+
const decision = meter.check(sessionKey, opts.sandboxes.sandboxes().length);
|
|
238
|
+
if (!decision.allowed) {
|
|
239
|
+
// Concurrency is a "come back later" refusal about the INSTANCE — there is no
|
|
240
|
+
// offending session to punish, and revoking someone else's route would be wrong.
|
|
241
|
+
if (decision.cap !== 'concurrency') {
|
|
242
|
+
await enforce(sessionKey, decision);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return decision;
|
|
246
|
+
},
|
|
247
|
+
recordTurn(sessionKey) {
|
|
248
|
+
// Always meter, even while unbounded: caps can be tightened mid-flight from the
|
|
249
|
+
// control plane, and a session must not get a clean slate the moment one appears.
|
|
250
|
+
meter.record(sessionKey);
|
|
251
|
+
},
|
|
252
|
+
killAll,
|
|
253
|
+
refresh: checkLease,
|
|
254
|
+
start() {
|
|
255
|
+
if (!opts.getLease || timer) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// One constant for the poll interval, shared with the lease source's cache — see
|
|
259
|
+
// DEFAULT_LEASE_POLL_SECONDS. These two used to disagree (30 vs 60) and the banner
|
|
260
|
+
// printed the smaller one.
|
|
261
|
+
const everyMs = (0, lease_1.resolveLeasePollSeconds)(opts.pollSeconds) * 1000;
|
|
262
|
+
timer = setInterval(() => { void checkLease(); }, everyMs);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
void checkLease();
|
|
265
|
+
},
|
|
266
|
+
stop() {
|
|
267
|
+
if (timer) {
|
|
268
|
+
clearInterval(timer);
|
|
269
|
+
timer = undefined;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
// The caps ACTUALLY in force, which after a poll may be tighter than the boot-time ones.
|
|
273
|
+
policy: () => meter.policy(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Put the governor in front of a runner, so every turn passes the gate no matter which endpoint
|
|
278
|
+
* it arrived on. serve has three call sites into `runner.run`; decorating the runner covers all
|
|
279
|
+
* of them and cannot be forgotten when a fourth is added — the same relocation trick
|
|
280
|
+
* `createSandboxRunner` uses one layer down.
|
|
281
|
+
*/
|
|
282
|
+
function withGovernor(runner, governor) {
|
|
283
|
+
return {
|
|
284
|
+
async run(options) {
|
|
285
|
+
const sessionKey = options.sessionId ?? 'default';
|
|
286
|
+
// The identity serve resolved for this turn — what makes "stop this user" exact.
|
|
287
|
+
const decision = await governor.admitTurn(sessionKey, options.analyticsUserId);
|
|
288
|
+
if (!decision.allowed) {
|
|
289
|
+
// A refusal is a normal, reportable outcome — not an exception. The caller turns
|
|
290
|
+
// this into a 429/403 with the operator's reason intact.
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
text: '',
|
|
294
|
+
thinking: '',
|
|
295
|
+
toolCalls: [],
|
|
296
|
+
error: decision.reason ?? 'refused by policy',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// Counted BEFORE the turn runs: a turn that crashes or is aborted still consumed the
|
|
300
|
+
// budget, and not counting it would let a session retry past its cap forever.
|
|
301
|
+
governor.recordTurn(sessionKey);
|
|
302
|
+
return runner.run(options);
|
|
303
|
+
},
|
|
304
|
+
dispose() {
|
|
305
|
+
governor.stop();
|
|
306
|
+
runner.dispose();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DENY_SCOPE_ALIASES = exports.DENY_MODE_ALIASES = exports.STRICTEST_DENY_MODE = exports.DENY_MODES = exports.DENY_SCOPES = exports.STRICTEST_ON_BREACH = exports.ON_BREACH_ALIASES = exports.ON_BREACH_ACTIONS = exports.DEPRECATED_CAP_SPELLINGS = exports.LEASE_CAP_ALIASES = exports.LEASE_CAP_FIELDS = void 0;
|
|
7
|
+
exports.parseCapPolicy = parseCapPolicy;
|
|
8
|
+
exports.parseLease = parseLease;
|
|
9
|
+
// ---------------------------------------------------------------------------------------------
|
|
10
|
+
// Vocabulary tables — extend these, not the code below
|
|
11
|
+
// ---------------------------------------------------------------------------------------------
|
|
12
|
+
/** Numeric cap fields, and what a valid value looks like. */
|
|
13
|
+
exports.LEASE_CAP_FIELDS = {
|
|
14
|
+
maxTurnsPerSession: { kind: 'positive-int' },
|
|
15
|
+
maxSessionSeconds: { kind: 'positive-int' },
|
|
16
|
+
maxConcurrentSandboxes: { kind: 'positive-int' },
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Alternative spellings for cap fields, mapped onto the canonical name.
|
|
20
|
+
*
|
|
21
|
+
* The extension point for vocabulary migrations. Adding a row is the entire change — there is no
|
|
22
|
+
* second place that reads a cap field, so an alias added here works for leases, env and CLI flags
|
|
23
|
+
* at once.
|
|
24
|
+
*/
|
|
25
|
+
exports.LEASE_CAP_ALIASES = {
|
|
26
|
+
/** Pre-rename vocabulary: a "cell" is what a sandbox used to be called. */
|
|
27
|
+
maxConcurrentCells: 'maxConcurrentSandboxes',
|
|
28
|
+
};
|
|
29
|
+
/** Deprecated cap spellings → the message an operator should see. Keyed by the OLD name. */
|
|
30
|
+
exports.DEPRECATED_CAP_SPELLINGS = {
|
|
31
|
+
maxConcurrentCells: 'maxConcurrentSandboxes',
|
|
32
|
+
AGENTHIPPO_MAX_CONCURRENT_CELLS: 'AGENTHIPPO_MAX_CONCURRENT_SANDBOXES',
|
|
33
|
+
'destroy-cell': 'destroy-sandbox',
|
|
34
|
+
'--max-concurrent-cells': '--max-concurrent-sandboxes',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Breach actions, ordered by STRICTNESS. An unrecognised value clamps to the highest strictness
|
|
38
|
+
* in this table, never to the lowest and never to "unset".
|
|
39
|
+
*/
|
|
40
|
+
exports.ON_BREACH_ACTIONS = {
|
|
41
|
+
'revoke-inference': { strictness: 1 },
|
|
42
|
+
'destroy-sandbox': { strictness: 2 },
|
|
43
|
+
};
|
|
44
|
+
/** Alternative spellings for breach actions. Same extension point as `LEASE_CAP_ALIASES`. */
|
|
45
|
+
exports.ON_BREACH_ALIASES = {
|
|
46
|
+
/** Pre-rename vocabulary. Deliberately mapped rather than dropped: dropping it SOFTENED the action. */
|
|
47
|
+
'destroy-cell': 'destroy-sandbox',
|
|
48
|
+
};
|
|
49
|
+
/** The strictest action this build knows about — where an unrecognised value lands. */
|
|
50
|
+
exports.STRICTEST_ON_BREACH = Object.keys(exports.ON_BREACH_ACTIONS)
|
|
51
|
+
.reduce((a, b) => (exports.ON_BREACH_ACTIONS[a].strictness >= exports.ON_BREACH_ACTIONS[b].strictness ? a : b));
|
|
52
|
+
/** Deny-rule scopes. An unknown scope rejects the document — see rule 1 above. */
|
|
53
|
+
exports.DENY_SCOPES = ['session', 'user', 'pack'];
|
|
54
|
+
/** Deny-rule modes, ordered by strictness for the same reason `onBreach` is. */
|
|
55
|
+
exports.DENY_MODES = { drain: { strictness: 1 }, kill: { strictness: 2 } };
|
|
56
|
+
exports.STRICTEST_DENY_MODE = 'kill';
|
|
57
|
+
/** Alternative spellings for deny modes / scopes, for the back-compat layer. */
|
|
58
|
+
exports.DENY_MODE_ALIASES = {};
|
|
59
|
+
exports.DENY_SCOPE_ALIASES = {};
|
|
60
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
61
|
+
/**
|
|
62
|
+
* A cap value the meter can actually compare against.
|
|
63
|
+
*
|
|
64
|
+
* Rejects — never coerces — strings. `parseInt("lots")` is NaN and `Number("")` is 0; both would
|
|
65
|
+
* turn a control-plane typo into a disabled or absurd cap. A cap that cannot be read is a lease
|
|
66
|
+
* that cannot be trusted.
|
|
67
|
+
*/
|
|
68
|
+
function parseCapNumber(field, raw) {
|
|
69
|
+
if (typeof raw !== 'number') {
|
|
70
|
+
return { ok: false, reason: `${field} must be a number, got ${typeof raw} (${JSON.stringify(raw)})` };
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isFinite(raw)) {
|
|
73
|
+
return { ok: false, reason: `${field} must be finite, got ${raw}` };
|
|
74
|
+
}
|
|
75
|
+
if (raw <= 0) {
|
|
76
|
+
// 0 has meant "unlimited" for --max-turns-per-session since before caps existed, but from a
|
|
77
|
+
// control plane it is far more likely to be an unset field serialised as a number. Refuse
|
|
78
|
+
// rather than guess which meaning was intended.
|
|
79
|
+
return { ok: false, reason: `${field} must be greater than 0, got ${raw} (omit the field for "unbounded")` };
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isInteger(raw)) {
|
|
82
|
+
return { ok: false, reason: `${field} must be a whole number, got ${raw}` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, value: raw };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Validate a cap policy from an untrusted source.
|
|
88
|
+
*
|
|
89
|
+
* Also used by `capsFromEnv`, so a local flag and a control-plane field are held to exactly the
|
|
90
|
+
* same standard — the alternative is two vocabularies that drift.
|
|
91
|
+
*/
|
|
92
|
+
function parseCapPolicy(raw, where = 'caps') {
|
|
93
|
+
if (raw === undefined || raw === null) {
|
|
94
|
+
return { ok: true, caps: {}, warnings: [] };
|
|
95
|
+
}
|
|
96
|
+
if (!isPlainObject(raw)) {
|
|
97
|
+
return { ok: false, reason: `${where} must be an object` };
|
|
98
|
+
}
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const caps = {};
|
|
101
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
102
|
+
if (value === undefined || value === null) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (key === 'onBreach') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const canonical = (key in exports.LEASE_CAP_FIELDS ? key : exports.LEASE_CAP_ALIASES[key]);
|
|
109
|
+
if (!canonical) {
|
|
110
|
+
// Unknown cap fields are TOLERATED (a newer control plane may send a cap this build does
|
|
111
|
+
// not implement) but never silently: an operator must be able to see that the bound they
|
|
112
|
+
// configured is not being enforced by this version.
|
|
113
|
+
warnings.push(`${where}.${key} is not a cap this build understands and is NOT being enforced`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const parsed = parseCapNumber(`${where}.${key}`, value);
|
|
117
|
+
if (!parsed.ok) {
|
|
118
|
+
return { ok: false, reason: parsed.reason };
|
|
119
|
+
}
|
|
120
|
+
caps[canonical] = parsed.value;
|
|
121
|
+
}
|
|
122
|
+
const rawBreach = raw.onBreach;
|
|
123
|
+
if (rawBreach !== undefined && rawBreach !== null) {
|
|
124
|
+
if (typeof rawBreach !== 'string') {
|
|
125
|
+
return { ok: false, reason: `${where}.onBreach must be a string, got ${typeof rawBreach}` };
|
|
126
|
+
}
|
|
127
|
+
const canonical = (rawBreach in exports.ON_BREACH_ACTIONS ? rawBreach : exports.ON_BREACH_ALIASES[rawBreach]);
|
|
128
|
+
if (canonical) {
|
|
129
|
+
caps.onBreach = canonical;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// FAIL CLOSED, LOUDLY. Leaving it unset let an unrecognised value fall through mergeCaps
|
|
133
|
+
// and soften the breach action from destroy-sandbox to revoke-inference — a typo in the
|
|
134
|
+
// control plane weakening enforcement is the exact failure mode this guards.
|
|
135
|
+
caps.onBreach = exports.STRICTEST_ON_BREACH;
|
|
136
|
+
warnings.push(`${where}.onBreach="${rawBreach}" is not an action this build understands. ` +
|
|
137
|
+
`Failing CLOSED to "${exports.STRICTEST_ON_BREACH}" (the strictest known action). ` +
|
|
138
|
+
`Known actions: ${Object.keys(exports.ON_BREACH_ACTIONS).join(', ')}.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, caps, warnings };
|
|
142
|
+
}
|
|
143
|
+
function parseDenyRule(raw, index) {
|
|
144
|
+
if (!isPlainObject(raw)) {
|
|
145
|
+
return { ok: false, reason: `deny[${index}] must be an object` };
|
|
146
|
+
}
|
|
147
|
+
const warnings = [];
|
|
148
|
+
const rawScope = raw.scope;
|
|
149
|
+
if (typeof rawScope !== 'string') {
|
|
150
|
+
return { ok: false, reason: `deny[${index}].scope must be a string` };
|
|
151
|
+
}
|
|
152
|
+
const scope = exports.DENY_SCOPES.includes(rawScope)
|
|
153
|
+
? rawScope
|
|
154
|
+
: exports.DENY_SCOPE_ALIASES[rawScope];
|
|
155
|
+
if (!scope) {
|
|
156
|
+
// Rejecting the document is the fail-closed choice: a rule this build cannot evaluate is a
|
|
157
|
+
// stop order it would silently not apply, while the console reports it as applied.
|
|
158
|
+
return { ok: false, reason: `deny[${index}].scope "${rawScope}" is not a scope this build understands (known: ${exports.DENY_SCOPES.join(', ')})` };
|
|
159
|
+
}
|
|
160
|
+
if (typeof raw.match !== 'string' || raw.match.length === 0) {
|
|
161
|
+
return { ok: false, reason: `deny[${index}].match must be a non-empty string` };
|
|
162
|
+
}
|
|
163
|
+
const rawMode = raw.mode;
|
|
164
|
+
if (typeof rawMode !== 'string') {
|
|
165
|
+
return { ok: false, reason: `deny[${index}].mode must be a string` };
|
|
166
|
+
}
|
|
167
|
+
let mode = (rawMode in exports.DENY_MODES ? rawMode : exports.DENY_MODE_ALIASES[rawMode]);
|
|
168
|
+
if (!mode) {
|
|
169
|
+
// Same asymmetry as onBreach: clamp UP. A stop order whose mode we cannot read is treated as
|
|
170
|
+
// the strictest one, never dropped and never softened.
|
|
171
|
+
mode = exports.STRICTEST_DENY_MODE;
|
|
172
|
+
warnings.push(`deny[${index}].mode="${rawMode}" is not a mode this build understands. ` +
|
|
173
|
+
`Failing CLOSED to "${exports.STRICTEST_DENY_MODE}". Known modes: ${Object.keys(exports.DENY_MODES).join(', ')}.`);
|
|
174
|
+
}
|
|
175
|
+
const rule = { scope, match: raw.match, mode };
|
|
176
|
+
if (raw.reason !== undefined) {
|
|
177
|
+
if (typeof raw.reason !== 'string') {
|
|
178
|
+
return { ok: false, reason: `deny[${index}].reason must be a string` };
|
|
179
|
+
}
|
|
180
|
+
rule.reason = raw.reason;
|
|
181
|
+
}
|
|
182
|
+
if (raw.until !== undefined && raw.until !== null) {
|
|
183
|
+
if (typeof raw.until !== 'number' || !Number.isFinite(raw.until)) {
|
|
184
|
+
return { ok: false, reason: `deny[${index}].until must be a finite epoch-seconds number` };
|
|
185
|
+
}
|
|
186
|
+
rule.until = raw.until;
|
|
187
|
+
}
|
|
188
|
+
if (raw.user !== undefined && raw.user !== null) {
|
|
189
|
+
if (typeof raw.user !== 'string') {
|
|
190
|
+
return { ok: false, reason: `deny[${index}].user must be a string` };
|
|
191
|
+
}
|
|
192
|
+
rule.user = raw.user;
|
|
193
|
+
}
|
|
194
|
+
if (raw.followUser !== undefined && raw.followUser !== null) {
|
|
195
|
+
if (typeof raw.followUser !== 'boolean') {
|
|
196
|
+
return { ok: false, reason: `deny[${index}].followUser must be a boolean` };
|
|
197
|
+
}
|
|
198
|
+
rule.followUser = raw.followUser;
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, rule, warnings };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Parse and validate a whole lease document.
|
|
204
|
+
*
|
|
205
|
+
* **This is the exported validator the back-compat/alias work layers onto.** Adding a row to
|
|
206
|
+
* `LEASE_CAP_ALIASES`, `ON_BREACH_ALIASES`, `DENY_MODE_ALIASES` or `DENY_SCOPE_ALIASES` is
|
|
207
|
+
* sufficient; there is no second place that reads a lease field.
|
|
208
|
+
*/
|
|
209
|
+
function parseLease(raw) {
|
|
210
|
+
if (!isPlainObject(raw)) {
|
|
211
|
+
return { ok: false, reason: 'lease must be a JSON object' };
|
|
212
|
+
}
|
|
213
|
+
const warnings = [];
|
|
214
|
+
if (typeof raw.pack !== 'string' || raw.pack.length === 0) {
|
|
215
|
+
return { ok: false, reason: 'lease.pack must be a non-empty string' };
|
|
216
|
+
}
|
|
217
|
+
if (typeof raw.enabled !== 'boolean') {
|
|
218
|
+
// Not coerced. `enabled: "false"` is truthy in JavaScript, so a string here would lift the
|
|
219
|
+
// kill switch rather than apply it — the single most dangerous coercion in this document.
|
|
220
|
+
return { ok: false, reason: `lease.enabled must be a boolean, got ${typeof raw.enabled}` };
|
|
221
|
+
}
|
|
222
|
+
if (typeof raw.expiresAt !== 'number' || !Number.isFinite(raw.expiresAt)) {
|
|
223
|
+
return { ok: false, reason: `lease.expiresAt must be a finite epoch-seconds number, got ${JSON.stringify(raw.expiresAt)}` };
|
|
224
|
+
}
|
|
225
|
+
const lease = { pack: raw.pack, enabled: raw.enabled, expiresAt: raw.expiresAt };
|
|
226
|
+
for (const key of ['version', 'imageDigest', 'reason']) {
|
|
227
|
+
const v = raw[key];
|
|
228
|
+
if (v === undefined || v === null) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (typeof v !== 'string') {
|
|
232
|
+
return { ok: false, reason: `lease.${key} must be a string, got ${typeof v}` };
|
|
233
|
+
}
|
|
234
|
+
lease[key] = v;
|
|
235
|
+
}
|
|
236
|
+
for (const key of ['requireSignedPack', 'requireSignedLease']) {
|
|
237
|
+
const v = raw[key];
|
|
238
|
+
if (v === undefined || v === null) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (typeof v !== 'boolean') {
|
|
242
|
+
return { ok: false, reason: `lease.${key} must be a boolean, got ${typeof v}` };
|
|
243
|
+
}
|
|
244
|
+
lease[key] = v;
|
|
245
|
+
}
|
|
246
|
+
if (raw.caps !== undefined && raw.caps !== null) {
|
|
247
|
+
const caps = parseCapPolicy(raw.caps, 'lease.caps');
|
|
248
|
+
if (!caps.ok) {
|
|
249
|
+
return { ok: false, reason: caps.reason };
|
|
250
|
+
}
|
|
251
|
+
lease.caps = caps.caps;
|
|
252
|
+
warnings.push(...caps.warnings);
|
|
253
|
+
}
|
|
254
|
+
if (raw.deny !== undefined && raw.deny !== null) {
|
|
255
|
+
if (!Array.isArray(raw.deny)) {
|
|
256
|
+
return { ok: false, reason: 'lease.deny must be an array' };
|
|
257
|
+
}
|
|
258
|
+
const rules = [];
|
|
259
|
+
for (let i = 0; i < raw.deny.length; i++) {
|
|
260
|
+
const parsed = parseDenyRule(raw.deny[i], i);
|
|
261
|
+
if (!parsed.ok) {
|
|
262
|
+
return { ok: false, reason: parsed.reason };
|
|
263
|
+
}
|
|
264
|
+
rules.push(parsed.rule);
|
|
265
|
+
warnings.push(...parsed.warnings);
|
|
266
|
+
}
|
|
267
|
+
lease.deny = rules;
|
|
268
|
+
}
|
|
269
|
+
return { ok: true, lease, warnings };
|
|
270
|
+
}
|