@elliotsystems/eai 0.1.7 → 0.1.14

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Keeps the Jev guardrail's per-request activation current with the team's
3
+ * REAL, live `Team.jev_policy_enabled` — instead of the one-shot snapshot
4
+ * `eai setup` bakes into `opencode.json`'s static `options.metadata.
5
+ * guardrails` (see elliot-launcher/lib/setup.js's own `elliotProvider`).
6
+ *
7
+ * ## The gap this closes (CLI session-recovery)
8
+ *
9
+ * Web Chat's equivalent bug (terminal/src/opencode/useOpencodeChat.ts) was:
10
+ * an admin's `jev_policy_enabled` change doesn't reach an already-running
11
+ * OpenCode instance until that instance is torn down and rebuilt — fixed on
12
+ * the Web Chat side by (a) checking team-level drift on every request
13
+ * (backend `_reconcile`) and (b) transparently starting a fresh session when
14
+ * the old one's instance is gone.
15
+ *
16
+ * NEITHER half of that fix has a real analogue for the EAI CLI, and this
17
+ * module exists because of that, not in spite of it:
18
+ *
19
+ * - There is no "OpenCode server instance" to replace. Web Chat's
20
+ * OpenCode runtimes are backend-hosted containers/subprocesses, keyed by
21
+ * team, with their own lifecycle (services/opencode/manager.py). The
22
+ * CLI's OpenCode process is the user's own local machine running the
23
+ * real upstream `opencode` binary (elliot-launcher/bin/eai just
24
+ * `spawn`s it with `stdio: "inherit"`) — nothing on Elliot's backend
25
+ * ever tears it down or replaces it. A "replaced instance" literally
26
+ * cannot happen to it.
27
+ * - `elliot-launcher`'s `eai` has zero visibility into the TUI's traffic
28
+ * once spawned (inherited stdio, a real full-screen terminal app) and
29
+ * the OpenCode plugin API (opencode/packages/plugin/src/index.ts) has NO
30
+ * hook that observes a chat response or error — `tool.execute.after`
31
+ * only covers TOOL calls, not the chat completion itself. So "detect
32
+ * this message got blocked, then resend it" is not implementable from
33
+ * either layer available to this integration: there is no hook to
34
+ * detect it from.
35
+ *
36
+ * What IS real, and what this fixes: the CLI's guardrail activation is a
37
+ * STATIC snapshot, taken once at `eai setup` and never touched again for the
38
+ * life of that file. An admin flipping the toggle after that leaves the CLI
39
+ * silently wrong in BOTH directions until the user re-runs `eai setup`:
40
+ * - turned ON after setup -> the CLI never guards anything (a real
41
+ * enforcement gap, not just a UX one);
42
+ * - turned OFF after setup -> the CLI keeps blocking every single prompt
43
+ * forever (the exact behavior reported: "for every prompts... fix it").
44
+ *
45
+ * `chat.params` (opencode/packages/plugin/src/index.ts) is the one hook that
46
+ * runs on EVERY outgoing request, before it is sent, with write access to
47
+ * the exact `options` object that becomes the request body (traced through
48
+ * session/llm/request.ts: `options = mergeOptions(base, model.options, ...)`
49
+ * is passed into `chat.params`'s `output.options`, and `model.options` is
50
+ * precisely where `eai setup` wrote `metadata.guardrails`). Using it here
51
+ * means the correct, CURRENT guardrail state is used on the message's one
52
+ * and only attempt — there is no failure to recover from, no retry, and
53
+ * therefore none of the duplicate-request/duplicate-tool-execution risk a
54
+ * retry-based design would carry.
55
+ *
56
+ * ## Fail-safe direction
57
+ *
58
+ * A live check that cannot complete (offline, backend down, expired token)
59
+ * returns `null` and changes NOTHING — the request goes out with whatever
60
+ * the static config already had, exactly as it did before this module
61
+ * existed. This can under-protect (an admin's brand-new enable is missed
62
+ * while offline) but never over-blocks and never throws, which is the
63
+ * right trade for a CLI tool that must keep working offline: this was never
64
+ * the enforcement boundary (LiteLLM's own guardrail is, and it fails CLOSED
65
+ * on its own uncertainty — see jev_decisions_logic.py's CONFIDENCE_THRESHOLD
66
+ * — because THAT failure is security-load-bearing). This module only
67
+ * decides which flag to send; a transport failure here is an availability
68
+ * question, not a security one.
69
+ */
70
+ import { readElliotConfig, type ElliotConfig } from "./elliot-auth.js"
71
+
72
+ export const JEV_GUARDRAIL_NAME = "jev-policy"
73
+
74
+ /**
75
+ * Jev audit (2026-10, G2): the POST-CALL counterpart to `JEV_GUARDRAIL_NAME`
76
+ * — must match `Team.jev_output_validation_enabled`'s own LiteLLM guardrail
77
+ * name (`deploy/litellm/config.yaml`'s `"jev-output-validation"` entry,
78
+ * `backend/app/services/jev_guardrail_names.py`'s `POST_CALL_GUARDRAIL_NAME`).
79
+ * Before this constant/the functions below existed, this file only ever
80
+ * live-synced `jev-policy` — an admin enabling or disabling
81
+ * `jev_output_validation_enabled` had ZERO effect on an already-`eai setup`
82
+ * CLI session until the user re-ran setup, the exact bug this whole module
83
+ * exists to prevent for the pre-call flag (see this file's own top docstring)
84
+ * but silently left unfixed for the post-call one.
85
+ */
86
+ export const OUTPUT_VALIDATION_GUARDRAIL_NAME = "jev-output-validation"
87
+
88
+ /** How long a live answer is trusted before the next request re-checks.
89
+ * Short enough that an admin's change reaches the CLI within one coffee
90
+ * break of it taking effect; long enough that a user typing several
91
+ * messages in a row does not turn every one into a network round trip. */
92
+ const CACHE_TTL_MS = 15_000
93
+
94
+ /** Per-request timeout for the live check itself. A hung connection must
95
+ * not add a user-visible stall to every single message. */
96
+ const CHECK_TIMEOUT_MS = 3_000
97
+
98
+ interface CacheEntry {
99
+ jevPolicyEnabled: boolean
100
+ /** Jev audit (2026-10, G2). */
101
+ jevOutputValidationEnabled: boolean
102
+ /** Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
103
+ * compaction-pilot.md`, task T7) -- `Team.eai_compaction_enabled`, read
104
+ * from this SAME `GET /teams/{id}` response, never a second round trip. */
105
+ compactionEnabled: boolean
106
+ teamId: string
107
+ expiresAt: number
108
+ }
109
+
110
+ let cache: CacheEntry | null = null
111
+
112
+ /** Test-only: forget whatever the last live check learned, so a test does
113
+ * not inherit state another test left behind (module-level cache is
114
+ * otherwise invisible from outside this file). */
115
+ export function resetGuardrailSyncCache(): void {
116
+ cache = null
117
+ }
118
+
119
+ export interface LiveCheckDeps {
120
+ /** Overridable for tests — never the real network in a unit test. */
121
+ fetchImpl?: typeof fetch
122
+ now?: () => number
123
+ /** Overridable so a test does not have to write a real file to disk for
124
+ * every case; defaults to the real `~/.elliot/config.json` reader. */
125
+ readConfig?: () => ElliotConfig | null
126
+ /**
127
+ * The team THIS OUTGOING REQUEST is actually scoped to — the ground
128
+ * truth already baked into `options.headers["x-litellm-spend-logs-
129
+ * metadata"]` by `teamconfig.py`'s own `_litellm_identity_headers` at
130
+ * provisioning time (see `extractInstanceTeamId` below, which the
131
+ * `chat.params` hook uses to derive this). NOT read from `~/.elliot/
132
+ * config.json` — that file is per-MACHINE, written once by `eai setup`
133
+ * for one specific person/team/backend.
134
+ *
135
+ * Required to trust the live check at all: a backend-hosted Web Chat
136
+ * OpenCode runtime, in this project's local-dev mode (`OPENCODE_
137
+ * RUNTIME_SUBPROCESS` — same user, same filesystem, no isolation), can
138
+ * end up running under a real developer's OWN `eai setup` login for a
139
+ * completely different team, tenant, or even backend (e.g. staging)
140
+ * than the one THIS request belongs to. Live-confirmed this session: a
141
+ * local "Test" team's guardrail toggle was silently overridden by a
142
+ * staging account's config for an unrelated team, because nothing here
143
+ * had ever checked that the two team ids agreed before trusting the
144
+ * live answer.
145
+ *
146
+ * `undefined` (the default) skips this check entirely — an existing
147
+ * caller with no way to know its own instance team id (there isn't one
148
+ * for the real EAI CLI, which never shares a filesystem with anyone
149
+ * else's session) keeps working exactly as before. Only an EXPLICIT,
150
+ * KNOWN mismatch (both ids present and different) suppresses the live
151
+ * check; this narrows an unsafe case, it does not add a new
152
+ * requirement for callers that already work correctly.
153
+ */
154
+ instanceTeamId?: string | null
155
+ }
156
+
157
+ /**
158
+ * Both guardrail toggles' current, live values from the backend — fetched
159
+ * and cached TOGETHER from the one `GET /teams/{id}` call that already
160
+ * returns both `jev_policy_enabled` and `jev_output_validation_enabled`
161
+ * (Jev audit, 2026-10, G2: previously only the pre-call flag was ever
162
+ * fetched/cached here, so the post-call one had no live-sync mechanism at
163
+ * all). Returns `null` under the exact same conditions `liveJevPolicyEnabled`
164
+ * always has (see its own docstring, preserved below) — no team context, an
165
+ * instance/request team-id mismatch, or a failed check with no usable cache.
166
+ *
167
+ * Fast Jev Compaction pilot (T7): also reads `eai_compaction_enabled` from
168
+ * this same response/cache entry -- one more field off a call this module
169
+ * already makes, not a second network round trip.
170
+ */
171
+ async function liveJevGuardrailFlags(
172
+ deps: LiveCheckDeps = {},
173
+ ): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean; compactionEnabled: boolean } | null> {
174
+ const now = deps.now ?? Date.now
175
+ const fetchImpl = deps.fetchImpl ?? fetch
176
+ const readConfig = deps.readConfig ?? readElliotConfig
177
+ const config = readConfig()
178
+
179
+ // No team context at all -- a tenant-wide (owner/admin) setup, or a
180
+ // config predating team scoping. Nothing team-level to check live;
181
+ // genuinely unknown rather than "disabled."
182
+ if (!config?.backend_url || !config.tenant_id || !config.team_id || !config.jwt_token) {
183
+ return null
184
+ }
185
+
186
+ // The local machine's own login config is for a DIFFERENT team than the
187
+ // one this specific request is scoped to -- trusting it would silently
188
+ // apply (or strip) a guardrail based on someone else's team/tenant/
189
+ // backend. Treat exactly like every other "cannot determine" case above
190
+ // rather than guessing. Only fires when BOTH ids are actually known;
191
+ // `undefined`/`null` here (the default for every existing caller) never
192
+ // triggers it.
193
+ if (
194
+ deps.instanceTeamId !== undefined &&
195
+ deps.instanceTeamId !== null &&
196
+ deps.instanceTeamId !== config.team_id
197
+ ) {
198
+ return null
199
+ }
200
+
201
+ if (cache && cache.teamId === config.team_id && now() < cache.expiresAt) {
202
+ return {
203
+ jevPolicyEnabled: cache.jevPolicyEnabled,
204
+ jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
205
+ compactionEnabled: cache.compactionEnabled,
206
+ }
207
+ }
208
+
209
+ const controller = new AbortController()
210
+ const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
211
+ try {
212
+ const base = config.backend_url.replace(/\/+$/, "")
213
+ const res = await fetchImpl(`${base}/teams/${encodeURIComponent(config.team_id)}`, {
214
+ headers: { Authorization: `Bearer ${config.jwt_token}` },
215
+ signal: controller.signal,
216
+ })
217
+ if (!res.ok) throw new Error(`backend returned ${res.status}`)
218
+ const body: unknown = await res.json()
219
+ const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {}
220
+ const jevPolicyEnabled = Boolean(record.jev_policy_enabled)
221
+ const jevOutputValidationEnabled = Boolean(record.jev_output_validation_enabled)
222
+ const compactionEnabled = Boolean(record.eai_compaction_enabled)
223
+ cache = {
224
+ jevPolicyEnabled,
225
+ jevOutputValidationEnabled,
226
+ compactionEnabled,
227
+ teamId: config.team_id,
228
+ expiresAt: now() + CACHE_TTL_MS,
229
+ }
230
+ return { jevPolicyEnabled, jevOutputValidationEnabled, compactionEnabled }
231
+ } catch {
232
+ // Offline, backend unreachable, expired token, or a malformed response.
233
+ // Never throw into the chat.params hook over this -- a CLI message must
234
+ // still be sendable without a network round trip succeeding. Fall back
235
+ // to a still-fresh cached value for this same team if one exists;
236
+ // otherwise genuinely unknown.
237
+ return cache && cache.teamId === config.team_id && now() < cache.expiresAt
238
+ ? {
239
+ jevPolicyEnabled: cache.jevPolicyEnabled,
240
+ jevOutputValidationEnabled: cache.jevOutputValidationEnabled,
241
+ compactionEnabled: cache.compactionEnabled,
242
+ }
243
+ : null
244
+ } finally {
245
+ clearTimeout(timer)
246
+ }
247
+ }
248
+
249
+ /**
250
+ * The team's current `jev_policy_enabled`, live from the backend — or
251
+ * `null` when it cannot be determined (no team context, not logged in, the
252
+ * check itself failed, or the local config belongs to a DIFFERENT team than
253
+ * this request — see `instanceTeamId`), which callers must read as "use
254
+ * whatever is already configured," never as "disabled."
255
+ */
256
+ export async function liveJevPolicyEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
257
+ const flags = await liveJevGuardrailFlags(deps)
258
+ return flags ? flags.jevPolicyEnabled : null
259
+ }
260
+
261
+ /**
262
+ * The POST-CALL counterpart to `liveJevPolicyEnabled` — the team's current
263
+ * `jev_output_validation_enabled`, live from the backend, with the exact
264
+ * same `null`-means-"leave it alone" contract (Jev audit, 2026-10, G2).
265
+ * Shares `liveJevPolicyEnabled`'s own cache/fetch under the hood (one
266
+ * `GET /teams/{id}` call answers both), so calling both on the same request
267
+ * costs at most one network round trip, not two.
268
+ */
269
+ export async function liveJevOutputValidationEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
270
+ const flags = await liveJevGuardrailFlags(deps)
271
+ return flags ? flags.jevOutputValidationEnabled : null
272
+ }
273
+
274
+ /**
275
+ * Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
276
+ * pilot.md`, task T7) — the team's current `Team.eai_compaction_enabled`,
277
+ * live from the backend, with the exact same `null`-means-"leave it alone"
278
+ * contract as `liveJevPolicyEnabled`/`liveJevOutputValidationEnabled`.
279
+ * Shares the same cached `GET /teams/{id}` call under the hood, so calling
280
+ * this alongside either (or both) of the other two costs at most one
281
+ * network round trip, not two or three.
282
+ *
283
+ * Callers (index.ts's `experimental.chat.messages.transform` hook) must
284
+ * treat `null` and `false` identically here: skip compaction and leave
285
+ * `output.messages` exactly as `stripBlockedTurns` left them. Unlike the
286
+ * Jev guardrails, there is no security-load-bearing reason to fail closed
287
+ * on uncertainty -- this flag only decides whether a token-reduction pass
288
+ * runs at all, so "do nothing" is always the safe default when the answer
289
+ * is not confidently known.
290
+ */
291
+ export async function liveCompactionEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
292
+ const flags = await liveJevGuardrailFlags(deps)
293
+ return flags ? flags.compactionEnabled : null
294
+ }
295
+
296
+ /**
297
+ * Applies a live guardrail decision to the outgoing request's `options`,
298
+ * mutating `metadata.guardrails` to match exactly — adding `guardrailName`
299
+ * when live-enabled, removing it (even if the STATIC config baked it in at
300
+ * `eai setup` time) when live-disabled. A `null` decision (see
301
+ * `liveJevPolicyEnabled`'s own docstring) changes nothing, leaving whatever
302
+ * the static config/an earlier plugin already set untouched.
303
+ *
304
+ * `guardrailName` defaults to `JEV_GUARDRAIL_NAME` (pre-call) so every
305
+ * existing 2-argument call site is unaffected; pass
306
+ * `OUTPUT_VALIDATION_GUARDRAIL_NAME` for the post-call decision (Jev audit,
307
+ * 2026-10, G2). Two calls targeting different names compose safely — each
308
+ * only ever adds/removes its OWN name from the shared `guardrails` array,
309
+ * never touching the other's entry, so calling this once for each flag (as
310
+ * `index.ts`'s `chat.params` hook now does) correctly reflects both toggles
311
+ * independently on the same request.
312
+ *
313
+ * Pure and synchronous on purpose: the async live-check already happened by
314
+ * the time this runs, so applying its result cannot itself introduce a
315
+ * second network call, a retry, or any other source of a duplicate request.
316
+ */
317
+ export function applyGuardrailDecision(
318
+ options: Record<string, unknown>,
319
+ decision: boolean | null,
320
+ guardrailName: string = JEV_GUARDRAIL_NAME,
321
+ ): void {
322
+ if (decision === null) return
323
+
324
+ const metadata =
325
+ options.metadata && typeof options.metadata === "object" && !Array.isArray(options.metadata)
326
+ ? (options.metadata as Record<string, unknown>)
327
+ : (options.metadata = {} as Record<string, unknown>)
328
+
329
+ const existing = Array.isArray(metadata.guardrails)
330
+ ? (metadata.guardrails as unknown[]).filter((g): g is string => typeof g === "string")
331
+ : []
332
+ const withoutThisName = existing.filter((g) => g !== guardrailName)
333
+
334
+ metadata.guardrails = decision ? [...withoutThisName, guardrailName] : withoutThisName
335
+ }
336
+
337
+ /**
338
+ * The team id THIS specific outgoing request is scoped to, read from the
339
+ * SAME static header `teamconfig.py`'s `_litellm_identity_headers` already
340
+ * baked into `options.headers["x-litellm-spend-logs-metadata"]` at
341
+ * provisioning time — e.g. `{"tenant_id":"...","team_id":"...",
342
+ * "source":"opencode"}`. This is ground truth about the request regardless
343
+ * of which real person's OS login happens to be running the process, which
344
+ * is exactly why it is the right thing to compare `~/.elliot/config.json`'s
345
+ * own `team_id` against (see `LiveCheckDeps.instanceTeamId`'s own
346
+ * docstring).
347
+ *
348
+ * Returns `undefined` (not `null`) for "could not determine" — `undefined`
349
+ * is `LiveCheckDeps.instanceTeamId`'s own "skip this check" value, so a
350
+ * request shape this function does not recognize degrades to the EXACT
351
+ * pre-existing behavior (trust whatever `~/.elliot/config.json` says) rather
352
+ * than a new, stricter failure mode nobody asked for.
353
+ */
354
+ export function extractInstanceTeamId(options: Record<string, unknown> | null | undefined): string | undefined {
355
+ const headers = options?.["headers"]
356
+ if (!headers || typeof headers !== "object") return undefined
357
+ const raw = (headers as Record<string, unknown>)["x-litellm-spend-logs-metadata"]
358
+ if (typeof raw !== "string") return undefined
359
+ try {
360
+ const parsed: unknown = JSON.parse(raw)
361
+ const teamId =
362
+ parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>)["team_id"] : undefined
363
+ return typeof teamId === "string" && teamId ? teamId : undefined
364
+ } catch {
365
+ // Malformed/unexpected header shape -- "could not determine" (see this
366
+ // function's own docstring), never a thrown error over a header this
367
+ // module merely reads and does not own the shape of.
368
+ return undefined
369
+ }
370
+ }