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.
- package/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The safety pipeline (L0 + L2 + L4 wiring). FAIL CLOSED is the law here:
|
|
3
|
+
* any timeout, HTTP error, rate limit, or malformed verdict becomes a block
|
|
4
|
+
* with failClosed set. The main model's output is never revealed and writes
|
|
5
|
+
* never land without a clean pass.
|
|
6
|
+
*
|
|
7
|
+
* Backends:
|
|
8
|
+
* - With an OpenAI moderation key: the free moderation endpoint covers the
|
|
9
|
+
* broad taxonomy while a compact prompted kid-check (grooming, pii,
|
|
10
|
+
* jailbreak) runs in parallel on the classifier model.
|
|
11
|
+
* - Without one: a single prompted classifier covers the full taxonomy.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { streamText } from 'ai';
|
|
15
|
+
import { T } from '../ui/text.js';
|
|
16
|
+
import { scanCode } from './codescan.js';
|
|
17
|
+
import { normalizeText, prefilterContext as prefilterContextImpl, prefilterInput as prefilterInputImpl, } from './prefilter.js';
|
|
18
|
+
import { bumpCounters, groomingEscalation, windowText } from './session.js';
|
|
19
|
+
import { blockMessage, buildClassifierPrompt, failClosedVerdict, MODERATION_CUTOFFS, parseVerdict, primaryCategory, severityBlocks, } from './taxonomy.js';
|
|
20
|
+
import { extractVisibleText } from './textextract.js';
|
|
21
|
+
export const DEFAULT_TIMEOUT_MS = 8000;
|
|
22
|
+
const MODERATION_URL = 'https://api.openai.com/v1/moderations';
|
|
23
|
+
const MODERATION_MODEL = 'omni-moderation-latest';
|
|
24
|
+
/** Chars of judged text per prompted-check call (token efficiency). */
|
|
25
|
+
export const JUDGE_TEXT_CAP = 2000;
|
|
26
|
+
/**
|
|
27
|
+
* Verdict budget for one prompted check. Reasoning models spend thinking
|
|
28
|
+
* tokens from this budget before any visible text; too small a cap starves
|
|
29
|
+
* the verdict, which fails closed and blocks everything. Keep it roomy.
|
|
30
|
+
*/
|
|
31
|
+
export const CLASSIFIER_MAX_OUTPUT_TOKENS = 600;
|
|
32
|
+
/** Most allow verdicts remembered per session for unchanged file text. */
|
|
33
|
+
const FILE_VERDICT_CACHE_CAP = 200;
|
|
34
|
+
/** Rejects when the wrapped promise takes longer than ms. */
|
|
35
|
+
async function withTimeout(promise, ms) {
|
|
36
|
+
let timer;
|
|
37
|
+
const timeout = new Promise((_, reject) => {
|
|
38
|
+
timer = setTimeout(() => reject(new Error('safety check timed out')), ms);
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([promise, timeout]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function maxSeverity(a, b) {
|
|
48
|
+
return a >= b ? a : b;
|
|
49
|
+
}
|
|
50
|
+
/** Merges parallel verdicts: most severe wins, any failure stays a block. */
|
|
51
|
+
function mergeVerdicts(verdicts) {
|
|
52
|
+
if (verdicts.length === 0) {
|
|
53
|
+
return failClosedVerdict();
|
|
54
|
+
}
|
|
55
|
+
const categories = [];
|
|
56
|
+
let severity = 0;
|
|
57
|
+
let selfHarmConcern = false;
|
|
58
|
+
let anyFailClosed = false;
|
|
59
|
+
let allAllowed = true;
|
|
60
|
+
for (const v of verdicts) {
|
|
61
|
+
for (const c of v.categories) {
|
|
62
|
+
if (!categories.includes(c)) {
|
|
63
|
+
categories.push(c);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
severity = maxSeverity(severity, v.severity);
|
|
67
|
+
selfHarmConcern = selfHarmConcern || v.selfHarmConcern;
|
|
68
|
+
anyFailClosed = anyFailClosed || v.failClosed;
|
|
69
|
+
allAllowed = allAllowed && v.allowed;
|
|
70
|
+
}
|
|
71
|
+
const blocked = anyFailClosed || !allAllowed || severityBlocks(categories, severity);
|
|
72
|
+
if (!blocked) {
|
|
73
|
+
return { allowed: true, categories, severity, selfHarmConcern, failClosed: false, kidMessage: null };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
allowed: false,
|
|
77
|
+
categories,
|
|
78
|
+
severity,
|
|
79
|
+
selfHarmConcern,
|
|
80
|
+
failClosed: anyFailClosed,
|
|
81
|
+
kidMessage: categories.length > 0 ? blockMessage(categories) : T.errors.failClosed,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function moderationCheck(key, text, fetchImpl) {
|
|
85
|
+
const res = await fetchImpl(MODERATION_URL, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'content-type': 'application/json',
|
|
89
|
+
authorization: `Bearer ${key}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ model: MODERATION_MODEL, input: text }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`moderation endpoint returned ${res.status}`);
|
|
95
|
+
}
|
|
96
|
+
const data = (await res.json());
|
|
97
|
+
const scores = data.results?.[0]?.category_scores;
|
|
98
|
+
if (!scores || typeof scores !== 'object') {
|
|
99
|
+
throw new Error('moderation response malformed');
|
|
100
|
+
}
|
|
101
|
+
// An empty or unrecognized scores object must not read as "all clear".
|
|
102
|
+
if (!MODERATION_CUTOFFS.some((cutoff) => typeof scores[cutoff.score] === 'number')) {
|
|
103
|
+
throw new Error('moderation response malformed');
|
|
104
|
+
}
|
|
105
|
+
const categories = [];
|
|
106
|
+
let severity = 0;
|
|
107
|
+
let selfHarmConcern = false;
|
|
108
|
+
for (const cutoff of MODERATION_CUTOFFS) {
|
|
109
|
+
const value = scores[cutoff.score];
|
|
110
|
+
if (typeof value === 'number' && value >= cutoff.min) {
|
|
111
|
+
if (!categories.includes(cutoff.category)) {
|
|
112
|
+
categories.push(cutoff.category);
|
|
113
|
+
}
|
|
114
|
+
severity = maxSeverity(severity, cutoff.severity);
|
|
115
|
+
if (cutoff.selfHarmConcern) {
|
|
116
|
+
selfHarmConcern = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const blocked = severityBlocks(categories, severity);
|
|
121
|
+
return {
|
|
122
|
+
allowed: !blocked,
|
|
123
|
+
categories,
|
|
124
|
+
severity,
|
|
125
|
+
selfHarmConcern,
|
|
126
|
+
failClosed: false,
|
|
127
|
+
kidMessage: blocked ? blockMessage(categories) : null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function promptedCheck(model, direction, composedWindow, scope) {
|
|
131
|
+
// streamText, not generateText: the ChatGPT coding backend requires
|
|
132
|
+
// stream true on every call. The text is collected, never revealed.
|
|
133
|
+
let streamError = null;
|
|
134
|
+
const result = streamText({
|
|
135
|
+
model,
|
|
136
|
+
prompt: buildClassifierPrompt(direction, composedWindow, scope),
|
|
137
|
+
temperature: 0,
|
|
138
|
+
maxOutputTokens: CLASSIFIER_MAX_OUTPUT_TOKENS,
|
|
139
|
+
onError: ({ error }) => {
|
|
140
|
+
if (streamError === null) {
|
|
141
|
+
streamError = error;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
let text;
|
|
146
|
+
try {
|
|
147
|
+
text = await result.text;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
throw streamError ?? err;
|
|
151
|
+
}
|
|
152
|
+
if (streamError !== null) {
|
|
153
|
+
throw streamError;
|
|
154
|
+
}
|
|
155
|
+
return parseVerdict(text);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Judged text is data. Braces are swapped for parentheses before the text
|
|
159
|
+
* enters a classifier prompt, so an echo of judged content can never form
|
|
160
|
+
* the JSON object that parseVerdict looks for (verdict forgery).
|
|
161
|
+
*/
|
|
162
|
+
function neutralizeJudged(text) {
|
|
163
|
+
return text.replace(/\{/g, '(').replace(/\}/g, ')');
|
|
164
|
+
}
|
|
165
|
+
/** Splits normalized judged text into prompt-sized chunks (at least one). */
|
|
166
|
+
function judgeChunks(normalized) {
|
|
167
|
+
if (normalized.length <= JUDGE_TEXT_CAP) {
|
|
168
|
+
return [normalized];
|
|
169
|
+
}
|
|
170
|
+
const chunks = [];
|
|
171
|
+
for (let i = 0; i < normalized.length; i += JUDGE_TEXT_CAP) {
|
|
172
|
+
chunks.push(normalized.slice(i, i + JUDGE_TEXT_CAP));
|
|
173
|
+
}
|
|
174
|
+
return chunks;
|
|
175
|
+
}
|
|
176
|
+
export function createSafetyPipeline(deps) {
|
|
177
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
179
|
+
// Session-scoped allow cache for file text: the same visible text is never
|
|
180
|
+
// re-judged remotely twice. Blocks and failures are never cached.
|
|
181
|
+
const fileAllowCache = new Set();
|
|
182
|
+
function auditVerdict(verdict, direction, text, groomingFlag) {
|
|
183
|
+
if (verdict.allowed) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
deps.audit({
|
|
187
|
+
ts: new Date().toISOString(),
|
|
188
|
+
layer: direction === 'input' ? 'L2' : 'L4',
|
|
189
|
+
event: groomingFlag ? 'grooming_flag' : verdict.failClosed ? 'fail_closed' : 'block',
|
|
190
|
+
category: primaryCategory(verdict.categories) ?? undefined,
|
|
191
|
+
severity: verdict.severity,
|
|
192
|
+
direction,
|
|
193
|
+
excerpt: text.slice(0, 80),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
async function check(direction, text, state, source) {
|
|
197
|
+
try {
|
|
198
|
+
const normalized = normalizeText(text);
|
|
199
|
+
const cacheKey = source === 'file'
|
|
200
|
+
? createHash('sha256').update(normalized).digest('hex')
|
|
201
|
+
: null;
|
|
202
|
+
if (cacheKey !== null && fileAllowCache.has(cacheKey)) {
|
|
203
|
+
return {
|
|
204
|
+
allowed: true,
|
|
205
|
+
categories: [],
|
|
206
|
+
severity: 0,
|
|
207
|
+
selfHarmConcern: false,
|
|
208
|
+
failClosed: false,
|
|
209
|
+
kidMessage: null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Every chunk of the judged text gets its own prompted check, so
|
|
213
|
+
// nothing past the per-call cap goes unjudged. Braces in judged text
|
|
214
|
+
// are neutralized so an echo cannot forge a verdict.
|
|
215
|
+
const window = neutralizeJudged(windowText(state));
|
|
216
|
+
const chunks = judgeChunks(neutralizeJudged(normalized));
|
|
217
|
+
const tasks = [];
|
|
218
|
+
const key = deps.moderationKey();
|
|
219
|
+
const model = deps.classifierModel();
|
|
220
|
+
const guard = deps.localGuard?.() ?? null;
|
|
221
|
+
if (key) {
|
|
222
|
+
tasks.push(withTimeout(moderationCheck(key, text, fetchImpl), timeoutMs));
|
|
223
|
+
}
|
|
224
|
+
if (guard) {
|
|
225
|
+
// The on-device guard judges every chunk across its full taxonomy,
|
|
226
|
+
// for input and output alike. On output checks it also sees the
|
|
227
|
+
// kid's last message, the exchange shape it was trained on. No
|
|
228
|
+
// withTimeout wrapper here: the runner bounds its own load and each
|
|
229
|
+
// generation, and chunks queue behind one another, so an outer timer
|
|
230
|
+
// started at task creation would fail-closed on long files for
|
|
231
|
+
// nothing more than waiting their turn.
|
|
232
|
+
const lastKid = [...state.recentTurns].reverse().find((turn) => turn.role === 'kid')?.text ?? '';
|
|
233
|
+
for (const chunk of chunks) {
|
|
234
|
+
tasks.push(direction === 'input'
|
|
235
|
+
? guard.classifyInput(chunk)
|
|
236
|
+
: guard.classifyOutput(lastKid, chunk));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (model) {
|
|
240
|
+
// Only the moderation endpoint narrows the prompted check to the
|
|
241
|
+
// kid-specific categories: its taxonomy covers hate and harassment.
|
|
242
|
+
// The on-device guard does NOT narrow it. The guard has no
|
|
243
|
+
// hate_harassment or profanity category, so with the guard as the
|
|
244
|
+
// only broad backend the full prompted scope must keep running or
|
|
245
|
+
// those two categories would silently lose their model coverage.
|
|
246
|
+
const scope = key ? 'kidcheck' : 'full';
|
|
247
|
+
for (const chunk of chunks) {
|
|
248
|
+
const composedWindow = `${window}\nTEXT TO JUDGE:\n${chunk}`;
|
|
249
|
+
tasks.push(withTimeout(promptedCheck(model, direction, composedWindow, scope), timeoutMs));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let merged;
|
|
253
|
+
if (tasks.length === 0) {
|
|
254
|
+
// No classifier backend available: never fail open.
|
|
255
|
+
merged = failClosedVerdict();
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const settled = await Promise.all(tasks.map((t) => t.catch(() => failClosedVerdict())));
|
|
259
|
+
merged = mergeVerdicts(settled);
|
|
260
|
+
}
|
|
261
|
+
// Cross-turn grooming escalation off cumulative session counters.
|
|
262
|
+
// File text never bumps: counters track the conversation (what the
|
|
263
|
+
// kid types and what Termi says), not the kid's own game content.
|
|
264
|
+
if (source === 'chat') {
|
|
265
|
+
bumpCounters(state, merged.categories, text);
|
|
266
|
+
}
|
|
267
|
+
let groomingFlag = false;
|
|
268
|
+
if (source === 'chat' && groomingEscalation(state)) {
|
|
269
|
+
groomingFlag = true;
|
|
270
|
+
const categories = merged.categories.includes('grooming')
|
|
271
|
+
? merged.categories
|
|
272
|
+
: ['grooming', ...merged.categories];
|
|
273
|
+
merged = {
|
|
274
|
+
allowed: false,
|
|
275
|
+
categories,
|
|
276
|
+
severity: maxSeverity(merged.severity, 2),
|
|
277
|
+
selfHarmConcern: merged.selfHarmConcern,
|
|
278
|
+
failClosed: merged.failClosed,
|
|
279
|
+
kidMessage: blockMessage(categories),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (cacheKey !== null && merged.allowed && !merged.failClosed) {
|
|
283
|
+
if (fileAllowCache.size >= FILE_VERDICT_CACHE_CAP) {
|
|
284
|
+
fileAllowCache.clear();
|
|
285
|
+
}
|
|
286
|
+
fileAllowCache.add(cacheKey);
|
|
287
|
+
}
|
|
288
|
+
auditVerdict(merged, direction, text, groomingFlag);
|
|
289
|
+
return merged;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Belt and suspenders: nothing in this pipeline may throw upward.
|
|
293
|
+
const verdict = failClosedVerdict();
|
|
294
|
+
auditVerdict(verdict, direction, text, false);
|
|
295
|
+
return verdict;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
prefilterInput(text) {
|
|
300
|
+
const result = prefilterInputImpl(text);
|
|
301
|
+
if (result.block) {
|
|
302
|
+
deps.audit({
|
|
303
|
+
ts: new Date().toISOString(),
|
|
304
|
+
layer: 'L0',
|
|
305
|
+
event: 'block',
|
|
306
|
+
category: result.block.categories[0],
|
|
307
|
+
severity: result.block.severity,
|
|
308
|
+
direction: 'input',
|
|
309
|
+
excerpt: text.slice(0, 80),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else if (result.notice) {
|
|
313
|
+
deps.audit({
|
|
314
|
+
ts: new Date().toISOString(),
|
|
315
|
+
layer: 'L0',
|
|
316
|
+
event: 'redact',
|
|
317
|
+
category: 'pii',
|
|
318
|
+
direction: 'input',
|
|
319
|
+
// The excerpt comes from the redacted text so PII never lands in the log.
|
|
320
|
+
excerpt: result.redacted.slice(0, 80),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
},
|
|
325
|
+
prefilterContext(text) {
|
|
326
|
+
return prefilterContextImpl(text);
|
|
327
|
+
},
|
|
328
|
+
checkInput(text, s) {
|
|
329
|
+
return check('input', text, s, 'chat');
|
|
330
|
+
},
|
|
331
|
+
checkOutputText(text, s, source = 'reply') {
|
|
332
|
+
return check('output', text, s, source === 'file' ? 'file' : 'chat');
|
|
333
|
+
},
|
|
334
|
+
scanCode,
|
|
335
|
+
extractVisibleText,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L4c: static code scan, defense in depth only. The sound egress control is
|
|
3
|
+
* the preview server CSP; this scanner catches the obvious tricks early and
|
|
4
|
+
* explains them in plain engineering terms the model can act on.
|
|
5
|
+
*
|
|
6
|
+
* Only JS, HTML, and CSS files are scanned. Markdown and plain text go
|
|
7
|
+
* through text classification instead. Relative and local references pass.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
function fileKind(relPath) {
|
|
11
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
12
|
+
switch (ext) {
|
|
13
|
+
case '.js':
|
|
14
|
+
case '.mjs':
|
|
15
|
+
case '.cjs':
|
|
16
|
+
return 'js';
|
|
17
|
+
case '.html':
|
|
18
|
+
case '.htm':
|
|
19
|
+
return 'html';
|
|
20
|
+
case '.css':
|
|
21
|
+
return 'css';
|
|
22
|
+
default:
|
|
23
|
+
return 'skip';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const EXTERNAL_URL = 'https?:|//';
|
|
27
|
+
const RULES = [
|
|
28
|
+
// Network APIs. Termi projects must work with zero network.
|
|
29
|
+
{
|
|
30
|
+
kinds: ['js', 'html'],
|
|
31
|
+
regex: /\bfetch\s*\(/,
|
|
32
|
+
reason: 'uses fetch, a network call. Projects must work fully offline.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kinds: ['js', 'html'],
|
|
36
|
+
regex: /\bXMLHttpRequest\b/,
|
|
37
|
+
reason: 'uses XMLHttpRequest, a network call. Projects must work fully offline.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kinds: ['js', 'html'],
|
|
41
|
+
regex: /\bnew\s+WebSocket\s*\(|\bWebSocket\s*\(/,
|
|
42
|
+
reason: 'opens a WebSocket, a live network connection. Not allowed.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kinds: ['js', 'html'],
|
|
46
|
+
regex: /\bnew\s+EventSource\s*\(|\bEventSource\s*\(/,
|
|
47
|
+
reason: 'opens an EventSource, a network stream. Not allowed.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
kinds: ['js', 'html'],
|
|
51
|
+
regex: /\bsendBeacon\s*\(/,
|
|
52
|
+
reason: 'uses sendBeacon, which sends data out. Not allowed.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kinds: ['js', 'html'],
|
|
56
|
+
regex: /\bRTCPeerConnection\b/,
|
|
57
|
+
reason: 'uses RTCPeerConnection, a network channel. Not allowed.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kinds: ['js', 'html'],
|
|
61
|
+
regex: new RegExp(`\\.src\\s*=\\s*["'\`]\\s*(?:${EXTERNAL_URL})`),
|
|
62
|
+
reason: 'sets a src to an outside web address. Use local files only.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kinds: ['js'],
|
|
66
|
+
regex: new RegExp(`\\bimport\\s*\\(\\s*["'\`](?:${EXTERNAL_URL})`),
|
|
67
|
+
reason: 'imports code from the internet. Import local files only.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
kinds: ['js'],
|
|
71
|
+
regex: new RegExp(`\\bimport\\b[^'"\`\\n]*["'\`](?:${EXTERNAL_URL})`),
|
|
72
|
+
reason: 'imports code from the internet. Import local files only.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
kinds: ['html'],
|
|
76
|
+
regex: new RegExp(`<script[^>]+src\\s*=\\s*["']?\\s*(?:${EXTERNAL_URL})`, 'i'),
|
|
77
|
+
reason: 'loads a script from the internet. Use local script files only.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
kinds: ['html'],
|
|
81
|
+
regex: new RegExp(`<(?:link|img|iframe|audio|video|source|embed|object)[^>]+(?:href|src|data)\\s*=\\s*["']?\\s*(?:${EXTERNAL_URL})`, 'i'),
|
|
82
|
+
reason: 'loads something from an outside web address. Use local files only.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
kinds: ['html'],
|
|
86
|
+
regex: new RegExp(`<form[^>]+action\\s*=\\s*["']?\\s*(?:${EXTERNAL_URL})`, 'i'),
|
|
87
|
+
reason: 'sends a form to an outside web address. Forms must stay local.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kinds: ['css'],
|
|
91
|
+
regex: new RegExp(`url\\(\\s*["']?\\s*(?:${EXTERNAL_URL})`, 'i'),
|
|
92
|
+
reason: 'loads a style resource from the internet. Use local files only.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
kinds: ['css'],
|
|
96
|
+
regex: new RegExp(`@import\\s+["']?\\s*(?:${EXTERNAL_URL})`, 'i'),
|
|
97
|
+
reason: 'imports a stylesheet from the internet. Use local files only.',
|
|
98
|
+
},
|
|
99
|
+
// Eval family. Code built from strings is impossible to review.
|
|
100
|
+
{
|
|
101
|
+
kinds: ['js', 'html'],
|
|
102
|
+
regex: /\beval\s*\(/,
|
|
103
|
+
reason: 'uses eval, which runs code from a string. Write the code directly.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
kinds: ['js', 'html'],
|
|
107
|
+
regex: /\bnew\s+Function\s*\(/,
|
|
108
|
+
reason: 'uses new Function, which runs code from a string. Write the code directly.',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
kinds: ['js', 'html'],
|
|
112
|
+
regex: /\bset(?:Timeout|Interval)\s*\(\s*["'`]/,
|
|
113
|
+
reason: 'passes a code string to a timer. Pass a function instead.',
|
|
114
|
+
},
|
|
115
|
+
// Storage and document tricks.
|
|
116
|
+
{
|
|
117
|
+
kinds: ['js', 'html'],
|
|
118
|
+
regex: /\bdocument\s*\.\s*cookie\b/,
|
|
119
|
+
reason: 'touches document.cookie. Use localStorage for saving game data.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
kinds: ['js', 'html'],
|
|
123
|
+
regex: /["'`=]\s*javascript:/i,
|
|
124
|
+
reason: 'uses a javascript: link, which hides code in a URL. Not allowed.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
kinds: ['js', 'html'],
|
|
128
|
+
regex: /data:text\/html/i,
|
|
129
|
+
reason: 'uses a data:text/html URL, which hides a page in a URL. Not allowed.',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
kinds: ['html'],
|
|
133
|
+
regex: /\bsrcdoc\s*=/i,
|
|
134
|
+
reason: 'uses iframe srcdoc, which embeds a hidden page. Not allowed.',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
kinds: ['html'],
|
|
138
|
+
regex: /<base\b/i,
|
|
139
|
+
reason: 'uses a base tag, which redirects where files load from. Not allowed.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kinds: ['html'],
|
|
143
|
+
regex: /<meta[^>]+http-equiv\s*=\s*["']?refresh[^>]*url\s*=/i,
|
|
144
|
+
reason: 'uses a meta refresh that jumps to another page. Not allowed.',
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
/**
|
|
148
|
+
* Scans one project file. Returns ok plus plain-language reasons for each
|
|
149
|
+
* finding. TERMI.md and .txt files skip the scan: they are prose and go
|
|
150
|
+
* through text classification.
|
|
151
|
+
*/
|
|
152
|
+
export function scanCode(relPath, content) {
|
|
153
|
+
const kind = fileKind(relPath);
|
|
154
|
+
if (kind === 'skip') {
|
|
155
|
+
return { ok: true, reasons: [] };
|
|
156
|
+
}
|
|
157
|
+
const reasons = [];
|
|
158
|
+
for (const rule of RULES) {
|
|
159
|
+
if (!rule.kinds.includes(kind)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
rule.regex.lastIndex = 0;
|
|
163
|
+
if (rule.regex.test(content)) {
|
|
164
|
+
reasons.push(`${relPath}: ${rule.reason}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ok: reasons.length === 0, reasons };
|
|
168
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background fetcher for the on-device classifier model.
|
|
3
|
+
*
|
|
4
|
+
* One in-flight download per process, kicked off without blocking whatever
|
|
5
|
+
* the kid or parent is doing. Progress is observable (the home menu and the
|
|
6
|
+
* grown-ups panel render it as a bar), completion is auditable, and the
|
|
7
|
+
* safety pipeline hot-attaches the classifier on its next check once the
|
|
8
|
+
* verified file lands. Interrupted transfers resume on the next kickoff.
|
|
9
|
+
*/
|
|
10
|
+
import { appendAudit } from './audit.js';
|
|
11
|
+
import { downloadGuardModel, GUARD_MODEL, guardModelReady, } from './modelstore.js';
|
|
12
|
+
let state = { status: 'idle', written: 0, total: GUARD_MODEL.bytes };
|
|
13
|
+
let inFlight = null;
|
|
14
|
+
/** Snapshot of the background fetch. 'ready' wins once the file exists. */
|
|
15
|
+
export function guardFetchState() {
|
|
16
|
+
if (state.status !== 'downloading' && guardModelReady()) {
|
|
17
|
+
return { status: 'ready', written: GUARD_MODEL.bytes, total: GUARD_MODEL.bytes };
|
|
18
|
+
}
|
|
19
|
+
return { ...state };
|
|
20
|
+
}
|
|
21
|
+
function audit(excerpt) {
|
|
22
|
+
try {
|
|
23
|
+
appendAudit({ ts: new Date().toISOString(), layer: 'system', event: 'settings_change', excerpt });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// The fetcher never falls over because the audit disk write failed.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Starts the background download when the model is missing and nothing is
|
|
31
|
+
* already in flight. Returns the in-flight promise (true = model ready) so
|
|
32
|
+
* blocking callers, like the grown-ups panel, can await it; fire-and-forget
|
|
33
|
+
* callers just ignore it.
|
|
34
|
+
*/
|
|
35
|
+
export function ensureGuardFetch(opts = {}) {
|
|
36
|
+
if (guardModelReady()) {
|
|
37
|
+
state = { status: 'ready', written: GUARD_MODEL.bytes, total: GUARD_MODEL.bytes };
|
|
38
|
+
return Promise.resolve(true);
|
|
39
|
+
}
|
|
40
|
+
if (inFlight !== null) {
|
|
41
|
+
return inFlight;
|
|
42
|
+
}
|
|
43
|
+
state = { status: 'downloading', written: 0, total: opts.artifact?.bytes ?? GUARD_MODEL.bytes };
|
|
44
|
+
inFlight = downloadGuardModel({
|
|
45
|
+
...opts,
|
|
46
|
+
onProgress: (written, total) => {
|
|
47
|
+
state.written = written;
|
|
48
|
+
state.total = total;
|
|
49
|
+
opts.onProgress?.(written, total);
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
.then(() => {
|
|
53
|
+
state = { ...state, status: 'ready', written: state.total };
|
|
54
|
+
audit('local classifier model downloaded');
|
|
55
|
+
return true;
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
state = { ...state, status: 'failed' };
|
|
59
|
+
audit('local classifier model download failed');
|
|
60
|
+
return false;
|
|
61
|
+
})
|
|
62
|
+
.finally(() => {
|
|
63
|
+
inFlight = null;
|
|
64
|
+
});
|
|
65
|
+
return inFlight;
|
|
66
|
+
}
|
|
67
|
+
/** Resets module state (tests only). */
|
|
68
|
+
export function resetGuardFetchForTests() {
|
|
69
|
+
state = { status: 'idle', written: 0, total: GUARD_MODEL.bytes };
|
|
70
|
+
inFlight = null;
|
|
71
|
+
}
|
|
72
|
+
const BAR_SLOTS = 10;
|
|
73
|
+
/** A kid-friendly progress bar: [####______] 42% of 623 MB. */
|
|
74
|
+
export function guardProgressBar(snapshot = guardFetchState()) {
|
|
75
|
+
const share = snapshot.total > 0 ? Math.min(1, snapshot.written / snapshot.total) : 0;
|
|
76
|
+
const filled = Math.floor(share * BAR_SLOTS);
|
|
77
|
+
const bar = '#'.repeat(filled) + '_'.repeat(BAR_SLOTS - filled);
|
|
78
|
+
return `[${bar}] ${Math.floor(share * 100)}% of ${GUARD_MODEL.displaySize}`;
|
|
79
|
+
}
|