@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.11
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/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- 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 +29 -25
- package/out/analytics/sessionEvents.js +100 -24
- 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 +126 -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 +151 -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 +351 -0
- package/out/cli/commands/serve.js +1145 -130
- package/out/cli/commands/store.js +12 -4
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +18 -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 +225 -0
- package/out/cli/deploy/layers/identity.js +88 -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 +171 -17
- 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 +260 -14
- 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 +111 -27
- package/out/engines/custom.js +15 -1
- package/out/evidence/sessionShip.js +643 -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 +25 -2
- 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 +326 -0
- package/out/pack/lease-schema.js +273 -0
- package/out/pack/lease.js +682 -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 +141 -0
- package/out/runner/protocol.js +128 -0
- package/out/runner/remoteEngine.js +166 -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 +572 -0
- package/out/sandbox/processDriver.js +318 -0
- package/out/sandbox/sandboxRunner.js +303 -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 +266 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
- package/out/scripts/analytics/setup-analytics.ps1 +45 -14
- package/out/scripts/analytics/setup-analytics.sh +106 -15
- 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 +460 -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-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +380 -0
- package/out/test/deploy-init.test.js +47 -1
- 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 +222 -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 +224 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/localFleetAdapter.test.js +91 -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 +406 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +227 -15
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +618 -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,682 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_LEASE_ENVELOPE_TTL_SECONDS = exports.LEASE_JWS_TYP = exports.LeaseDeniedError = exports.LEASE_MAX_STALENESS_ENV = exports.DEFAULT_LEASE_MAX_STALENESS_SECONDS = exports.MIN_LEASE_POLL_SECONDS = exports.DEFAULT_LEASE_POLL_SECONDS = void 0;
|
|
7
|
+
exports.resolveLeasePollSeconds = resolveLeasePollSeconds;
|
|
8
|
+
exports.leaseCacheSecondsFor = leaseCacheSecondsFor;
|
|
9
|
+
exports.effectiveKillLatencySeconds = effectiveKillLatencySeconds;
|
|
10
|
+
exports.leaseMaxStalenessFromEnv = leaseMaxStalenessFromEnv;
|
|
11
|
+
exports.resolveAllowServiceTurns = resolveAllowServiceTurns;
|
|
12
|
+
exports.describeServiceTurnGate = describeServiceTurnGate;
|
|
13
|
+
exports.denyVerdict = denyVerdict;
|
|
14
|
+
exports.resolveFollowedUsers = resolveFollowedUsers;
|
|
15
|
+
exports.leaseIsDead = leaseIsDead;
|
|
16
|
+
exports.policyVersion = policyVersion;
|
|
17
|
+
exports.assertAdmitted = assertAdmitted;
|
|
18
|
+
exports.signLease = signLease;
|
|
19
|
+
exports.verifySignedLease = verifySignedLease;
|
|
20
|
+
exports.requireSignedLease = requireSignedLease;
|
|
21
|
+
exports.createLeaseSource = createLeaseSource;
|
|
22
|
+
/**
|
|
23
|
+
* Lease + pinned version — the kill switch and rollback (PLAN/serve-v4-clean.md §5).
|
|
24
|
+
*
|
|
25
|
+
* Deliberately ONE mechanism for two jobs: "may this artifact run here, now?" is the same
|
|
26
|
+
* question whether it is asked commercially (entitlement) or operationally (emergency stop).
|
|
27
|
+
* It is checked at **sandbox creation**, which is why kill and rollback need no new subsystem:
|
|
28
|
+
*
|
|
29
|
+
* kill — revoke the lease ⇒ every instance refuses NEW sandboxes immediately;
|
|
30
|
+
* sandboxes already running are bounded by their TTL (eventual kill, no fleet loop),
|
|
31
|
+
* and can be ended now with `SandboxDriver.destroy` on the instance that owns them.
|
|
32
|
+
* rollback — pin `version` ⇒ new sandboxes run the pinned pack; running sandboxes drain by TTL.
|
|
33
|
+
* No hot-swap machinery: version convergence IS sandbox lifecycle.
|
|
34
|
+
*
|
|
35
|
+
* The lease is *fail-closed on expiry* but *fail-open on transport*: an unreachable control
|
|
36
|
+
* plane must not take a customer's fleet down, while an expired lease must not keep running.
|
|
37
|
+
* That asymmetry is deliberate and is the kind of thing that silently flips if unstated.
|
|
38
|
+
*/
|
|
39
|
+
const crypto_1 = require("crypto");
|
|
40
|
+
const warrant_1 = require("../authority/warrant");
|
|
41
|
+
const lease_schema_1 = require("./lease-schema");
|
|
42
|
+
const signature_1 = require("./signature");
|
|
43
|
+
/**
|
|
44
|
+
* The single source of truth for the lease poll interval.
|
|
45
|
+
*
|
|
46
|
+
* Kill latency is `poll + cache`, and those two numbers used to be written down in five places
|
|
47
|
+
* with two different defaults: the cache defaulted to 60s while every poll defaulted to 30s, so a
|
|
48
|
+
* fleet whose banner printed "lease poll 30s" actually took up to 90s to honour a kill switch —
|
|
49
|
+
* and the comment sitting next to the cache line warned about exactly that drift. Everything now
|
|
50
|
+
* derives from here.
|
|
51
|
+
*/
|
|
52
|
+
exports.DEFAULT_LEASE_POLL_SECONDS = 30;
|
|
53
|
+
/** Below this, polling costs more than it buys. */
|
|
54
|
+
exports.MIN_LEASE_POLL_SECONDS = 5;
|
|
55
|
+
function resolveLeasePollSeconds(requested) {
|
|
56
|
+
const n = typeof requested === 'number' && Number.isFinite(requested) ? requested : exports.DEFAULT_LEASE_POLL_SECONDS;
|
|
57
|
+
return Math.max(exports.MIN_LEASE_POLL_SECONDS, Math.floor(n));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* How long a fetched lease may be reused, derived from the poll interval.
|
|
61
|
+
*
|
|
62
|
+
* Equal to the poll: serve runs three independent timers on the poll cadence (policy provenance,
|
|
63
|
+
* pack convergence, the governor) and they must share one fetch rather than tripling load on the
|
|
64
|
+
* control plane. Because a cached copy can be up to `cache` seconds old when a poll reads it, the
|
|
65
|
+
* WORST-CASE latency from an operator hitting stop to this instance refusing a turn is
|
|
66
|
+
* `poll + cache`. That number is printed at boot rather than the poll interval alone —
|
|
67
|
+
* `effectiveKillLatencySeconds` exists so the banner cannot drift from the behaviour again.
|
|
68
|
+
*
|
|
69
|
+
* An operator who needs it faster does not lower the poll: `governor.refresh()` (wired to the
|
|
70
|
+
* admin endpoint / control-plane webhook) re-reads immediately.
|
|
71
|
+
*/
|
|
72
|
+
function leaseCacheSecondsFor(pollSeconds) {
|
|
73
|
+
return resolveLeasePollSeconds(pollSeconds);
|
|
74
|
+
}
|
|
75
|
+
/** Worst-case seconds between a control-plane change and this instance enforcing it. */
|
|
76
|
+
function effectiveKillLatencySeconds(pollSeconds) {
|
|
77
|
+
const poll = resolveLeasePollSeconds(pollSeconds);
|
|
78
|
+
return poll + leaseCacheSecondsFor(poll);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Ceiling on how long a lease may go unrefreshed before this instance refuses turns.
|
|
82
|
+
*
|
|
83
|
+
* The kill switch failed OPEN under network denial with no ceiling at all: `createLeaseSource`
|
|
84
|
+
* keeps the last lease on any transport error, bounded only by that lease's own `expiresAt` —
|
|
85
|
+
* which a control plane typically sets days or weeks out. An attacker who can drop packets to the
|
|
86
|
+
* control plane (or a customer who firewalls it) therefore keeps a revoked fleet running until the
|
|
87
|
+
* entitlement expires, and "revoke the lease" stops meaning "stop the fleet".
|
|
88
|
+
*
|
|
89
|
+
* The default is deliberately generous. Too low and a real control-plane outage becomes a
|
|
90
|
+
* customer-visible outage — the fail-open-on-transport property exists for good reasons and this
|
|
91
|
+
* must not quietly delete it. 24 hours is far longer than any plausible planned outage and far
|
|
92
|
+
* shorter than a typical lease lifetime, so it converts "indefinite" into "bounded" without
|
|
93
|
+
* bricking a deployment that has ever successfully polled. Set 0 to disable the ceiling entirely
|
|
94
|
+
* (explicitly accepting the unbounded behaviour); set it lower for a tighter guarantee.
|
|
95
|
+
*/
|
|
96
|
+
exports.DEFAULT_LEASE_MAX_STALENESS_SECONDS = 24 * 60 * 60;
|
|
97
|
+
exports.LEASE_MAX_STALENESS_ENV = 'AGENTHIPPO_LEASE_MAX_STALENESS_SECONDS';
|
|
98
|
+
function leaseMaxStalenessFromEnv(env = process.env) {
|
|
99
|
+
const raw = env[exports.LEASE_MAX_STALENESS_ENV]?.trim();
|
|
100
|
+
if (raw === undefined || raw === '') {
|
|
101
|
+
return exports.DEFAULT_LEASE_MAX_STALENESS_SECONDS;
|
|
102
|
+
}
|
|
103
|
+
const n = Number(raw);
|
|
104
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
105
|
+
return exports.DEFAULT_LEASE_MAX_STALENESS_SECONDS;
|
|
106
|
+
}
|
|
107
|
+
return Math.floor(n);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* May this deployment run turns with no human behind them (PLAN/agent-identity.md §5.6)?
|
|
111
|
+
*
|
|
112
|
+
* **Polarity note, because it is the opposite of every other flag here.** `requireSignedPack` and
|
|
113
|
+
* friends are TIGHTEN-ONLY: absent means "nobody asked for the restriction", so absent is
|
|
114
|
+
* permissive and that is safe. This flag GRANTS a standing unattended identity, so the same
|
|
115
|
+
* treatment would be exactly backwards — a control plane that has not been taught the field would
|
|
116
|
+
* silently authorise unsupervised runs across the fleet. Absent therefore means DENY wherever a
|
|
117
|
+
* control plane exists to have said otherwise.
|
|
118
|
+
*
|
|
119
|
+
* Unmanaged deployments (no lease URL configured at all) keep the existing local-first philosophy:
|
|
120
|
+
* there is no control plane to ask, so the operator's env is the whole answer. serve says so at
|
|
121
|
+
* boot rather than leaving it implicit.
|
|
122
|
+
*/
|
|
123
|
+
function resolveAllowServiceTurns(opts) {
|
|
124
|
+
if (!opts.managed) {
|
|
125
|
+
return { allowed: true, gate: 'unmanaged_no_lease' };
|
|
126
|
+
}
|
|
127
|
+
if (opts.lease === true) {
|
|
128
|
+
return { allowed: true, gate: 'lease_allows' };
|
|
129
|
+
}
|
|
130
|
+
// `undefined` and `false` are deliberately NOT distinguished in the outcome, only in the
|
|
131
|
+
// reason: both deny, so a control plane too old to emit the field is exactly as safe as one
|
|
132
|
+
// that emitted `false`.
|
|
133
|
+
return { allowed: false, gate: opts.lease === false ? 'lease_denies' : 'lease_absent_field' };
|
|
134
|
+
}
|
|
135
|
+
/** Operator-facing explanation for a `service_turn_denied` audit row and the boot banner. */
|
|
136
|
+
function describeServiceTurnGate(gate) {
|
|
137
|
+
switch (gate) {
|
|
138
|
+
case 'lease_allows':
|
|
139
|
+
return 'permitted by the control plane (lease.allowServiceTurns)';
|
|
140
|
+
case 'unmanaged_no_lease':
|
|
141
|
+
return 'permitted: no control plane configured, so AGENTHIPPO_SERVICE_USER is the only gate';
|
|
142
|
+
case 'lease_denies':
|
|
143
|
+
return 'refused: the control plane set lease.allowServiceTurns=false';
|
|
144
|
+
case 'lease_absent_field':
|
|
145
|
+
return 'refused: this is a managed deployment and the lease does not set allowServiceTurns '
|
|
146
|
+
+ '(absent means deny — enable it in the console)';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Does any live rule name this session?
|
|
151
|
+
*
|
|
152
|
+
* Session matching accepts BOTH the full session key and the bare `session_id` the caller sent,
|
|
153
|
+
* because serve namespaces keys by verified user (`u<hash>--<id>`) and an operator reading a
|
|
154
|
+
* support ticket has the bare id, not the hash. Accepting only the full key would make the
|
|
155
|
+
* console's most obvious input silently match nothing.
|
|
156
|
+
*
|
|
157
|
+
* User matching prefers the RESOLVED user id when the caller knows it, and falls back to the
|
|
158
|
+
* `u<hash>--` key prefix otherwise. The prefix alone was not enough: a gateway sender with no
|
|
159
|
+
* explicit session id gets the key `gateway-<userId>`, which has no `u<hash>--` prefix, so
|
|
160
|
+
* "stop this user" matched nothing for every Telegram/Slack conversation while the console
|
|
161
|
+
* reported the rule applied — a stop order that silently covers only some of a user's sessions is
|
|
162
|
+
* worse than one that fails loudly.
|
|
163
|
+
*/
|
|
164
|
+
function denyVerdict(lease, req, nowSeconds) {
|
|
165
|
+
if (!lease?.deny?.length) {
|
|
166
|
+
return { denied: false };
|
|
167
|
+
}
|
|
168
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
169
|
+
const sep = req.sessionKey.indexOf('--');
|
|
170
|
+
const bareSession = sep >= 0 ? req.sessionKey.slice(sep + 2) : req.sessionKey;
|
|
171
|
+
// `kill` outranks `drain` when both match, so a narrower emergency order is never softened
|
|
172
|
+
// by a broader routine one.
|
|
173
|
+
let verdict = { denied: false };
|
|
174
|
+
for (const rule of lease.deny) {
|
|
175
|
+
if (rule.until !== undefined && rule.until <= now) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let hit = false;
|
|
179
|
+
if (rule.scope === 'pack') {
|
|
180
|
+
hit = rule.match === req.pack;
|
|
181
|
+
}
|
|
182
|
+
else if (rule.scope === 'session') {
|
|
183
|
+
hit = rule.match === req.sessionKey || rule.match === bareSession;
|
|
184
|
+
// Identity binding (mitigation 2 above): a rule naming a user matches only when the
|
|
185
|
+
// verified identity agrees, so the same session id presented by another account misses.
|
|
186
|
+
// When the caller resolved NO identity we cannot contradict the rule, so the id-only
|
|
187
|
+
// match stands — refusing there would let an unauthenticated shape silently ignore a
|
|
188
|
+
// stop order.
|
|
189
|
+
if (hit && rule.user !== undefined && req.userId !== undefined && rule.user !== req.userId) {
|
|
190
|
+
hit = false;
|
|
191
|
+
}
|
|
192
|
+
// Rotation escape (mitigation 3): the same user under a different session id.
|
|
193
|
+
if (!hit && rule.followUser !== false && req.userId !== undefined && deniedByFollowedUser(rule, req.userId, req.followedUsers)) {
|
|
194
|
+
hit = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (rule.scope === 'user') {
|
|
198
|
+
// Exact identity when we have it; the namespaced-key prefix as the fallback for call
|
|
199
|
+
// sites that only hold a session key (the lease watcher sweeping live sandboxes).
|
|
200
|
+
hit = (req.userId !== undefined && rule.match === req.userId)
|
|
201
|
+
|| (req.userHash !== undefined && req.sessionKey.startsWith(`u${req.userHash(rule.match)}--`));
|
|
202
|
+
}
|
|
203
|
+
if (!hit) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const reason = `${rule.mode === 'kill' ? 'stopped' : 'draining'} by the control plane${rule.reason ? `: ${rule.reason}` : ''}`;
|
|
207
|
+
if (rule.mode === 'kill') {
|
|
208
|
+
return { denied: true, mode: 'kill', reason };
|
|
209
|
+
}
|
|
210
|
+
if (!verdict.denied) {
|
|
211
|
+
verdict = { denied: true, mode: 'drain', reason };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return verdict;
|
|
215
|
+
}
|
|
216
|
+
const followKey = (rule) => `${rule.scope}:${rule.match}`;
|
|
217
|
+
function deniedByFollowedUser(rule, userId, followed) {
|
|
218
|
+
return followed?.get(followKey(rule))?.has(userId) ?? false;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Which verified users a session-scoped rule reaches, given what we have learned about sessions.
|
|
222
|
+
*
|
|
223
|
+
* `sessionUsers` is the governor's sessionKey → verified user map, populated on the turn path.
|
|
224
|
+
* A session rule naming `s` therefore also covers every OTHER session belonging to whoever was
|
|
225
|
+
* last seen using `s` — which is what makes "stop this session" survive the caller simply sending
|
|
226
|
+
* a new `session_id`. Rules that set `followUser: false`, and rules for deployments where no
|
|
227
|
+
* identity was ever resolved, produce no entries and behave exactly as before.
|
|
228
|
+
*/
|
|
229
|
+
function resolveFollowedUsers(lease, sessionUsers, nowSeconds) {
|
|
230
|
+
const out = new Map();
|
|
231
|
+
if (!lease?.deny?.length) {
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
235
|
+
for (const rule of lease.deny) {
|
|
236
|
+
if (rule.scope !== 'session' || rule.followUser === false) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (rule.until !== undefined && rule.until <= now) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const users = new Set();
|
|
243
|
+
for (const [sessionKey, userId] of sessionUsers) {
|
|
244
|
+
const sep = sessionKey.indexOf('--');
|
|
245
|
+
const bare = sep >= 0 ? sessionKey.slice(sep + 2) : sessionKey;
|
|
246
|
+
if (rule.match !== sessionKey && rule.match !== bare) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// An explicitly identity-bound rule must not widen itself to a different account.
|
|
250
|
+
if (rule.user !== undefined && rule.user !== userId) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
users.add(userId);
|
|
254
|
+
}
|
|
255
|
+
if (users.size > 0) {
|
|
256
|
+
out.set(followKey(rule), users);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* True when the lease says "stop now" — revoked, disabled, or expired.
|
|
263
|
+
*
|
|
264
|
+
* `assertAdmitted` refuses NEW sandboxes; this is what a watcher uses to also end the ones already
|
|
265
|
+
* running. Without it an emergency stop takes up to a full sandbox TTL to take effect, which is not
|
|
266
|
+
* what an operator hitting a kill switch means by "stop".
|
|
267
|
+
*/
|
|
268
|
+
function leaseIsDead(lease, nowSeconds) {
|
|
269
|
+
// No lease ⇒ unmanaged deployment; nothing to kill. Same rule as admission.
|
|
270
|
+
if (!lease) {
|
|
271
|
+
return { dead: false };
|
|
272
|
+
}
|
|
273
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
274
|
+
if (!lease.enabled) {
|
|
275
|
+
return { dead: true, reason: `kill switch active${lease.reason ? `: ${lease.reason}` : ''}` };
|
|
276
|
+
}
|
|
277
|
+
if (lease.expiresAt <= now) {
|
|
278
|
+
return { dead: true, reason: `lease expired at ${new Date(lease.expiresAt * 1000).toISOString()}` };
|
|
279
|
+
}
|
|
280
|
+
return { dead: false };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Version of the policy in force — sha256 over the canonicalised lease (sorted keys,
|
|
284
|
+
* `expiresAt` excluded: it changes every poll and is not policy).
|
|
285
|
+
*
|
|
286
|
+
* This is what makes "was the cap enforced?" answerable after anyone edits a cap
|
|
287
|
+
* (PLAN/compliance.md §3.1, R-3 policy provenance): a turn stamped with this value can be tied
|
|
288
|
+
* to the exact rules that governed it, not to whatever the lease says at audit time.
|
|
289
|
+
*/
|
|
290
|
+
function policyVersion(lease) {
|
|
291
|
+
if (!lease) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const { expiresAt: _ignored, ...policy } = lease;
|
|
295
|
+
const canonical = (v) => {
|
|
296
|
+
if (Array.isArray(v)) {
|
|
297
|
+
return v.map(canonical);
|
|
298
|
+
}
|
|
299
|
+
if (v && typeof v === 'object') {
|
|
300
|
+
return Object.fromEntries(Object.keys(v).sort()
|
|
301
|
+
.map((k) => [k, canonical(v[k])]));
|
|
302
|
+
}
|
|
303
|
+
return v;
|
|
304
|
+
};
|
|
305
|
+
return (0, crypto_1.createHash)('sha256').update(JSON.stringify(canonical(policy))).digest('hex');
|
|
306
|
+
}
|
|
307
|
+
class LeaseDeniedError extends Error {
|
|
308
|
+
constructor(message) {
|
|
309
|
+
super(message);
|
|
310
|
+
this.name = 'LeaseDeniedError';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
exports.LeaseDeniedError = LeaseDeniedError;
|
|
314
|
+
/**
|
|
315
|
+
* The admission decision. Throws `LeaseDeniedError` — the caller (`createSandboxRunner.admit`)
|
|
316
|
+
* lets it propagate so no sandbox is ever created for refused work.
|
|
317
|
+
*/
|
|
318
|
+
function assertAdmitted(lease, req) {
|
|
319
|
+
// No lease configured at all ⇒ unlicensed/unmanaged deployment: allow. Self-hosted dev and
|
|
320
|
+
// the free tier must keep working without a control plane. Enforcement begins once an
|
|
321
|
+
// operator configures one.
|
|
322
|
+
if (!lease) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const now = req.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
326
|
+
if (!lease.enabled) {
|
|
327
|
+
throw new LeaseDeniedError(`kill switch active for pack ${req.pack}${lease.reason ? `: ${lease.reason}` : ''}`);
|
|
328
|
+
}
|
|
329
|
+
if (lease.pack !== req.pack) {
|
|
330
|
+
throw new LeaseDeniedError(`lease is for pack ${lease.pack}, not ${req.pack}`);
|
|
331
|
+
}
|
|
332
|
+
if (lease.expiresAt <= now) {
|
|
333
|
+
throw new LeaseDeniedError(`lease for ${req.pack} expired at ${new Date(lease.expiresAt * 1000).toISOString()}`);
|
|
334
|
+
}
|
|
335
|
+
// Rollback: the pinned version is the only one admitted. Running sandboxes drain on TTL.
|
|
336
|
+
if (lease.version && lease.version !== req.version) {
|
|
337
|
+
throw new LeaseDeniedError(`pack ${req.pack} is pinned to ${lease.version}; refusing to start ${req.version} (rollback in force)`);
|
|
338
|
+
}
|
|
339
|
+
// Supply-chain pin: the signed digest, not a mutable tag.
|
|
340
|
+
if (lease.imageDigest && req.image && !req.image.includes(lease.imageDigest)) {
|
|
341
|
+
throw new LeaseDeniedError(`image for ${req.pack} does not match the leased digest ${lease.imageDigest}`);
|
|
342
|
+
}
|
|
343
|
+
// Scoped stop orders. Both modes refuse a NEW sandbox — they differ only in what happens to one
|
|
344
|
+
// already running, which is the lease watcher's job, not admission's.
|
|
345
|
+
if (req.sessionKey) {
|
|
346
|
+
const verdict = denyVerdict(lease, {
|
|
347
|
+
sessionKey: req.sessionKey,
|
|
348
|
+
pack: req.pack,
|
|
349
|
+
userHash: req.userHash,
|
|
350
|
+
userId: req.userId,
|
|
351
|
+
followedUsers: req.followedUsers,
|
|
352
|
+
}, now);
|
|
353
|
+
if (verdict.denied) {
|
|
354
|
+
throw new LeaseDeniedError(verdict.reason ?? 'stopped by the control plane');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// ---------------------------------------------------------------------------------------------
|
|
359
|
+
// Signed leases
|
|
360
|
+
// ---------------------------------------------------------------------------------------------
|
|
361
|
+
/**
|
|
362
|
+
* WIRE FORMAT — signed lease.
|
|
363
|
+
*
|
|
364
|
+
* A **compact JWS (ES256)** whose payload is the `LeaseEnvelopeClaims` object above; identical
|
|
365
|
+
* machinery to `pack.sig` and to inference warrants, verified through the same
|
|
366
|
+
* `verifyCompactJws` (alg pinned to ES256, `kid` must already be known, raw R||S encoding) — but
|
|
367
|
+
* against its OWN JWKS trust root (`loadLeaseTrustRoot`, purpose `lease-signing`), not the pack
|
|
368
|
+
* one. A key that can vouch for a lease should not thereby be able to vouch for code, and the
|
|
369
|
+
* control plane will not register one kid under both purposes anyway.
|
|
370
|
+
*
|
|
371
|
+
* header { "alg": "ES256", "typ": "agenthippo-lease+jws", "kid": "<key id>" }
|
|
372
|
+
* payload { "lease": { …Lease… }, "aud": "<instance-id>" | "*", "sub": "<pack>",
|
|
373
|
+
* "iat": <epoch s>, "exp": <epoch s>, "iss"?: "…", "jti"?: "…" }
|
|
374
|
+
*
|
|
375
|
+
* The endpoint may answer with any of:
|
|
376
|
+
* 1. the compact JWS as the response body (`text/plain` or a JSON string),
|
|
377
|
+
* 2. `{"lease": "<compact JWS>"}` — an envelope, so an endpoint can add unsigned metadata,
|
|
378
|
+
* 3. a bare `Lease` JSON object — LEGACY, unsigned, accepted only while signing is not required.
|
|
379
|
+
*
|
|
380
|
+
* `typ` prevents a pack signature (`typ: JWT`) from being replayed as a lease under the same key.
|
|
381
|
+
*
|
|
382
|
+
* Why each binding is present:
|
|
383
|
+
*
|
|
384
|
+
* aud the instance this lease was minted for. A lease captured from one instance cannot be
|
|
385
|
+
* replayed at another. `*` is a legitimate fleet-wide lease and is accepted as such.
|
|
386
|
+
* sub the pack, cross-checked against `lease.pack`, so a lease for one product cannot be
|
|
387
|
+
* re-labelled as another after signing.
|
|
388
|
+
* iat monotonic per instance: an envelope older than the newest one already accepted is
|
|
389
|
+
* refused. This is the anti-ROLLBACK binding, and it is the one that matters most —
|
|
390
|
+
* without it an on-path attacker replays yesterday's `enabled: true` lease forever and the
|
|
391
|
+
* kill switch never lands. `governor.checkLease` treats a restored lease as "lease
|
|
392
|
+
* restored — accepting turns again", so a replay does not merely delay a kill, it lifts
|
|
393
|
+
* one that has already latched.
|
|
394
|
+
* exp the ENVELOPE's own expiry, separate from `lease.expiresAt` (an entitlement boundary that
|
|
395
|
+
* is legitimately weeks out). Capped at `MAX_LEASE_ENVELOPE_TTL_SECONDS`, for the same
|
|
396
|
+
* reason warrants are capped: there is no revocation list, so lifetime IS the revocation.
|
|
397
|
+
*/
|
|
398
|
+
exports.LEASE_JWS_TYP = 'agenthippo-lease+jws';
|
|
399
|
+
/** Longer than this and the envelope is a credential, not a lease refresh. */
|
|
400
|
+
exports.MAX_LEASE_ENVELOPE_TTL_SECONDS = 3600;
|
|
401
|
+
/** Mint a signed lease. Used by the control plane and by tests; serve only ever verifies. */
|
|
402
|
+
function signLease(lease, key, input) {
|
|
403
|
+
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
404
|
+
const ttl = Math.min(input.ttlSeconds ?? 300, exports.MAX_LEASE_ENVELOPE_TTL_SECONDS);
|
|
405
|
+
const claims = {
|
|
406
|
+
lease,
|
|
407
|
+
aud: input.instanceId ?? '*',
|
|
408
|
+
sub: lease.pack,
|
|
409
|
+
iat: now,
|
|
410
|
+
exp: now + ttl,
|
|
411
|
+
...(input.iss ? { iss: input.iss } : {}),
|
|
412
|
+
};
|
|
413
|
+
return (0, warrant_1.signCompactJws)({ typ: exports.LEASE_JWS_TYP, kid: key.kid }, claims, key.privateKeyPem);
|
|
414
|
+
}
|
|
415
|
+
function verifySignedLease(token, publicKeysByKid, input = {}) {
|
|
416
|
+
if (Object.keys(publicKeysByKid).length === 0) {
|
|
417
|
+
return { ok: false, reason: 'no trusted lease-signing keys are configured on this instance' };
|
|
418
|
+
}
|
|
419
|
+
const jws = (0, warrant_1.verifyCompactJws)(token, publicKeysByKid, { typ: exports.LEASE_JWS_TYP });
|
|
420
|
+
if (!jws.ok) {
|
|
421
|
+
return { ok: false, reason: `lease signature: ${jws.reason}` };
|
|
422
|
+
}
|
|
423
|
+
const claims = jws.payload;
|
|
424
|
+
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
425
|
+
const skew = input.clockSkewSeconds ?? 60;
|
|
426
|
+
if (typeof claims.iat !== 'number' || !Number.isFinite(claims.iat)) {
|
|
427
|
+
return { ok: false, reason: 'lease envelope has no issued-at' };
|
|
428
|
+
}
|
|
429
|
+
if (typeof claims.exp !== 'number' || !Number.isFinite(claims.exp)) {
|
|
430
|
+
return { ok: false, reason: 'lease envelope has no expiry' };
|
|
431
|
+
}
|
|
432
|
+
if (claims.exp - claims.iat > exports.MAX_LEASE_ENVELOPE_TTL_SECONDS) {
|
|
433
|
+
return { ok: false, reason: `lease envelope lifetime ${claims.exp - claims.iat}s exceeds the ${exports.MAX_LEASE_ENVELOPE_TTL_SECONDS}s cap` };
|
|
434
|
+
}
|
|
435
|
+
if (claims.exp + skew <= now) {
|
|
436
|
+
return { ok: false, reason: `lease envelope expired at ${new Date(claims.exp * 1000).toISOString()}` };
|
|
437
|
+
}
|
|
438
|
+
if (claims.iat - skew > now) {
|
|
439
|
+
return { ok: false, reason: 'lease envelope is issued in the future' };
|
|
440
|
+
}
|
|
441
|
+
if (input.minIat !== undefined && claims.iat < input.minIat) {
|
|
442
|
+
return { ok: false, reason: `lease envelope is older than one already accepted (replay/rollback: iat ${claims.iat} < ${input.minIat})` };
|
|
443
|
+
}
|
|
444
|
+
if (input.instanceId !== undefined && claims.aud !== '*' && claims.aud !== input.instanceId) {
|
|
445
|
+
return { ok: false, reason: `lease envelope is for instance ${claims.aud}, not ${input.instanceId}` };
|
|
446
|
+
}
|
|
447
|
+
const parsed = (0, lease_schema_1.parseLease)(claims.lease);
|
|
448
|
+
if (!parsed.ok) {
|
|
449
|
+
return { ok: false, reason: `signed lease is malformed: ${parsed.reason}` };
|
|
450
|
+
}
|
|
451
|
+
if (claims.sub !== parsed.lease.pack) {
|
|
452
|
+
return { ok: false, reason: `lease envelope subject ${claims.sub} does not match lease.pack ${parsed.lease.pack}` };
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, lease: parsed.lease, claims };
|
|
455
|
+
}
|
|
456
|
+
/** Tighten-only, exactly like `requireSignedPack`. Neither side may waive the other's demand. */
|
|
457
|
+
function requireSignedLease(leaseValue, localValue) {
|
|
458
|
+
return Boolean(leaseValue) || Boolean(localValue);
|
|
459
|
+
}
|
|
460
|
+
const looksLikeCompactJws = (s) => /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(s);
|
|
461
|
+
/**
|
|
462
|
+
* Caching lease source.
|
|
463
|
+
*
|
|
464
|
+
* Fail-OPEN on transport (network down ⇒ keep serving on the last known lease), fail-CLOSED on
|
|
465
|
+
* content (an expired or disabled lease refuses, cached or not). Three things changed:
|
|
466
|
+
*
|
|
467
|
+
* 1. The body is VALIDATED (`parseLease`), not cast. A document that does not parse is treated
|
|
468
|
+
* exactly like a transport failure — the last good lease is kept and the failure is loud —
|
|
469
|
+
* rather than being installed as policy with `NaN` caps and unreadable deny rules.
|
|
470
|
+
* 2. A SIGNED lease is verified against the LEASE trust root. Unsigned leases still work, and say
|
|
471
|
+
* so loudly, unless signing is required — in which case they are refused.
|
|
472
|
+
* 3. Staleness is BOUNDED. Past `maxStalenessSeconds` without a successful refresh, `get`
|
|
473
|
+
* returns a synthetic disabled lease so admission and the governor both fail closed. Network
|
|
474
|
+
* denial can no longer hold a revoked fleet open indefinitely.
|
|
475
|
+
*/
|
|
476
|
+
function createLeaseSource(opts) {
|
|
477
|
+
let cached = opts.staticLease;
|
|
478
|
+
let fetchedAt = 0;
|
|
479
|
+
/** Wall-clock of the last SUCCESSFUL fetch. Distinct from `fetchedAt`, which drives the cache. */
|
|
480
|
+
let lastGoodAt;
|
|
481
|
+
let lastError;
|
|
482
|
+
let sawSignedLease = false;
|
|
483
|
+
/** Sticky: once the plane has demanded signed leases, an unsigned response cannot undo it. */
|
|
484
|
+
let leaseDemandsSignature = false;
|
|
485
|
+
let newestIat;
|
|
486
|
+
let staleWarned = false;
|
|
487
|
+
let unsignedWarned = false;
|
|
488
|
+
let urlChecked = false;
|
|
489
|
+
const ttl = opts.cacheSeconds ?? leaseCacheSecondsFor(exports.DEFAULT_LEASE_POLL_SECONDS);
|
|
490
|
+
const maxStaleness = opts.maxStalenessSeconds ?? leaseMaxStalenessFromEnv(opts.env);
|
|
491
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
492
|
+
const signingRequired = () => requireSignedLease(leaseDemandsSignature, opts.requireSignedLease);
|
|
493
|
+
/**
|
|
494
|
+
* What `get` hands back once the ceiling is passed. A synthetic lease rather than `undefined`
|
|
495
|
+
* because `undefined` means "unmanaged, admit everything" everywhere in this file — returning it
|
|
496
|
+
* here would fail OPEN, which is the bug.
|
|
497
|
+
*/
|
|
498
|
+
const staleLease = (staleFor) => ({
|
|
499
|
+
...(cached ?? { pack: '', expiresAt: 0 }),
|
|
500
|
+
enabled: false,
|
|
501
|
+
reason: `the control plane has not been reachable for ${staleFor}s, past the ${maxStaleness}s staleness ceiling ` +
|
|
502
|
+
`(${exports.LEASE_MAX_STALENESS_ENV}). Refusing work rather than running on a lease that can no longer be revoked. ` +
|
|
503
|
+
`Set ${exports.LEASE_MAX_STALENESS_ENV}=0 to accept unbounded staleness.`,
|
|
504
|
+
});
|
|
505
|
+
const parseBody = (text, now) => {
|
|
506
|
+
const trimmed = text.trim();
|
|
507
|
+
let jws;
|
|
508
|
+
let jsonBody;
|
|
509
|
+
if (looksLikeCompactJws(trimmed)) {
|
|
510
|
+
jws = trimmed;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
try {
|
|
514
|
+
jsonBody = JSON.parse(trimmed);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
return { ok: false, reason: `lease body is not JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
518
|
+
}
|
|
519
|
+
if (typeof jsonBody === 'string' && looksLikeCompactJws(jsonBody.trim())) {
|
|
520
|
+
jws = jsonBody.trim();
|
|
521
|
+
}
|
|
522
|
+
else if (jsonBody && typeof jsonBody === 'object' && typeof jsonBody.lease === 'string') {
|
|
523
|
+
jws = (jsonBody.lease).trim();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (jws) {
|
|
527
|
+
const verified = verifySignedLease(jws, opts.signingKeys?.() ?? {}, {
|
|
528
|
+
instanceId: opts.instanceId,
|
|
529
|
+
minIat: newestIat,
|
|
530
|
+
nowSeconds: now,
|
|
531
|
+
});
|
|
532
|
+
if (!verified.ok) {
|
|
533
|
+
return { ok: false, reason: verified.reason };
|
|
534
|
+
}
|
|
535
|
+
newestIat = verified.claims.iat;
|
|
536
|
+
return { ok: true, lease: verified.lease, signed: true };
|
|
537
|
+
}
|
|
538
|
+
if (signingRequired()) {
|
|
539
|
+
return {
|
|
540
|
+
ok: false,
|
|
541
|
+
reason: 'a signed lease is required but the control plane returned an unsigned one ' +
|
|
542
|
+
'(expected a compact JWS body or {"lease":"<jws>"})',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const parsed = (0, lease_schema_1.parseLease)(jsonBody);
|
|
546
|
+
if (!parsed.ok) {
|
|
547
|
+
return { ok: false, reason: parsed.reason };
|
|
548
|
+
}
|
|
549
|
+
for (const w of parsed.warnings) {
|
|
550
|
+
opts.logger?.warn(`[Lease] ${w}`);
|
|
551
|
+
}
|
|
552
|
+
return { ok: true, lease: parsed.lease, signed: false };
|
|
553
|
+
};
|
|
554
|
+
return {
|
|
555
|
+
status(nowSeconds) {
|
|
556
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
557
|
+
const staleFor = lastGoodAt === undefined ? undefined : now - lastGoodAt;
|
|
558
|
+
return {
|
|
559
|
+
signed: sawSignedLease,
|
|
560
|
+
requireSignedLease: signingRequired(),
|
|
561
|
+
lastGoodAt,
|
|
562
|
+
stalenessSeconds: staleFor,
|
|
563
|
+
maxStalenessSeconds: maxStaleness,
|
|
564
|
+
failClosed: maxStaleness > 0 && staleFor !== undefined && staleFor > maxStaleness,
|
|
565
|
+
lastError,
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
async get(nowSeconds) {
|
|
569
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
570
|
+
if (!opts.url) {
|
|
571
|
+
return cached;
|
|
572
|
+
}
|
|
573
|
+
// The lease decides whether this instance may run at all, so the channel it arrives on is
|
|
574
|
+
// checked too — but WARNED about rather than refused, and the asymmetry with the trust
|
|
575
|
+
// root is deliberate. Refusing to poll a plaintext lease URL would stop the kill switch
|
|
576
|
+
// from arriving at all: it fails OPEN, which is strictly worse than polling an
|
|
577
|
+
// interceptable channel. The trust root refuses because refusing there fails CLOSED
|
|
578
|
+
// (packs stop verifying). The real fix for a plaintext lease is `requireSignedLease` —
|
|
579
|
+
// most of what an on-path device could do is undone once the signature, not the
|
|
580
|
+
// transport, is what is being trusted.
|
|
581
|
+
//
|
|
582
|
+
// There used to be a `leaseUrlRefused` flag here, declared and read but never assigned:
|
|
583
|
+
// its comment promised "we never poll it at all", which is precisely the refusal the
|
|
584
|
+
// paragraph above rules out. It has been deleted rather than wired up — a live flag that
|
|
585
|
+
// bricks a fleet's kill switch is worse than a dead one.
|
|
586
|
+
//
|
|
587
|
+
// The warning fires in EVERY insecure case. It used to be guarded by
|
|
588
|
+
// `else if (!signingRequired())`, so the case an operator most needs to hear about — one
|
|
589
|
+
// who has demanded signed leases and therefore believes the channel is accounted for —
|
|
590
|
+
// was the only silent one.
|
|
591
|
+
if (!urlChecked) {
|
|
592
|
+
urlChecked = true;
|
|
593
|
+
const secure = (0, signature_1.assertSecureTrustUrl)(opts.url, 'AGENTHIPPO_LEASE_URL', opts.env ?? process.env);
|
|
594
|
+
if (secure.ok) {
|
|
595
|
+
for (const w of secure.warnings) {
|
|
596
|
+
opts.logger?.warn(`[Lease] ${w}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else if (signingRequired()) {
|
|
600
|
+
// Signing covers the CONTENT, not the channel. What is left is still governance:
|
|
601
|
+
// the poll carries this instance's id, pack and version in the clear, and an
|
|
602
|
+
// on-path device can withhold a kill switch by replaying the last envelope until
|
|
603
|
+
// its own `exp` runs out.
|
|
604
|
+
opts.logger?.warn(`[Lease] INSECURE LEASE CHANNEL — ${secure.reason} Signed leases are required here, so the ` +
|
|
605
|
+
'policy itself is still verified — but the channel is not: anyone on the path can read this ' +
|
|
606
|
+
`instance's identity, pack and version, and can delay a kill switch by up to ` +
|
|
607
|
+
`${exports.MAX_LEASE_ENVELOPE_TTL_SECONDS}s by replaying the last envelope. requireSignedLease is not a ` +
|
|
608
|
+
'substitute for https; serve the lease over https.');
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
opts.logger?.warn(`[Lease] INSECURE LEASE CHANNEL — ${secure.reason} Anyone on the path can lift this instance's ` +
|
|
612
|
+
'kill switch, widen its caps or change its version pin. Continuing to poll (refusing would ' +
|
|
613
|
+
'disable the kill switch entirely), but this deployment is not governable until the lease is ' +
|
|
614
|
+
'signed: set requireSignedLease and have the control plane sign leases.');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (cached && now - fetchedAt < ttl) {
|
|
618
|
+
return cached;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const headers = { accept: 'application/json, text/plain' };
|
|
622
|
+
for (const [key, value] of Object.entries(opts.identity?.() ?? {})) {
|
|
623
|
+
if (value !== undefined && value !== '') {
|
|
624
|
+
headers[`x-agenthippo-${key}`] = String(value);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const res = await doFetch(opts.url, { headers });
|
|
628
|
+
if (!res.ok) {
|
|
629
|
+
throw new Error(`lease fetch ${res.status}`);
|
|
630
|
+
}
|
|
631
|
+
const text = typeof res.text === 'function'
|
|
632
|
+
? await res.text()
|
|
633
|
+
// Test doubles and older shims implement only `json()`.
|
|
634
|
+
: JSON.stringify(await res.json());
|
|
635
|
+
const parsed = parseBody(text, now);
|
|
636
|
+
if (!parsed.ok) {
|
|
637
|
+
// A garbled or unverifiable document is NOT installed as policy. It behaves like a
|
|
638
|
+
// transport failure, which is the only reading that cannot be used to weaken
|
|
639
|
+
// enforcement by publishing junk.
|
|
640
|
+
throw new Error(parsed.reason);
|
|
641
|
+
}
|
|
642
|
+
if (parsed.signed) {
|
|
643
|
+
sawSignedLease = true;
|
|
644
|
+
if (parsed.lease.requireSignedLease) {
|
|
645
|
+
leaseDemandsSignature = true;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else if (!unsignedWarned) {
|
|
649
|
+
unsignedWarned = true;
|
|
650
|
+
opts.logger?.warn('[Lease] running on an UNSIGNED lease. Anyone who can answer or intercept ' +
|
|
651
|
+
`${opts.url} can lift this instance's kill switch, widen its caps and change its version pin. ` +
|
|
652
|
+
'Have the control plane sign leases (ES256 compact JWS) and set requireSignedLease.');
|
|
653
|
+
}
|
|
654
|
+
cached = parsed.lease;
|
|
655
|
+
fetchedAt = now;
|
|
656
|
+
lastGoodAt = now;
|
|
657
|
+
lastError = undefined;
|
|
658
|
+
staleWarned = false;
|
|
659
|
+
return cached;
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
663
|
+
// Transport failure keeps the LAST lease, which still expires on its own clock —
|
|
664
|
+
// an outage cannot extend an entitlement, only preserve one already granted.
|
|
665
|
+
opts.logger?.warn(`[Lease] fetch failed, using cached lease: ${lastError}`);
|
|
666
|
+
if (lastGoodAt === undefined) {
|
|
667
|
+
lastGoodAt = now;
|
|
668
|
+
}
|
|
669
|
+
const staleFor = now - lastGoodAt;
|
|
670
|
+
if (maxStaleness > 0 && staleFor > maxStaleness) {
|
|
671
|
+
if (!staleWarned) {
|
|
672
|
+
staleWarned = true;
|
|
673
|
+
opts.logger?.warn(`[Lease] STALENESS CEILING PASSED — no successful lease refresh for ${staleFor}s ` +
|
|
674
|
+
`(ceiling ${maxStaleness}s). Failing closed: turns are refused until the control plane is reachable.`);
|
|
675
|
+
}
|
|
676
|
+
return staleLease(staleFor);
|
|
677
|
+
}
|
|
678
|
+
return cached;
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|