ada-agent 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/selfcheck.ts CHANGED
@@ -293,6 +293,159 @@ async function main(): Promise<void> {
293
293
  assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
294
294
  }
295
295
 
296
+ // --- enterprise control plane: seats, policy, metering, audit (temp data dir, no HTTP) ---
297
+ {
298
+ const dir = join(tmpdir(), `ada-ent-${Date.now()}`);
299
+ const ent = await import("./server/enterprise.ts");
300
+ process.env.ADA_DATA_DIR = dir;
301
+ try {
302
+ assert.equal(ent.enterpriseMode(dir), false, "no seats + no admin key → enterprise mode off");
303
+ const key = ent.createSeat("alice", "admin", dir);
304
+ assert.ok(key.startsWith("ada_sk_") && key.length > 40, "seat keys are long and prefixed");
305
+ assert.equal(ent.enterpriseMode(dir), true, "a seat activates enterprise mode");
306
+ assert.deepEqual(ent.identifySeat(key, dir), { user: "alice", role: "admin" }, "seat key resolves to its identity");
307
+ assert.equal(ent.identifySeat("ada_sk_wrong", dir), null, "unknown key → null");
308
+ // The auth-bypass the review caught: Object.prototype keys must NOT authenticate.
309
+ for (const evil of ["toString", "constructor", "__proto__", "valueOf", "hasOwnProperty"]) {
310
+ assert.equal(ent.identifySeat(evil, dir), null, `prototype key "${evil}" must not authenticate`);
311
+ }
312
+ assert.equal(ent.listSeats(dir)[0]!.keyPrefix.length, 14, "listing exposes only a key prefix");
313
+ assert.equal(ent.disableSeat(key.slice(0, 8), dir), null, "too-short prefix refused");
314
+ assert.equal(ent.disableSeat(key.slice(0, 14), dir), "alice", "disable by unique prefix");
315
+ assert.equal(ent.identifySeat(key, dir), null, "disabled seat no longer authenticates");
316
+
317
+ assert.ok(ent.modelAllowed("claude-opus-4-8", {}), "empty policy allows everything");
318
+ const pol = { models: ["@cf/*", "claude-*"] };
319
+ assert.ok(ent.modelAllowed("@cf/moonshotai/kimi-k2.7-code", pol), "wildcard allowlist matches");
320
+ assert.ok(!ent.modelAllowed("gpt-5", pol), "non-listed model denied");
321
+
322
+ ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 100, completionTokens: 20 }, dir);
323
+ ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 50, completionTokens: 10 }, dir);
324
+ ent.appendUsage({ ts: Date.now() - 90 * 86_400_000, user: "old", model: "m1", provider: "p", promptTokens: 999, completionTokens: 999 }, dir);
325
+ const sum = ent.usageSummary(30, dir);
326
+ assert.equal(sum.byUser.alice!.requests, 2, "usage aggregates per user");
327
+ assert.equal(sum.totals.promptTokens, 150, "old rows fall outside the window");
328
+
329
+ assert.ok(ent.auditTail(10, dir).some((e) => e.event === "seat_created"), "audit log records seat creation");
330
+
331
+ const sse = 'data: {"choices":[]}\n\ndata: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"completion_tokens_details":{"reasoning_tokens":2}}}\n\ndata: [DONE]\n\n';
332
+ assert.deepEqual(ent.extractLastUsage(sse), { promptTokens: 11, completionTokens: 7 }, "usage extracted from SSE tail (nested details ok)");
333
+ assert.equal(ent.extractLastUsage("no usage here"), null, "no usage → null");
334
+ // A trailing "usage": null must not hide the real one earlier in the stream.
335
+ assert.deepEqual(ent.extractLastUsage('{"usage":{"prompt_tokens":5,"completion_tokens":3}}\n{"usage":null}'), { promptTokens: 5, completionTokens: 3 }, "trailing usage:null skipped, real one found");
336
+
337
+ // policy validation rejects malformed shapes, accepts good ones
338
+ assert.ok("error" in ent.validatePolicy({ models: [1, 2] }), "non-string models rejected");
339
+ assert.ok("error" in ent.validatePolicy({ permissions: [{ tool: "x" }] }), "permission without action rejected");
340
+ assert.ok("policy" in ent.validatePolicy({ models: ["@cf/*"], permissions: [{ tool: "bash", action: "deny" }] }), "valid policy accepted");
341
+
342
+ // corrupt users.json → CorruptStore (fail-closed), NOT an empty map that unlocks the backend
343
+ writeFileSync(join(dir, "users.json"), "{ this is not json");
344
+ assert.throws(() => ent.loadSeats(dir), (e: unknown) => e instanceof ent.CorruptStore, "corrupt users.json throws CorruptStore");
345
+ assert.equal(ent.enterpriseMode(dir), true, "corrupt store → still enterprise (locked), never open");
346
+ } finally {
347
+ delete process.env.ADA_DATA_DIR;
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+ }
351
+
352
+ // --- OIDC SSO (Stage 2): JIT seat invariants + hermetic RS256 id-token verification ---
353
+ {
354
+ const dir = join(tmpdir(), `ada-oidc-${Date.now()}`);
355
+ const ent = await import("./server/enterprise.ts");
356
+ const oidc = await import("./server/oidc.ts");
357
+ const { generateKeyPairSync, sign } = await import("node:crypto");
358
+ const savedEnv = { ...process.env };
359
+ const iss = "https://idp.example.com";
360
+ process.env.ADA_DATA_DIR = dir;
361
+ process.env.ADA_OIDC_ISSUER = iss;
362
+ process.env.ADA_OIDC_CLIENT_ID = "ada-client";
363
+ process.env.ADA_OIDC_ALLOWED_GROUPS = "engineering";
364
+ process.env.ADA_OIDC_ADMIN_GROUP = "admins";
365
+ try {
366
+ // JIT seat provisioning invariants (the load-bearing new behavior).
367
+ const ext = `${iss}#sub-123`;
368
+ const k1 = ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir);
369
+ assert.ok(k1 && k1.startsWith("ada_sk_") && k1.length > 40, "OIDC JIT mints a valid seat key");
370
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), k1, "same iss#sub reuses one seat (no key rotation)");
371
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "admin", dir), k1, "existing seat is NOT auto-escalated to admin on login");
372
+ assert.deepEqual(ent.identifySeat(k1!, dir), { user: "sso-user", role: "dev" }, "SSO seat key authenticates like any seat");
373
+ assert.equal(ent.disableSeatByExternalId(ext, dir), "sso-user", "disable-by-externalId offboards");
374
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), null, "disabled externalId denies re-login (fail-closed deprovision, no resurrect)");
375
+ assert.equal(ent.identifySeat(k1!, dir), null, "disabled SSO seat no longer authenticates");
376
+ assert.equal(ent.seatByExternalId("__proto__", dir), null, "externalId scan is prototype-safe");
377
+ // admin→dev downgrade when the admin group drops off a later login.
378
+ const ext2 = `${iss}#boss`;
379
+ const kb = ent.upsertSeatForSSO(ext2, iss, "boss", "admin", dir);
380
+ assert.equal(ent.identifySeat(kb!, dir)!.role, "admin", "admin seat provisioned");
381
+ assert.equal(ent.upsertSeatForSSO(ext2, iss, "boss", "dev", dir), kb, "downgrade reuses the same key");
382
+ assert.equal(ent.identifySeat(kb!, dir)!.role, "dev", "admin→dev downgrade on group removal");
383
+
384
+ // group/domain gate.
385
+ assert.ok(oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["engineering"] }), "allowed group provisions");
386
+ assert.ok(!oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["other"], email: "x@evil.com" }), "non-allowed group/domain refused");
387
+ assert.equal(oidc.mapIdentityToSeatFields({ iss, sub: "z", name: "z", groups: ["admins"] }).role, "admin", "admin group → admin role");
388
+
389
+ // Hermetic RS256 verification: sign a token locally, verify via an injected JWKS key.
390
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
391
+ const pubJwk = { ...(publicKey.export({ format: "jwk" }) as Record<string, unknown>), kid: "test", kty: "RSA" };
392
+ const getKey = (kid: string) => (kid === "test" ? pubJwk : null);
393
+ const now = 1_800_000_000_000;
394
+ const sec = Math.floor(now / 1000);
395
+ const b64u = (o: unknown): string => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
396
+ const mkToken = (payload: Record<string, unknown>, alg = "RS256"): string => {
397
+ const head = b64u({ alg, kid: "test", typ: "JWT" });
398
+ const body = b64u(payload);
399
+ if (alg === "none") return `${head}.${body}.`;
400
+ return `${head}.${body}.${sign("RSA-SHA256", Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
401
+ };
402
+ const good = { iss, aud: "ada-client", sub: "sub-123", exp: sec + 3600, iat: sec, groups: ["engineering"], email: "dev@corp.com" };
403
+ const id = await oidc.verifyOidcToken(mkToken(good), { getKey, now });
404
+ assert.ok(id && id.sub === "sub-123" && id.iss === iss, "valid RS256 id_token verifies");
405
+ const validTok = mkToken(good);
406
+ assert.equal(await oidc.verifyOidcToken(`${validTok.slice(0, -4)}AAAA`, { getKey, now }), null, "tampered signature → null");
407
+ assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, aud: "someone-else" }), { getKey, now }), null, "wrong audience → null");
408
+ assert.equal(await oidc.verifyOidcToken(mkToken(good, "none"), { getKey, now }), null, "alg=none → null (no key confusion)");
409
+ assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, exp: sec - 7200 }), { getKey, now }), null, "expired token → null");
410
+ // email is trusted only when the IdP marks it verified (domain-provisioning fail-open fix).
411
+ const idU = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: false }), { getKey, now });
412
+ assert.ok(idU && idU.email === undefined, "unverified email dropped from identity");
413
+ const idV = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: true }), { getKey, now });
414
+ assert.equal(idV!.email, "x@corp.com", "verified email kept");
415
+
416
+ // SSRF guard classifies against a parsed IP (net.isIP), not a string prefix.
417
+ for (const bad of ["https://[::1]/keys", "https://[fe80::1]/keys", "https://[fc00::1]/keys", "https://[::ffff:127.0.0.1]/keys", "https://127.0.0.1/keys", "https://10.1.2.3/keys", "http://idp.okta.com/keys"]) {
418
+ assert.throws(() => oidc.assertSafeJwksUri(bad), `jwks_uri rejected: ${bad}`);
419
+ }
420
+ for (const ok of ["https://fcm.googleapis.com/keys", "https://fd-idp.corp.com/keys", "https://your-tenant.okta.com/oauth2/v1/keys"]) {
421
+ assert.doesNotThrow(() => oidc.assertSafeJwksUri(ok), `jwks_uri allowed: ${ok}`);
422
+ }
423
+ } finally {
424
+ for (const k of ["ADA_DATA_DIR", "ADA_OIDC_ISSUER", "ADA_OIDC_CLIENT_ID", "ADA_OIDC_ALLOWED_GROUPS", "ADA_OIDC_ADMIN_GROUP"]) {
425
+ if (savedEnv[k] === undefined) delete process.env[k];
426
+ else process.env[k] = savedEnv[k];
427
+ }
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
430
+ }
431
+
432
+ // --- org policy merge: restrictive wins, org can tighten but never loosen ---
433
+ {
434
+ const { permissionFor, setActiveAgentPermissions, setOrgPermissions } = await import("./client/settings.ts");
435
+ setActiveAgentPermissions([{ tool: "bash", action: "allow" }]);
436
+ setOrgPermissions([{ tool: "bash", action: "deny" }]);
437
+ assert.equal(permissionFor("bash", "x"), "deny", "org deny beats local allow");
438
+ setOrgPermissions([{ tool: "bash", action: "ask" }]);
439
+ assert.equal(permissionFor("bash", "x"), "ask", "org ask upgrades local allow");
440
+ setActiveAgentPermissions([{ tool: "bash", action: "deny" }]);
441
+ setOrgPermissions([{ tool: "bash", action: "allow" }]);
442
+ assert.equal(permissionFor("bash", "x"), "deny", "org allow cannot loosen a local deny");
443
+ setActiveAgentPermissions([]);
444
+ assert.equal(permissionFor("bash", "x"), null, "org allow cannot loosen the default gating");
445
+ setOrgPermissions(null);
446
+ setActiveAgentPermissions(null);
447
+ }
448
+
296
449
  // --- @codebase semantic search: pure parts (no network / no embedding model needed) ---
297
450
  {
298
451
  const { chunkText, cosine, walkFiles } = await import("./client/embed-index.ts");
@@ -0,0 +1,408 @@
1
+ // Enterprise control plane: seats (per-user client keys), org policy, usage metering, audit log.
2
+ // One deployment = one org (it's self-hosted; multi-org is the SaaS upgrade path, not v1).
3
+ //
4
+ // Enterprise mode ACTIVATES when a seat exists or ADA_ADMIN_KEY is set — with neither, the backend
5
+ // behaves exactly as before (dev-open / ADA_CLIENT_KEYS / login). Bootstrap:
6
+ //
7
+ // ADA_ADMIN_KEY=<random> ada-server
8
+ // curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" localhost:8787/v1/users -d '{"name":"alice"}'
9
+ //
10
+ // Security posture (hardened after an adversarial review):
11
+ // - lookups are own-property + format-guarded (no prototype-key auth bypass);
12
+ // - writes are atomic (tmp + rename); a corrupt/unreadable store fails CLOSED (never dev-open);
13
+ // - key comparisons are timing-safe.
14
+ // ponytail: file-backed under ~/.ada/server — fine to ~50 seats. Postgres + rotating usage logs are
15
+ // the upgrade path when an org outgrows files (usageSummary/auditTail read whole files: OK to
16
+ // low-millions of rows, then rotate).
17
+
18
+ import { randomBytes, timingSafeEqual } from "node:crypto";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+
23
+ // Resolved once: an empty ADA_DATA_DIR means "unset", and a relative path can't scatter the auth
24
+ // store across working directories (which would itself be a fail-open).
25
+ const DATA_DIR = resolve(process.env.ADA_DATA_DIR || join(homedir(), ".ada", "server"));
26
+ function dataDir(): string {
27
+ return DATA_DIR;
28
+ }
29
+
30
+ /** Thrown when a store file exists but can't be read/parsed — callers must fail CLOSED, never open. */
31
+ export class CorruptStore extends Error {}
32
+
33
+ export interface Seat {
34
+ name: string;
35
+ role: "admin" | "dev";
36
+ created: string;
37
+ disabled?: boolean;
38
+ externalId?: string; // OIDC SSO: issuer-scoped stable id (`iss#sub`) — non-secret, the deprovision target
39
+ iss?: string; // OIDC issuer the seat was provisioned from (so an issuer change is detectable)
40
+ }
41
+ export interface PolicyRule {
42
+ tool?: string;
43
+ pattern?: string;
44
+ action: "allow" | "ask" | "deny";
45
+ }
46
+ export interface Policy {
47
+ models?: string[];
48
+ permissions?: PolicyRule[];
49
+ }
50
+ export interface UsageRow {
51
+ ts: number;
52
+ user: string;
53
+ model: string;
54
+ provider: string;
55
+ promptTokens: number;
56
+ completionTokens: number;
57
+ }
58
+ export interface Identity {
59
+ user: string;
60
+ role: "admin" | "dev";
61
+ }
62
+
63
+ const usersFile = (dir: string): string => join(dir, "users.json");
64
+ const policyFile = (dir: string): string => join(dir, "policy.json");
65
+ const usageFile = (dir: string): string => join(dir, "usage.jsonl");
66
+ const auditFile = (dir: string): string => join(dir, "audit.jsonl");
67
+
68
+ function atomicWrite(file: string, data: string): void {
69
+ mkdirSync(join(file, ".."), { recursive: true });
70
+ const tmp = `${file}.tmp.${process.pid}`;
71
+ writeFileSync(tmp, data);
72
+ renameSync(tmp, file); // atomic on the same filesystem — a crash can't leave a torn file
73
+ }
74
+
75
+ function errno(e: unknown): string | undefined {
76
+ return (e as NodeJS.ErrnoException).code;
77
+ }
78
+
79
+ /** Seats keyed by full key. Missing file → empty (prototype-free) map. Any OTHER read/parse error
80
+ * → CorruptStore (callers fail closed — a torn users.json must not silently disable auth). */
81
+ export function loadSeats(dir = dataDir()): Record<string, Seat> {
82
+ const map: Record<string, Seat> = Object.create(null); // no Object.prototype — belt-and-suspenders with the own-property check
83
+ let text: string;
84
+ try {
85
+ text = readFileSync(usersFile(dir), "utf8");
86
+ } catch (e) {
87
+ if (errno(e) === "ENOENT") return map;
88
+ throw new CorruptStore(`users.json unreadable: ${e instanceof Error ? e.message : e}`);
89
+ }
90
+ try {
91
+ const parsed = (JSON.parse(text) as { users?: Record<string, Seat> }).users ?? {};
92
+ for (const [k, v] of Object.entries(parsed)) map[k] = v;
93
+ return map;
94
+ } catch (e) {
95
+ throw new CorruptStore(`users.json corrupt: ${e instanceof Error ? e.message : e}`);
96
+ }
97
+ }
98
+
99
+ function saveSeats(seats: Record<string, Seat>, dir = dataDir()): void {
100
+ atomicWrite(usersFile(dir), JSON.stringify({ users: seats }, null, 2));
101
+ }
102
+
103
+ /** Enterprise mode = admin key set, or seats exist. A corrupt seat store counts as enterprise
104
+ * (locked), never as "no seats" — fail closed. */
105
+ export function enterpriseMode(dir = dataDir()): boolean {
106
+ if (process.env.ADA_ADMIN_KEY) return true;
107
+ try {
108
+ return Object.keys(loadSeats(dir)).length > 0;
109
+ } catch {
110
+ return true;
111
+ }
112
+ }
113
+
114
+ function timingEqual(a: string, b: string): boolean {
115
+ const ab = Buffer.from(a);
116
+ const bb = Buffer.from(b);
117
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
118
+ }
119
+
120
+ /** Resolve a bearer token to a seat identity (or the bootstrap admin). Null = not a seat. Throws
121
+ * CorruptStore if the seat store can't be read (caller returns 503, never dev-open). */
122
+ export function identifySeat(token: string, dir = dataDir()): Identity | null {
123
+ const admin = process.env.ADA_ADMIN_KEY;
124
+ if (admin && timingEqual(token, admin)) return { user: "admin", role: "admin" };
125
+ if (!token.startsWith("ada_sk_")) return null; // format guard — "toString"/"__proto__"/… never reach the map
126
+ const seats = loadSeats(dir); // may throw CorruptStore
127
+ if (!Object.prototype.hasOwnProperty.call(seats, token)) return null; // own-property only
128
+ const seat = seats[token]!;
129
+ return seat.disabled ? null : { user: seat.name, role: seat.role };
130
+ }
131
+
132
+ /** Create a seat; returns its full key (shown once — only a prefix is ever listed again). */
133
+ export function createSeat(name: string, role: "admin" | "dev" = "dev", dir = dataDir()): string {
134
+ const key = `ada_sk_${randomBytes(24).toString("hex")}`;
135
+ const seats = loadSeats(dir);
136
+ seats[key] = { name, role, created: new Date().toISOString() };
137
+ saveSeats(seats, dir);
138
+ appendAudit({ ts: Date.now(), user: "-", event: "seat_created", detail: `${name} (${role})` }, dir);
139
+ return key;
140
+ }
141
+
142
+ /** Find a seat by its OIDC externalId (`iss#sub`). Scans the (small) seat map by VALUE — externalId
143
+ * is compared, never used as a lookup key, so it's inherently prototype-safe. Inherits CorruptStore
144
+ * from loadSeats (callers fail closed). */
145
+ export function seatByExternalId(externalId: string, dir = dataDir()): { key: string; seat: Seat } | null {
146
+ const seats = loadSeats(dir);
147
+ for (const key of Object.keys(seats)) {
148
+ const seat = seats[key]!;
149
+ if (seat.externalId === externalId) return { key, seat };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** JIT-provision (or reuse) a seat for a verified OIDC identity. Returns the seat's `ada_sk_` key, or
155
+ * null if the seat exists but is DISABLED (a deprovisioned user must not be resurrected by re-login).
156
+ * - existing enabled seat → reuse its key (NO rotation); if it was admin and the login no longer
157
+ * carries the admin group, downgrade admin→dev (privilege revocation). Never auto-ESCALATE here.
158
+ * - new identity → mint one key, stamp externalId+iss, one `seat_created` audit row. */
159
+ export function upsertSeatForSSO(externalId: string, iss: string, name: string, role: "admin" | "dev", dir = dataDir()): string | null {
160
+ const seats = loadSeats(dir);
161
+ let foundKey: string | null = null;
162
+ for (const key of Object.keys(seats)) {
163
+ if (seats[key]!.externalId === externalId) {
164
+ foundKey = key;
165
+ break;
166
+ }
167
+ }
168
+ if (foundKey) {
169
+ const seat = seats[foundKey]!;
170
+ if (seat.disabled) return null; // deprovisioned — do NOT resurrect
171
+ if (seat.role === "admin" && role === "dev") {
172
+ seat.role = "dev";
173
+ saveSeats(seats, dir);
174
+ appendAudit({ ts: Date.now(), user: name, event: "role_changed", detail: `${name} admin→dev (SSO group removed)` }, dir);
175
+ }
176
+ return foundKey;
177
+ }
178
+ const key = `ada_sk_${randomBytes(24).toString("hex")}`;
179
+ seats[key] = { name, role, created: new Date().toISOString(), externalId, iss };
180
+ saveSeats(seats, dir);
181
+ appendAudit({ ts: Date.now(), user: name, event: "seat_created", detail: `${name} (${role}) via OIDC ${externalId}` }, dir);
182
+ return key;
183
+ }
184
+
185
+ /** Immediate offboarding: disable the seat for an OIDC externalId. The admin endpoint (and, later,
186
+ * SCIM DELETE) call this — the next identifySeat for that key returns null (401). */
187
+ export function disableSeatByExternalId(externalId: string, dir = dataDir()): string | null {
188
+ const seats = loadSeats(dir);
189
+ for (const key of Object.keys(seats)) {
190
+ const seat = seats[key]!;
191
+ if (seat.externalId === externalId) {
192
+ if (seat.disabled) return seat.name; // idempotent
193
+ seat.disabled = true;
194
+ saveSeats(seats, dir);
195
+ appendAudit({ ts: Date.now(), user: seat.name, event: "seat_disabled", detail: `${seat.name} (SSO ${externalId})` }, dir);
196
+ return seat.name;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** Disable (not delete — the audit trail keeps the history) the seat whose key starts with prefix. */
203
+ export function disableSeat(prefix: string, dir = dataDir()): string | null {
204
+ if (prefix.length < 12) return null; // too short to be safely unique
205
+ const seats = loadSeats(dir);
206
+ const keys = Object.keys(seats).filter((k) => k.startsWith(prefix));
207
+ if (keys.length !== 1) return null;
208
+ seats[keys[0]!]!.disabled = true;
209
+ saveSeats(seats, dir);
210
+ appendAudit({ ts: Date.now(), user: "-", event: "seat_disabled", detail: seats[keys[0]!]!.name }, dir);
211
+ return seats[keys[0]!]!.name;
212
+ }
213
+
214
+ /** Key prefixes + metadata for listing — full keys are never returned after creation. Display-only,
215
+ * so a corrupt store yields [] rather than crashing the banner. */
216
+ export function listSeats(dir = dataDir()): Array<Seat & { keyPrefix: string }> {
217
+ try {
218
+ return Object.entries(loadSeats(dir)).map(([k, s]) => ({ ...s, keyPrefix: k.slice(0, 14) }));
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ let lastGoodPolicy: Policy | null = null;
225
+ /** Missing file → {} (no policy = allow all, legitimate). A corrupt EXISTING file → last-known-good
226
+ * if we have one, else CorruptStore (fail closed — a security control must not degrade to allow-all). */
227
+ export function loadPolicy(dir = dataDir()): Policy {
228
+ let text: string;
229
+ try {
230
+ text = readFileSync(policyFile(dir), "utf8");
231
+ } catch (e) {
232
+ if (errno(e) === "ENOENT") return {};
233
+ if (lastGoodPolicy) return lastGoodPolicy;
234
+ throw new CorruptStore(`policy.json unreadable: ${e instanceof Error ? e.message : e}`);
235
+ }
236
+ try {
237
+ lastGoodPolicy = JSON.parse(text) as Policy;
238
+ return lastGoodPolicy;
239
+ } catch (e) {
240
+ if (lastGoodPolicy) return lastGoodPolicy;
241
+ throw new CorruptStore(`policy.json corrupt: ${e instanceof Error ? e.message : e}`);
242
+ }
243
+ }
244
+
245
+ export function savePolicy(p: Policy, dir = dataDir()): void {
246
+ atomicWrite(policyFile(dir), JSON.stringify(p, null, 2));
247
+ lastGoodPolicy = p;
248
+ appendAudit({ ts: Date.now(), user: "-", event: "policy_updated", detail: JSON.stringify(p).slice(0, 300) }, dir);
249
+ }
250
+
251
+ /** Validate a policy shape from the wire. Returns the typed policy or an error message. */
252
+ export function validatePolicy(raw: unknown): { policy: Policy } | { error: string } {
253
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "policy must be a JSON object" };
254
+ const r = raw as Record<string, unknown>;
255
+ const out: Policy = {};
256
+ if (r.models !== undefined) {
257
+ if (!Array.isArray(r.models) || r.models.some((m) => typeof m !== "string" || !m.trim())) return { error: "models must be an array of non-empty strings" };
258
+ out.models = r.models as string[];
259
+ }
260
+ if (r.permissions !== undefined) {
261
+ if (!Array.isArray(r.permissions)) return { error: "permissions must be an array" };
262
+ for (const p of r.permissions) {
263
+ const rule = p as Record<string, unknown>;
264
+ if (!rule || typeof rule !== "object" || !["allow", "ask", "deny"].includes(rule.action as string)) return { error: "each permission needs action: allow|ask|deny" };
265
+ if (rule.tool !== undefined && typeof rule.tool !== "string") return { error: "permission.tool must be a string" };
266
+ if (rule.pattern !== undefined && typeof rule.pattern !== "string") return { error: "permission.pattern must be a string" };
267
+ }
268
+ out.permissions = r.permissions as PolicyRule[];
269
+ }
270
+ return { policy: out };
271
+ }
272
+
273
+ function globMatch(pattern: string, s: string): boolean {
274
+ const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
275
+ return re.test(s);
276
+ }
277
+
278
+ /** Is this model allowed by org policy? No/empty allowlist = everything allowed. */
279
+ export function modelAllowed(model: string, policy: Policy): boolean {
280
+ if (!Array.isArray(policy.models) || !policy.models.length) return true;
281
+ return policy.models.some((p) => globMatch(p, model));
282
+ }
283
+
284
+ export function appendUsage(row: UsageRow, dir = dataDir()): void {
285
+ try {
286
+ mkdirSync(dir, { recursive: true });
287
+ appendFileSync(usageFile(dir), `${JSON.stringify(row)}\n`);
288
+ } catch {
289
+ /* metering is best-effort; never fail a request over it */
290
+ }
291
+ }
292
+
293
+ export interface AuditRow {
294
+ ts: number;
295
+ user: string;
296
+ event: string;
297
+ detail: string;
298
+ }
299
+
300
+ export function appendAudit(row: AuditRow, dir = dataDir()): void {
301
+ try {
302
+ mkdirSync(dir, { recursive: true });
303
+ appendFileSync(auditFile(dir), `${JSON.stringify(row)}\n`);
304
+ } catch {
305
+ /* best-effort */
306
+ }
307
+ }
308
+
309
+ export function auditTail(limit = 200, dir = dataDir()): AuditRow[] {
310
+ let lines: string[];
311
+ try {
312
+ lines = readFileSync(auditFile(dir), "utf8").split("\n").filter(Boolean);
313
+ } catch {
314
+ return [];
315
+ }
316
+ const out: AuditRow[] = [];
317
+ for (const l of lines.slice(-limit)) {
318
+ try {
319
+ out.push(JSON.parse(l) as AuditRow); // skip a torn last line instead of losing the whole view
320
+ } catch {
321
+ /* skip corrupt line */
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+
327
+ interface Bucket {
328
+ requests: number;
329
+ promptTokens: number;
330
+ completionTokens: number;
331
+ }
332
+ export interface UsageSummary {
333
+ since: number;
334
+ totals: Bucket;
335
+ byUser: Record<string, Bucket>;
336
+ byModel: Record<string, Bucket>;
337
+ }
338
+
339
+ export function usageSummary(days = 30, dir = dataDir()): UsageSummary {
340
+ const since = Date.now() - days * 86_400_000;
341
+ const zero = (): Bucket => ({ requests: 0, promptTokens: 0, completionTokens: 0 });
342
+ const out: UsageSummary = { since, totals: zero(), byUser: Object.create(null), byModel: Object.create(null) };
343
+ let lines: string[] = [];
344
+ try {
345
+ lines = readFileSync(usageFile(dir), "utf8").split("\n").filter(Boolean);
346
+ } catch {
347
+ return out;
348
+ }
349
+ for (const l of lines) {
350
+ let r: UsageRow;
351
+ try {
352
+ r = JSON.parse(l) as UsageRow;
353
+ } catch {
354
+ continue;
355
+ }
356
+ if (r.ts < since) continue;
357
+ for (const b of [out.totals, (out.byUser[r.user] ??= zero()), (out.byModel[r.model] ??= zero())]) {
358
+ b.requests++;
359
+ b.promptTokens += r.promptTokens || 0;
360
+ b.completionTokens += r.completionTokens || 0;
361
+ }
362
+ }
363
+ return out;
364
+ }
365
+
366
+ function matchBraces(text: string, start: number): string | null {
367
+ let depth = 0;
368
+ let inStr = false;
369
+ let esc = false;
370
+ for (let i = start; i < text.length; i++) {
371
+ const c = text[i];
372
+ if (inStr) {
373
+ if (esc) esc = false;
374
+ else if (c === "\\") esc = true;
375
+ else if (c === '"') inStr = false;
376
+ } else if (c === '"') inStr = true;
377
+ else if (c === "{") depth++;
378
+ else if (c === "}" && --depth === 0) return text.slice(start, i + 1);
379
+ }
380
+ return null;
381
+ }
382
+
383
+ /** Pull the LAST real `"usage": { … }` object out of streamed/response text. Skips a trailing
384
+ * `"usage": null` and keeps scanning backwards, so a null in a late frame doesn't hide a real one. */
385
+ export function extractLastUsage(text: string): { promptTokens: number; completionTokens: number } | null {
386
+ let at = text.lastIndexOf('"usage"');
387
+ while (at >= 0) {
388
+ const brace = text.indexOf("{", at + 7);
389
+ const colon = text.indexOf(":", at + 7);
390
+ if (brace >= 0 && colon >= 0 && text.slice(colon + 1, brace).trim() === "") {
391
+ const obj = matchBraces(text, brace);
392
+ if (obj) {
393
+ try {
394
+ const u = JSON.parse(obj) as { prompt_tokens?: number; completion_tokens?: number };
395
+ if (u.prompt_tokens != null || u.completion_tokens != null) return { promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0 };
396
+ } catch {
397
+ /* malformed — keep looking backwards */
398
+ }
399
+ }
400
+ }
401
+ at = text.lastIndexOf('"usage"', at - 1);
402
+ }
403
+ return null;
404
+ }
405
+
406
+ export function storeExists(dir = dataDir()): boolean {
407
+ return existsSync(usersFile(dir)) || existsSync(policyFile(dir));
408
+ }