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,150 @@
1
+ /**
2
+ * Parent PIN: scrypt hashed in the keychain, with a lockout after repeated
3
+ * failures and a reset path that wipes provider credentials.
4
+ *
5
+ * Storage:
6
+ * - keychain "pin-hash": "<saltHex>:<hashHex>" (16-byte salt, scrypt
7
+ * N=16384 r=8 p=1, 64-byte key)
8
+ * - keychain "setup-marker": set when the wizard completes; survives
9
+ * deletion of the TERMI_HOME directory so recovery stays PIN gated
10
+ * - TERMI_HOME/pin.lock: JSON { failures, lockedUntil } tracking
11
+ * consecutive failures and the active lockout, shared across processes
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { API_KEY_ACCOUNTS, deleteSecret, getSecret, setSecret, } from '../auth/keychain.js';
17
+ import { atomicWriteFileSync, authJsonPath, termiHome } from './paths.js';
18
+ const PIN_ACCOUNT = 'pin-hash';
19
+ const SETUP_MARKER_ACCOUNT = 'setup-marker';
20
+ const SCRYPT_N = 16384;
21
+ const SCRYPT_R = 8;
22
+ const SCRYPT_P = 1;
23
+ const SALT_BYTES = 16;
24
+ const KEY_BYTES = 64;
25
+ const MAX_FAILURES = 5;
26
+ const LOCKOUT_MS = 5 * 60 * 1000;
27
+ function lockFilePath() {
28
+ return path.join(termiHome(), 'pin.lock');
29
+ }
30
+ function readLockState() {
31
+ try {
32
+ const parsed = JSON.parse(fs.readFileSync(lockFilePath(), 'utf8'));
33
+ if (parsed !== null && typeof parsed === 'object') {
34
+ const candidate = parsed;
35
+ const failures = typeof candidate.failures === 'number' ? candidate.failures : 0;
36
+ const lockedUntil = typeof candidate.lockedUntil === 'number' ? candidate.lockedUntil : null;
37
+ return { failures, lockedUntil };
38
+ }
39
+ }
40
+ catch {
41
+ // Missing or unreadable lock file means a clean slate.
42
+ }
43
+ return { failures: 0, lockedUntil: null };
44
+ }
45
+ function writeLockState(state) {
46
+ atomicWriteFileSync(lockFilePath(), JSON.stringify(state), 0o600);
47
+ }
48
+ function clearLockState() {
49
+ try {
50
+ fs.rmSync(lockFilePath(), { force: true });
51
+ }
52
+ catch {
53
+ // Best effort; a stale zero-failure file is harmless.
54
+ }
55
+ }
56
+ function hashPin(pin, salt) {
57
+ return crypto.scryptSync(pin, salt, KEY_BYTES, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
58
+ }
59
+ /** Stores the PIN hash and clears any previous lockout state. */
60
+ export function setPin(pin) {
61
+ const salt = crypto.randomBytes(SALT_BYTES);
62
+ const hash = hashPin(pin, salt);
63
+ setSecret(PIN_ACCOUNT, `${salt.toString('hex')}:${hash.toString('hex')}`);
64
+ clearLockState();
65
+ }
66
+ export function hasPin() {
67
+ return getSecret(PIN_ACCOUNT) !== null;
68
+ }
69
+ function pinMatches(pin, stored) {
70
+ const parts = stored.split(':');
71
+ if (parts.length !== 2) {
72
+ return false;
73
+ }
74
+ const [saltHex, hashHex] = parts;
75
+ if (!saltHex || !hashHex) {
76
+ return false;
77
+ }
78
+ const salt = Buffer.from(saltHex, 'hex');
79
+ const expected = Buffer.from(hashHex, 'hex');
80
+ if (salt.length !== SALT_BYTES || expected.length !== KEY_BYTES) {
81
+ return false;
82
+ }
83
+ const actual = hashPin(pin, salt);
84
+ return crypto.timingSafeEqual(actual, expected);
85
+ }
86
+ /**
87
+ * Verifies the PIN with constant-time comparison.
88
+ * Five consecutive failures lock verification for five minutes.
89
+ */
90
+ export function verifyPin(pin) {
91
+ const now = Date.now();
92
+ let lock = readLockState();
93
+ if (lock.lockedUntil !== null) {
94
+ if (now < lock.lockedUntil) {
95
+ return {
96
+ ok: false,
97
+ lockedForSeconds: Math.ceil((lock.lockedUntil - now) / 1000),
98
+ };
99
+ }
100
+ // Lockout expired: start a fresh counting window.
101
+ lock = { failures: 0, lockedUntil: null };
102
+ writeLockState(lock);
103
+ }
104
+ const stored = getSecret(PIN_ACCOUNT);
105
+ if (stored === null) {
106
+ return { ok: false };
107
+ }
108
+ if (pinMatches(pin, stored)) {
109
+ clearLockState();
110
+ return { ok: true };
111
+ }
112
+ const failures = lock.failures + 1;
113
+ if (failures >= MAX_FAILURES) {
114
+ const lockedUntil = now + LOCKOUT_MS;
115
+ writeLockState({ failures, lockedUntil });
116
+ return { ok: false, lockedForSeconds: Math.ceil(LOCKOUT_MS / 1000) };
117
+ }
118
+ writeLockState({ failures, lockedUntil: null });
119
+ return { ok: false };
120
+ }
121
+ /**
122
+ * Forgot-PIN path: wipes the PIN hash, every provider API key, and the OAuth
123
+ * token file, so resetting gains the kid nothing. Returns the wiped item
124
+ * names. The caller writes the audit entry and reverts settings to strict.
125
+ */
126
+ export function resetPin() {
127
+ const wiped = [];
128
+ if (deleteSecret(PIN_ACCOUNT)) {
129
+ wiped.push(PIN_ACCOUNT);
130
+ }
131
+ for (const account of API_KEY_ACCOUNTS) {
132
+ if (deleteSecret(account)) {
133
+ wiped.push(account);
134
+ }
135
+ }
136
+ const authFile = authJsonPath();
137
+ if (fs.existsSync(authFile)) {
138
+ fs.rmSync(authFile, { force: true });
139
+ wiped.push(path.basename(authFile));
140
+ }
141
+ clearLockState();
142
+ return wiped;
143
+ }
144
+ /** Records that the setup wizard finished. Lives in the keychain on purpose. */
145
+ export function markSetupComplete() {
146
+ setSecret(SETUP_MARKER_ACCOUNT, new Date().toISOString());
147
+ }
148
+ export function isSetupComplete() {
149
+ return getSecret(SETUP_MARKER_ACCOUNT) !== null;
150
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Signed settings persistence.
3
+ *
4
+ * settings.json holds a SettingsEnvelope: the Settings body plus an
5
+ * HMAC-SHA256 over its canonical JSON. The HMAC key lives in the keychain
6
+ * (account "hmac-key", random 32 bytes, created on first use). Any mismatch,
7
+ * parse failure, or missing-file-while-setup-marker-exists condition fails
8
+ * closed to the strictest defaults with tampered: true so the caller can
9
+ * force PIN-gated recovery.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import { getSecret, setSecret } from '../auth/keychain.js';
14
+ import { atomicWriteFileSync, settingsPath } from './paths.js';
15
+ const HMAC_KEY_ACCOUNT = 'hmac-key';
16
+ const SETUP_MARKER_ACCOUNT = 'setup-marker';
17
+ /** The strictest possible configuration. Used as the fail-closed baseline. */
18
+ export function defaultSettings() {
19
+ return {
20
+ version: 1,
21
+ installId: '',
22
+ kidNickname: '',
23
+ ageBand: 'under13',
24
+ consentAttestedAt: null,
25
+ activeProvider: null,
26
+ configuredProviders: [],
27
+ modelAlias: 'zippy',
28
+ safetyLevel: 'strict',
29
+ xaiParentAck: false,
30
+ localClassifier: true,
31
+ lastProjectSlug: null,
32
+ };
33
+ }
34
+ /**
35
+ * Fills fields added after a settings file was written and drops retired
36
+ * ones. Runs after the MAC check (the MAC covers the envelope as written).
37
+ * Older envelopes predate localClassifier: absent means on, the default.
38
+ */
39
+ export function normalizeSettings(stored) {
40
+ const raw = stored;
41
+ const { ollamaClassifier: _retired, ...kept } = raw;
42
+ return { ...kept, localClassifier: raw.localClassifier ?? true };
43
+ }
44
+ /** Stable stringify: object keys sorted recursively, array order preserved. */
45
+ function sortValue(value) {
46
+ if (Array.isArray(value)) {
47
+ return value.map(sortValue);
48
+ }
49
+ if (value !== null && typeof value === 'object') {
50
+ const source = value;
51
+ const sorted = {};
52
+ for (const key of Object.keys(source).sort()) {
53
+ sorted[key] = sortValue(source[key]);
54
+ }
55
+ return sorted;
56
+ }
57
+ return value;
58
+ }
59
+ export function canonicalJson(value) {
60
+ return JSON.stringify(sortValue(value));
61
+ }
62
+ function hmacKey() {
63
+ const existing = getSecret(HMAC_KEY_ACCOUNT);
64
+ if (existing) {
65
+ const key = Buffer.from(existing, 'hex');
66
+ if (key.length === 32) {
67
+ return key;
68
+ }
69
+ }
70
+ const fresh = crypto.randomBytes(32);
71
+ setSecret(HMAC_KEY_ACCOUNT, fresh.toString('hex'));
72
+ return fresh;
73
+ }
74
+ function signSettings(settings) {
75
+ return crypto.createHmac('sha256', hmacKey()).update(canonicalJson(settings)).digest('hex');
76
+ }
77
+ function isEnvelopeShape(value) {
78
+ if (value === null || typeof value !== 'object') {
79
+ return false;
80
+ }
81
+ const candidate = value;
82
+ return (typeof candidate.mac === 'string' &&
83
+ candidate.settings !== null &&
84
+ typeof candidate.settings === 'object' &&
85
+ candidate.settings.version === 1);
86
+ }
87
+ function macMatches(expected, actual) {
88
+ const a = Buffer.from(expected, 'utf8');
89
+ const b = Buffer.from(actual, 'utf8');
90
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
91
+ }
92
+ export function loadSettings() {
93
+ const file = settingsPath();
94
+ if (!fs.existsSync(file)) {
95
+ const setupMarkerExists = getSecret(SETUP_MARKER_ACCOUNT) !== null;
96
+ if (setupMarkerExists) {
97
+ // Setup finished before, yet the file is gone. Someone removed state.
98
+ return { settings: defaultSettings(), tampered: true, firstRun: false };
99
+ }
100
+ return { settings: defaultSettings(), tampered: false, firstRun: true };
101
+ }
102
+ let envelope;
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
105
+ if (!isEnvelopeShape(parsed)) {
106
+ return { settings: defaultSettings(), tampered: true, firstRun: false };
107
+ }
108
+ envelope = parsed;
109
+ }
110
+ catch {
111
+ return { settings: defaultSettings(), tampered: true, firstRun: false };
112
+ }
113
+ const expectedMac = signSettings(envelope.settings);
114
+ if (!macMatches(expectedMac, envelope.mac)) {
115
+ return { settings: defaultSettings(), tampered: true, firstRun: false };
116
+ }
117
+ return { settings: normalizeSettings(envelope.settings), tampered: false, firstRun: false };
118
+ }
119
+ /**
120
+ * Signs and writes settings atomically. Generates installId on first save.
121
+ * Returns the settings exactly as persisted.
122
+ */
123
+ export function saveSettings(settings) {
124
+ const toSave = { ...settings };
125
+ if (toSave.installId === '') {
126
+ toSave.installId = crypto.randomUUID();
127
+ }
128
+ const envelope = { settings: toSave, mac: signSettings(toSave) };
129
+ atomicWriteFileSync(settingsPath(), JSON.stringify(envelope, null, 2), 0o600);
130
+ return toSave;
131
+ }