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