termi-kids 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +148 -0
  3. package/SAFETY.md +187 -0
  4. package/bin/termi.js +22 -0
  5. package/dist/agent/context.js +126 -0
  6. package/dist/agent/loop.js +172 -0
  7. package/dist/agent/prompts/system.js +45 -0
  8. package/dist/agent/tools.js +335 -0
  9. package/dist/auth/keychain.js +146 -0
  10. package/dist/auth/oauth.js +375 -0
  11. package/dist/auth/tokens.js +219 -0
  12. package/dist/cli.js +258 -0
  13. package/dist/config/paths.js +92 -0
  14. package/dist/config/pin.js +150 -0
  15. package/dist/config/settings.js +131 -0
  16. package/dist/grownups/panel.js +483 -0
  17. package/dist/learn/lessons.js +490 -0
  18. package/dist/learn/runner.js +193 -0
  19. package/dist/preview/server.js +407 -0
  20. package/dist/projects/create.js +103 -0
  21. package/dist/projects/ideas.js +182 -0
  22. package/dist/projects/quests.js +277 -0
  23. package/dist/projects/scaffolds/art.js +484 -0
  24. package/dist/projects/scaffolds/biggames.js +554 -0
  25. package/dist/projects/scaffolds/characters.js +580 -0
  26. package/dist/projects/scaffolds/games.js +516 -0
  27. package/dist/projects/scaffolds/index.js +24 -0
  28. package/dist/projects/scaffolds/music.js +528 -0
  29. package/dist/projects/scaffolds/pets.js +567 -0
  30. package/dist/projects/scaffolds/quizzes.js +757 -0
  31. package/dist/projects/scaffolds/stories.js +620 -0
  32. package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
  33. package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
  34. package/dist/projects/scaffolds/websites.js +474 -0
  35. package/dist/projects/snapshots.js +203 -0
  36. package/dist/projects/store.js +325 -0
  37. package/dist/providers/errors.js +207 -0
  38. package/dist/providers/index.js +316 -0
  39. package/dist/providers/models.js +38 -0
  40. package/dist/safety/audit.js +195 -0
  41. package/dist/safety/blocks.js +29 -0
  42. package/dist/safety/classifier.js +337 -0
  43. package/dist/safety/codescan.js +168 -0
  44. package/dist/safety/guarddownload.js +79 -0
  45. package/dist/safety/guardrunner.js +125 -0
  46. package/dist/safety/localguard.js +227 -0
  47. package/dist/safety/modelstore.js +127 -0
  48. package/dist/safety/prefilter.js +214 -0
  49. package/dist/safety/session.js +118 -0
  50. package/dist/safety/taxonomy.js +246 -0
  51. package/dist/safety/textextract.js +193 -0
  52. package/dist/setup/launcher.js +65 -0
  53. package/dist/setup/wizard.js +469 -0
  54. package/dist/surfaces/chat.js +439 -0
  55. package/dist/surfaces/commands.js +206 -0
  56. package/dist/surfaces/home.js +438 -0
  57. package/dist/types.js +5 -0
  58. package/dist/ui/banner.js +35 -0
  59. package/dist/ui/celebrate.js +141 -0
  60. package/dist/ui/errors.js +97 -0
  61. package/dist/ui/mascot.js +223 -0
  62. package/dist/ui/text.js +156 -0
  63. package/dist/ui/theme.js +92 -0
  64. package/package.json +67 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Provider client factory. One place that knows how to talk to each
3
+ * AI helper account: the ChatGPT sign-in backend, the OpenAI platform
4
+ * API, Anthropic, and xAI. Secrets come from the keychain (API keys)
5
+ * or from tokens.ts (the ChatGPT sign-in). fetch is injectable so the
6
+ * tests never touch the network.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { createAnthropic } from '@ai-sdk/anthropic';
10
+ import { createOpenAI } from '@ai-sdk/openai';
11
+ import { createXai } from '@ai-sdk/xai';
12
+ import { getSecret } from '../auth/keychain.js';
13
+ import { loadSettings } from '../config/settings.js';
14
+ import { PROTOCOL_ORIGINATOR } from '../auth/oauth.js';
15
+ import { getValidAccessToken, loadTokens } from '../auth/tokens.js';
16
+ import { resolveModelId } from './models.js';
17
+ /** Base URL of the ChatGPT coding backend (Responses wire shape, SSE). */
18
+ export const CHATGPT_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api/codex';
19
+ function isSystemLikeMessage(item) {
20
+ if (item === null || typeof item !== 'object') {
21
+ return false;
22
+ }
23
+ const role = item.role;
24
+ return role === 'developer' || role === 'system';
25
+ }
26
+ function messageText(item) {
27
+ if (item === null || typeof item !== 'object') {
28
+ return null;
29
+ }
30
+ const content = item.content;
31
+ if (typeof content === 'string') {
32
+ return content;
33
+ }
34
+ if (Array.isArray(content)) {
35
+ const parts = [];
36
+ for (const part of content) {
37
+ if (part !== null && typeof part === 'object') {
38
+ const text = part.text;
39
+ if (typeof text === 'string') {
40
+ parts.push(text);
41
+ }
42
+ }
43
+ }
44
+ if (parts.length > 0) {
45
+ return parts.join('\n');
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ /**
51
+ * Sent as instructions when a request carries no system message at all
52
+ * (for example a prompted classifier call). The backend rejects requests
53
+ * without a top-level instructions string.
54
+ */
55
+ export const CHATGPT_FALLBACK_INSTRUCTIONS = 'Follow the latest user message exactly.';
56
+ /**
57
+ * Body params the ChatGPT coding backend rejects with a 400
58
+ * ("Unsupported parameter"). Verified live against the backend.
59
+ */
60
+ const CHATGPT_REJECTED_PARAMS = ['max_output_tokens'];
61
+ /**
62
+ * The load-bearing body shim for the ChatGPT backend. The backend requires
63
+ * a top-level "instructions" string and store set to false on every call.
64
+ * The AI SDK emits the system prompt as a developer message inside input[],
65
+ * so promote it and drop the message. Requests without any system message
66
+ * (for example classifier calls) get a neutral fallback instruction, and
67
+ * store defaults to false when the caller did not set it.
68
+ * Returns null when the body needs no change.
69
+ */
70
+ function promoteInstructions(body) {
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(body);
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ return null;
80
+ }
81
+ const obj = parsed;
82
+ let changed = false;
83
+ if (obj.store === undefined || obj.store === null) {
84
+ obj.store = false;
85
+ changed = true;
86
+ }
87
+ for (const param of CHATGPT_REJECTED_PARAMS) {
88
+ if (param in obj) {
89
+ delete obj[param];
90
+ changed = true;
91
+ }
92
+ }
93
+ const hasInstructions = 'instructions' in obj && obj.instructions !== null && obj.instructions !== undefined;
94
+ const input = obj.input;
95
+ if (!hasInstructions && Array.isArray(input)) {
96
+ const index = input.findIndex((item) => isSystemLikeMessage(item));
97
+ const text = index >= 0 ? messageText(input[index]) : null;
98
+ if (index >= 0 && text !== null) {
99
+ obj.instructions = text;
100
+ obj.input = [...input.slice(0, index), ...input.slice(index + 1)];
101
+ }
102
+ else {
103
+ obj.instructions = CHATGPT_FALLBACK_INSTRUCTIONS;
104
+ }
105
+ changed = true;
106
+ }
107
+ return changed ? JSON.stringify(obj) : null;
108
+ }
109
+ /**
110
+ * Wraps fetch for the ChatGPT backend: fetches a fresh access token and
111
+ * injects the Authorization header per request, applies the instructions
112
+ * promotion shim to JSON bodies, and passes everything else through.
113
+ */
114
+ export function makeChatgptFetch(getToken, baseFetch = globalThis.fetch) {
115
+ const wrapped = async (input, init) => {
116
+ const token = await getToken();
117
+ const nextInit = { ...(init ?? {}) };
118
+ const headers = new Headers(init?.headers);
119
+ headers.set('authorization', `Bearer ${token}`);
120
+ nextInit.headers = headers;
121
+ const body = init?.body;
122
+ if (typeof body === 'string') {
123
+ const shimmed = promoteInstructions(body);
124
+ if (shimmed !== null) {
125
+ nextInit.body = shimmed;
126
+ }
127
+ }
128
+ return baseFetch(input, nextInit);
129
+ };
130
+ return wrapped;
131
+ }
132
+ /**
133
+ * Hashes the install id into an anonymous, stable safety identifier.
134
+ * Sent on OpenAI platform API calls so any abuse flag lands on this
135
+ * one install instead of the whole account, per the provider's
136
+ * guidance for products used by minors. Never reversible to the id.
137
+ */
138
+ export function hashedSafetyIdentifier(installId) {
139
+ return createHash('sha256').update(`termi:${installId}`).digest('hex').slice(0, 40);
140
+ }
141
+ /**
142
+ * Wraps fetch for the OpenAI platform API: injects safety_identifier
143
+ * into JSON request bodies that do not already carry one. Bodies that
144
+ * are not JSON objects pass through untouched.
145
+ */
146
+ export function makeSafetyIdFetch(getInstallId, baseFetch = globalThis.fetch) {
147
+ const wrapped = async (input, init) => {
148
+ const body = init?.body;
149
+ const installId = getInstallId();
150
+ if (typeof body === 'string' && installId !== null && installId.length > 0) {
151
+ try {
152
+ const obj = JSON.parse(body);
153
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
154
+ const record = obj;
155
+ if (!('safety_identifier' in record)) {
156
+ record.safety_identifier = hashedSafetyIdentifier(installId);
157
+ return baseFetch(input, { ...(init ?? {}), body: JSON.stringify(record) });
158
+ }
159
+ }
160
+ }
161
+ catch {
162
+ // Not JSON: pass through unchanged.
163
+ }
164
+ }
165
+ return baseFetch(input, init);
166
+ };
167
+ return wrapped;
168
+ }
169
+ /**
170
+ * The free moderation endpoint needs an OpenAI platform key: either the
171
+ * parent-supplied one in the keychain or a key minted at sign-in.
172
+ */
173
+ export function moderationKeyAccessor(readSecret = getSecret) {
174
+ const key = readSecret('api-key-openai-api');
175
+ if (key !== null && key.trim().length > 0) {
176
+ return key;
177
+ }
178
+ const minted = loadTokens()?.minted_api_key;
179
+ return minted !== undefined && minted.length > 0 ? minted : null;
180
+ }
181
+ /** Builds the client for one provider. Throws when it is not set up yet. */
182
+ export function createProviderClient(providerId, deps = {}) {
183
+ const readSecret = deps.readSecret ?? getSecret;
184
+ const fetchImpl = deps.fetchImpl;
185
+ const moderationEndpoint = moderationKeyAccessor(readSecret) !== null;
186
+ switch (providerId) {
187
+ case 'openai-chatgpt': {
188
+ const stored = loadTokens();
189
+ if (stored === null) {
190
+ throw new Error('provider-not-configured:openai-chatgpt');
191
+ }
192
+ const getToken = deps.getAccessToken ?? (() => getValidAccessToken(fetchImpl));
193
+ const shimFetch = makeChatgptFetch(getToken, fetchImpl ?? globalThis.fetch);
194
+ const provider = createOpenAI({
195
+ baseURL: CHATGPT_BACKEND_BASE_URL,
196
+ apiKey: 'unused',
197
+ headers: {
198
+ 'chatgpt-account-id': stored.account_id,
199
+ 'OpenAI-Beta': 'responses=experimental',
200
+ originator: PROTOCOL_ORIGINATOR,
201
+ accept: 'text/event-stream',
202
+ },
203
+ fetch: shimFetch,
204
+ });
205
+ return {
206
+ id: providerId,
207
+ languageModel: (role, alias) => provider.responses(resolveModelId(providerId, role, alias)),
208
+ moderationEndpoint,
209
+ };
210
+ }
211
+ case 'openai-api': {
212
+ const key = readSecret('api-key-openai-api') ?? loadTokens()?.minted_api_key ?? null;
213
+ if (key === null || key.length === 0) {
214
+ throw new Error('provider-not-configured:openai-api');
215
+ }
216
+ // Resolved once per client, not per request: the id never changes
217
+ // mid-session and loadSettings costs a disk read plus an HMAC check.
218
+ let resolvedInstallId;
219
+ if (deps.installId !== undefined) {
220
+ resolvedInstallId = deps.installId;
221
+ }
222
+ else {
223
+ const id = loadSettings().settings.installId;
224
+ resolvedInstallId = id.length > 0 ? id : null;
225
+ }
226
+ const provider = createOpenAI({
227
+ apiKey: key,
228
+ fetch: makeSafetyIdFetch(() => resolvedInstallId, fetchImpl ?? globalThis.fetch),
229
+ });
230
+ return {
231
+ id: providerId,
232
+ languageModel: (role, alias) => provider.responses(resolveModelId(providerId, role, alias)),
233
+ moderationEndpoint,
234
+ };
235
+ }
236
+ case 'anthropic': {
237
+ const key = readSecret('api-key-anthropic');
238
+ if (key === null || key.length === 0) {
239
+ throw new Error('provider-not-configured:anthropic');
240
+ }
241
+ const provider = createAnthropic({
242
+ apiKey: key,
243
+ ...(fetchImpl !== undefined ? { fetch: fetchImpl } : {}),
244
+ });
245
+ return {
246
+ id: providerId,
247
+ languageModel: (role, alias) => provider(resolveModelId(providerId, role, alias)),
248
+ moderationEndpoint,
249
+ };
250
+ }
251
+ case 'xai': {
252
+ const key = readSecret('api-key-xai');
253
+ if (key === null || key.length === 0) {
254
+ throw new Error('provider-not-configured:xai');
255
+ }
256
+ // The adults-only acknowledgment is enforced here, not only in the
257
+ // wizard UI: a key dropped into the keychain by any other path still
258
+ // refuses to run until a parent has confirmed.
259
+ const ack = deps.xaiParentAck ?? loadSettings().settings.xaiParentAck;
260
+ if (!ack) {
261
+ throw new Error('provider-not-configured:xai');
262
+ }
263
+ const provider = createXai({
264
+ apiKey: key,
265
+ ...(fetchImpl !== undefined ? { fetch: fetchImpl } : {}),
266
+ });
267
+ return {
268
+ id: providerId,
269
+ languageModel: (role, alias) => provider(resolveModelId(providerId, role, alias)),
270
+ moderationEndpoint,
271
+ };
272
+ }
273
+ }
274
+ }
275
+ function safeCreate(providerId, deps) {
276
+ try {
277
+ return createProviderClient(providerId, deps);
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ /** First prompted-classifier client that builds, in preference order. */
284
+ function promptedClassifierClient(availability, deps) {
285
+ const order = ['anthropic', 'openai-chatgpt', 'xai'];
286
+ for (const id of order) {
287
+ if (availability[id]) {
288
+ const client = safeCreate(id, deps);
289
+ if (client !== null) {
290
+ return client;
291
+ }
292
+ }
293
+ }
294
+ return null;
295
+ }
296
+ /**
297
+ * Picks the safety classifier backend, independent of the active main
298
+ * provider. Preference order: OpenAI moderation (any platform key,
299
+ * including a minted one), then Anthropic haiku, then the ChatGPT mini
300
+ * on quota, then xAI as the last resort. When the moderation key exists
301
+ * but the openai-api client will not build, the prompted kid-check falls
302
+ * through the same chain so grooming, pii, and jailbreak stay covered.
303
+ */
304
+ export function pickClassifierBackend(settings, availability, deps = {}) {
305
+ // The on-device guard is not chosen here: it is wired straight into the
306
+ // safety pipeline (localGuard dep) and runs alongside whichever cloud
307
+ // backends this picker selects. settings stays for future cloud toggles.
308
+ void settings;
309
+ const readSecret = deps.readSecret ?? getSecret;
310
+ const moderationKey = moderationKeyAccessor(readSecret);
311
+ if (moderationKey !== null) {
312
+ const client = safeCreate('openai-api', deps) ?? promptedClassifierClient(availability, deps);
313
+ return { moderationKey, classifierClient: client };
314
+ }
315
+ return { moderationKey: null, classifierClient: promptedClassifierClient(availability, deps) };
316
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The single home for model ids. Everything else uses aliases.
3
+ * "zippy" is the fast default, "smart" is the careful one, and
4
+ * "classifier" is the model the safety checks call when prompted.
5
+ */
6
+ export const MODELS = {
7
+ 'openai-chatgpt': {
8
+ zippy: 'gpt-5.4-mini',
9
+ smart: 'gpt-5.5',
10
+ classifier: 'gpt-5.4-mini',
11
+ },
12
+ 'openai-api': {
13
+ zippy: 'gpt-5.4-mini',
14
+ smart: 'gpt-5.5',
15
+ classifier: 'gpt-5.4-mini',
16
+ },
17
+ anthropic: {
18
+ zippy: 'claude-sonnet-4-6',
19
+ smart: 'claude-opus-4-8',
20
+ classifier: 'claude-haiku-4-5',
21
+ },
22
+ xai: {
23
+ zippy: 'grok-4.3',
24
+ smart: 'grok-4.5',
25
+ classifier: 'grok-4.3',
26
+ },
27
+ };
28
+ /** The free dedicated moderation model on the OpenAI platform API. */
29
+ export const MODERATION_MODEL_ID = 'omni-moderation-latest';
30
+ /** Kid copy for the model picker. */
31
+ export function modelLabel(alias) {
32
+ return alias === 'zippy' ? 'Zippy' : 'Extra smart';
33
+ }
34
+ /** Resolves an alias to the concrete model id for a provider and role. */
35
+ export function resolveModelId(providerId, role, alias) {
36
+ const set = MODELS[providerId];
37
+ return role === 'classifier' ? set.classifier : set[alias];
38
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tamper-evident audit log: JSONL with a forward HMAC chain.
3
+ *
4
+ * Each line is {...event, prevMac, mac} where
5
+ * mac = HMAC-SHA256(key, prevMac + canonicalJson(event)).
6
+ * The key is the shared "hmac-key" keychain secret (created on first use).
7
+ * Appends are single appendFileSync lines, so concurrent writers interleave
8
+ * whole entries. Rotation at 5 MB renames to audit.log.1 and starts the new
9
+ * file with an anchor line that carries the old file's last mac.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import { getSecret, setSecret } from '../auth/keychain.js';
14
+ import { auditLogPath, ensureDirs, termiHome } from '../config/paths.js';
15
+ export const AUDIT_MAX_BYTES = 5 * 1024 * 1024;
16
+ const GENESIS = 'genesis';
17
+ const HMAC_ACCOUNT = 'hmac-key';
18
+ /** Where the rotated previous log lands. */
19
+ export function rotatedAuditLogPath() {
20
+ return `${auditLogPath()}.1`;
21
+ }
22
+ /**
23
+ * The key and the chain tip are cached between appends: without this,
24
+ * every audit event pays a keychain round trip plus a full read of the
25
+ * log just to find the last mac. Both caches self-invalidate when the
26
+ * state home or the file changes underneath them.
27
+ */
28
+ let keyCache = null;
29
+ let tipCache = null;
30
+ /** Loads (or creates once) the shared HMAC key. */
31
+ function hmacKey() {
32
+ const home = termiHome();
33
+ if (keyCache !== null && keyCache.home === home) {
34
+ return keyCache.key;
35
+ }
36
+ let hex = getSecret(HMAC_ACCOUNT);
37
+ if (!hex) {
38
+ hex = crypto.randomBytes(32).toString('hex');
39
+ setSecret(HMAC_ACCOUNT, hex);
40
+ }
41
+ const key = Buffer.from(hex, 'hex');
42
+ keyCache = { home, key };
43
+ return key;
44
+ }
45
+ /** JSON with recursively sorted keys, so the MAC input is stable. */
46
+ export function canonicalJson(value) {
47
+ if (Array.isArray(value)) {
48
+ return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
49
+ }
50
+ if (value && typeof value === 'object') {
51
+ const entries = Object.entries(value)
52
+ .filter(([, v]) => v !== undefined)
53
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
54
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
55
+ }
56
+ return JSON.stringify(value) ?? 'null';
57
+ }
58
+ function computeMac(key, prevMac, entryCanonical) {
59
+ return crypto.createHmac('sha256', key).update(prevMac).update(entryCanonical).digest('hex');
60
+ }
61
+ /** Reads the mac of the last line, or null for a fresh chain. */
62
+ function lastMac(file) {
63
+ let raw;
64
+ try {
65
+ raw = fs.readFileSync(file, 'utf8');
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
71
+ const last = lines[lines.length - 1];
72
+ if (!last) {
73
+ return null;
74
+ }
75
+ try {
76
+ const parsed = JSON.parse(last);
77
+ return typeof parsed.mac === 'string' ? parsed.mac : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** Strips chain fields so the original entry can be re-canonicalized. */
84
+ function entryWithoutChain(line) {
85
+ const copy = { ...line };
86
+ delete copy['prevMac'];
87
+ delete copy['mac'];
88
+ return copy;
89
+ }
90
+ function appendLine(file, entry, prevMac, key) {
91
+ const mac = computeMac(key, prevMac, canonicalJson(entry));
92
+ const full = { ...entry, prevMac, mac };
93
+ fs.appendFileSync(file, `${JSON.stringify(full)}\n`, { mode: 0o600 });
94
+ return mac;
95
+ }
96
+ /**
97
+ * Rotates when the file passed maxBytes: rename to audit.log.1 and start the
98
+ * new file with an anchor entry carrying the rotated file's last mac.
99
+ */
100
+ function maybeRotate(file, key, maxBytes) {
101
+ let size = 0;
102
+ try {
103
+ size = fs.statSync(file).size;
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ if (size <= maxBytes) {
109
+ return;
110
+ }
111
+ const carried = lastMac(file) ?? GENESIS;
112
+ fs.rmSync(rotatedAuditLogPath(), { force: true });
113
+ fs.renameSync(file, rotatedAuditLogPath());
114
+ const anchor = {
115
+ ts: new Date().toISOString(),
116
+ layer: 'system',
117
+ anchor: true,
118
+ rotatedFrom: 'audit.log.1',
119
+ };
120
+ // The anchor chains off the rotated file's last mac, preserving continuity.
121
+ appendLine(file, anchor, carried, key);
122
+ }
123
+ /** Appends one audit event to the chained log. */
124
+ export function appendAudit(event, opts) {
125
+ ensureDirs();
126
+ const file = auditLogPath();
127
+ const key = hmacKey();
128
+ maybeRotate(file, key, opts?.maxBytes ?? AUDIT_MAX_BYTES);
129
+ let size = 0;
130
+ try {
131
+ size = fs.statSync(file).size;
132
+ }
133
+ catch {
134
+ size = 0;
135
+ }
136
+ // The cached tip is only trusted while the file is exactly as we left
137
+ // it; any outside change falls back to reading the real last line.
138
+ const prevMac = tipCache !== null && tipCache.file === file && tipCache.size === size && size > 0
139
+ ? tipCache.mac
140
+ : (lastMac(file) ?? GENESIS);
141
+ const mac = appendLine(file, { ...event }, prevMac, key);
142
+ try {
143
+ tipCache = { file, size: fs.statSync(file).size, mac };
144
+ }
145
+ catch {
146
+ tipCache = null;
147
+ }
148
+ }
149
+ /**
150
+ * Walks the audit log and verifies the HMAC chain. A leading anchor line may
151
+ * carry a prevMac from a rotated file; its own mac must still verify.
152
+ */
153
+ export function verifyAuditChain(file = auditLogPath()) {
154
+ let raw;
155
+ try {
156
+ raw = fs.readFileSync(file, 'utf8');
157
+ }
158
+ catch {
159
+ return { ok: true, entries: 0, firstBadLine: null };
160
+ }
161
+ const key = hmacKey();
162
+ const lines = raw.split('\n');
163
+ let expectedPrev = null; // null until the first line fixes it
164
+ let entries = 0;
165
+ let lineNo = 0;
166
+ for (const line of lines) {
167
+ lineNo++;
168
+ if (!line.trim()) {
169
+ continue;
170
+ }
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(line);
174
+ }
175
+ catch {
176
+ return { ok: false, entries, firstBadLine: lineNo };
177
+ }
178
+ if (typeof parsed.mac !== 'string' || typeof parsed.prevMac !== 'string') {
179
+ return { ok: false, entries, firstBadLine: lineNo };
180
+ }
181
+ if (expectedPrev !== null && parsed.prevMac !== expectedPrev) {
182
+ return { ok: false, entries, firstBadLine: lineNo };
183
+ }
184
+ if (expectedPrev === null && !(parsed.prevMac === GENESIS || parsed['anchor'] === true)) {
185
+ return { ok: false, entries, firstBadLine: lineNo };
186
+ }
187
+ const recomputed = computeMac(key, parsed.prevMac, canonicalJson(entryWithoutChain(parsed)));
188
+ if (recomputed !== parsed.mac) {
189
+ return { ok: false, entries, firstBadLine: lineNo };
190
+ }
191
+ expectedPrev = parsed.mac;
192
+ entries++;
193
+ }
194
+ return { ok: true, entries, firstBadLine: null };
195
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * L5: turns a block verdict into the screen the kid sees.
3
+ * Never echoes blocked content. Always offers a way forward.
4
+ */
5
+ import { T } from '../ui/text.js';
6
+ import { blockMessage } from './taxonomy.js';
7
+ /** Maps a blocking verdict to the kid-facing screen. */
8
+ export function verdictToScreen(verdict) {
9
+ if (verdict.selfHarmConcern) {
10
+ return {
11
+ title: 'Thank you for telling me.',
12
+ body: T.selfHarmSupport.message,
13
+ mascotExpression: 'gentleNo',
14
+ };
15
+ }
16
+ if (verdict.failClosed) {
17
+ return {
18
+ title: 'Quick break time.',
19
+ body: T.errors.failClosed,
20
+ mascotExpression: 'oops',
21
+ };
22
+ }
23
+ const message = verdict.kidMessage ?? blockMessage(verdict.categories);
24
+ return {
25
+ title: 'Let us try that another way.',
26
+ body: `${message}\n${T.blocks.rephraseTip}`,
27
+ mascotExpression: 'gentleNo',
28
+ };
29
+ }