domain-skills.ts
438 lines · browse/src/domain-skills.ts at 3f79d81
1/**
2 * Domain skills — per-site notes the agent writes for itself, persisted
3 * alongside /learn's per-project learnings as type:"domain" rows.
4 *
5 * Scope:
6 * - per-project: ~/.gstack/projects/<slug>/learnings.jsonl
7 * - global: ~/.gstack/global-domain-skills.jsonl
8 *
9 * State machine (T6 — defense against persistent prompt poisoning):
10 *
11 * ┌──────────────┐ N=3 successful uses ┌────────┐ promote-to-global ┌────────┐
12 * │ quarantined │ ─────────────────────▶ │ active │ ──────────────────▶ │ global │
13 * │ (per-project)│ (no classifier flags) │(project)│ (manual command) │ │
14 * └──────────────┘ └────────┘ └────────┘
15 * ▲ │
16 * │ classifier flag during use │ rollback (version log)
17 * └───────────────────────────────────────┘
18 *
19 * - new save → quarantined (does NOT auto-fire in prompts)
20 * - active skills fire in prompts for their project (wrapped in UNTRUSTED)
21 * - global skills fire across all projects (cross-context, requires explicit promote)
22 * - rollback restores prior version by sha256
23 *
24 * Storage discipline (T5):
25 * - Append-only with O_APPEND (POSIX guarantees atomic appends < PIPE_BUF)
26 * - Tombstone for deletes; idle compactor rewrites file
27 * - Tolerant parser drops partial trailing line on read
28 *
29 * Hostname rules (T3, CEO-temporal):
30 * - Derived from active tab's top-level origin — NEVER agent-supplied
31 * - Lowercase, strip www., keep full subdomain (subdomain-exact match)
32 * - Punycode hostnames stored as-encoded
33 */
35import { promises as fs } from 'fs';
36import { open as fsOpen, constants as fsConstants } from 'fs';
37import * as path from 'path';
38import { createHash } from 'crypto';
39import type { Page } from 'playwright';
40import { resolveStateRoot } from '../../lib/state-root';
42export type SkillState = 'quarantined' | 'active' | 'global';
43export type SkillScope = 'project' | 'global';
44export type SkillSource = 'agent' | 'human';
46export interface DomainSkillRow {
47 type: 'domain';
48 host: string;
49 scope: SkillScope;
50 state: SkillState;
51 body: string;
52 version: number;
53 classifier_score: number;
54 source: SkillSource;
55 sha256: string;
56 use_count: number;
57 flag_count: number;
58 created_ts: string;
59 updated_ts: string;
60 tombstone?: boolean;
61}
63const PROMOTE_THRESHOLD = 3;
65function gstackHome(): string {
66 return resolveStateRoot();
67}
69function globalFile(): string {
70 return path.join(gstackHome(), 'global-domain-skills.jsonl');
71}
73function projectFile(slug: string): string {
74 return path.join(gstackHome(), 'projects', slug, 'learnings.jsonl');
75}
77// ─── Hostname normalization (T3) ──────────────────────────────
79export function normalizeHost(input: string): string {
80 let h = input.trim().toLowerCase();
81 // strip protocol if present
82 h = h.replace(/^https?:\/\//, '');
83 // strip path/query
84 h = h.split('/')[0]!.split('?')[0]!.split('#')[0]!;
85 // strip port
86 h = h.split(':')[0]!;
87 // strip www. prefix
88 h = h.replace(/^www\./, '');
89 return h;
90}
92/**
93 * Derive hostname from the active tab's top-level origin.
94 * Closes the confused-deputy bug (Codex T3): agent cannot supply a wrong
95 * hostname even if it tried — host is read from the page state we control.
96 */
97export async function deriveHostFromActiveTab(page: Page): Promise<string> {
98 const url = page.url();
99 if (!url || url === 'about:blank' || url.startsWith('chrome://')) {
100 throw new Error(
101 'Cannot save domain-skill: no top-level URL on active tab.\n' +
102 'Cause: tab is empty or on chrome:// page.\n' +
103 'Action: navigate to the target site first with $B goto <url>.'
104 );
105 }
106 return normalizeHost(url);
107}
109// ─── File I/O (T5: append-only + flock-free atomic appends) ────
111async function ensureDir(filePath: string): Promise<void> {
112 await fs.mkdir(path.dirname(filePath), { recursive: true });
113}
115/**
116 * Append a JSONL row atomically. POSIX guarantees atomicity for writes <
117 * PIPE_BUF (typically 4KB) when O_APPEND is set. Each row is single-line JSON
118 * well under that bound. fsync ensures durability before return.
119 */
120async function appendRow(filePath: string, row: DomainSkillRow): Promise<void> {
121 await ensureDir(filePath);
122 const line = JSON.stringify(row) + '\n';
123 return new Promise((resolve, reject) => {
124 fsOpen(filePath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND, 0o644, (err, fd) => {
125 if (err) return reject(err);
126 const buf = Buffer.from(line, 'utf8');
127 const writeAndSync = () => {
128 // Use fs.writeSync via fd to ensure single write call (atomic with O_APPEND).
129 const fsSync = require('fs');
130 try {
131 fsSync.writeSync(fd, buf, 0, buf.length);
132 fsSync.fsyncSync(fd);
133 fsSync.closeSync(fd);
134 resolve();
135 } catch (e) {
136 try {
137 fsSync.closeSync(fd);
138 } catch {
139 // Ignore close errors after a write failure — original error wins.
140 }
141 reject(e);
142 }
143 };
144 writeAndSync();
145 });
146 });
147}
149/**
150 * Read all rows from a JSONL file. Tolerant of partial trailing line (drops it).
151 * Returns rows in append order. Caller resolves latest-wins per (host, scope).
152 */
153async function readRows(filePath: string): Promise<DomainSkillRow[]> {
154 let raw: string;
155 try {
156 raw = await fs.readFile(filePath, 'utf8');
157 } catch (e) {
158 const err = e as NodeJS.ErrnoException;
159 if (err.code === 'ENOENT') return [];
160 throw err;
161 }
162 const rows: DomainSkillRow[] = [];
163 const lines = raw.split('\n');
164 // Last line is empty (trailing newline) OR partial. Drop unconditionally if no parse.
165 for (const line of lines) {
166 if (!line) continue;
167 try {
168 const parsed = JSON.parse(line);
169 if (parsed && parsed.type === 'domain') rows.push(parsed as DomainSkillRow);
170 } catch {
171 // Partial-line corruption tolerated. Compactor will clean up.
172 }
173 }
174 return rows;
175}
177// ─── Latest-wins resolution ────────────────────────────────────
179interface SkillKey {
180 host: string;
181 scope: SkillScope;
182}
184function keyOf(row: DomainSkillRow): string {
185 return `${row.scope}::${row.host}`;
186}
188/**
189 * Reduce a row stream to latest-version-wins per (host, scope).
190 * Tombstones win (deleted skill stays deleted).
191 */
192function resolveLatest(rows: DomainSkillRow[]): Map<string, DomainSkillRow> {
193 const m = new Map<string, DomainSkillRow>();
194 for (const row of rows) {
195 const k = keyOf(row);
196 const prior = m.get(k);
197 if (!prior || row.version >= prior.version) {
198 m.set(k, row);
199 }
200 }
201 // Drop tombstoned entries from the result map for readers; rollback uses raw history.
202 for (const [k, row] of m) {
203 if (row.tombstone) m.delete(k);
204 }
205 return m;
206}
208// ─── Public API ────────────────────────────────────────────────
210export interface ReadSkillResult {
211 row: DomainSkillRow;
212 source: 'project' | 'global';
213}
215/**
216 * Read the active or global skill for a host visible to a given project.
217 * Project-scoped active skills shadow global skills for the same host.
218 * Quarantined skills are NEVER returned (they don't fire).
219 */
220export async function readSkill(host: string, projectSlug: string): Promise<ReadSkillResult | null> {
221 const normalized = normalizeHost(host);
222 // Project layer first
223 const projectRows = await readRows(projectFile(projectSlug));
224 const projectLatest = resolveLatest(projectRows);
225 const projectHit = projectLatest.get(`project::${normalized}`);
226 if (projectHit && projectHit.state === 'active') {
227 return { row: projectHit, source: 'project' };
228 }
229 // Global layer fallback
230 const globalRows = await readRows(globalFile());
231 const globalLatest = resolveLatest(globalRows);
232 const globalHit = globalLatest.get(`global::${normalized}`);
233 if (globalHit && globalHit.state === 'global') {
234 return { row: globalHit, source: 'global' };
235 }
236 return null;
237}
239export interface WriteSkillInput {
240 host: string;
241 body: string; // markdown frontmatter + content
242 projectSlug: string;
243 source: SkillSource;
244 classifierScore: number; // 0..1; caller invokes classifier before calling this
245}
247/**
248 * Save a new skill (always quarantined initially per T6).
249 * Caller MUST run the classifier first and pass classifierScore.
250 * Score >= 0.85 should fail-fast at caller, never reach here.
251 */
252export async function writeSkill(input: WriteSkillInput): Promise<DomainSkillRow> {
253 if (input.classifierScore >= 0.85) {
254 throw new Error(
255 `Save blocked: classifier flagged content as potential injection (score: ${input.classifierScore.toFixed(2)}).\n` +
256 'Cause: skill body contains patterns the L4 classifier marks as risky.\n' +
257 'Action: rewrite the skill content removing instruction-like prose, retry.'
258 );
259 }
260 const normalized = normalizeHost(input.host);
261 const body = input.body;
262 const now = new Date().toISOString();
263 const sha = createHash('sha256').update(body, 'utf8').digest('hex');
264 // Determine prior version for this (host, scope=project) so version counter increments.
265 const projectRows = await readRows(projectFile(input.projectSlug));
266 const projectLatest = resolveLatest(projectRows);
267 const prior = projectLatest.get(`project::${normalized}`);
268 const version = prior ? prior.version + 1 : 1;
269 const row: DomainSkillRow = {
270 type: 'domain',
271 host: normalized,
272 scope: 'project',
273 state: 'quarantined',
274 body,
275 version,
276 classifier_score: input.classifierScore,
277 source: input.source,
278 sha256: sha,
279 use_count: 0,
280 flag_count: 0,
281 created_ts: prior?.created_ts ?? now,
282 updated_ts: now,
283 };
284 await appendRow(projectFile(input.projectSlug), row);
285 return row;
286}
288/**
289 * Promote a quarantined skill to active in its project after N=3 uses without
290 * classifier flagging. No production caller today — the chat-path agent that
291 * invoked it on successful skill use was ripped with the chat queue.
292 *
293 * Auto-promote logic:
294 * - increment use_count
295 * - if use_count >= PROMOTE_THRESHOLD AND flag_count == 0 AND L4 has scored
296 * the body (classifier_score > 0) → state:active
297 * - else stay quarantined with updated counter; user must run
298 * `domain-skill promote-to-global` manually
299 *
300 * The classifier_score > 0 gate is load-bearing: handleSave writes
301 * classifier_score=0 (meaning "never ML-scanned"), and NOTHING updates the
302 * score today — the load-time L4 scan died with the chat path, so skills
303 * authored via the production path never had their body scanned by L4.
304 * Without this gate, three benign uses promote any quarantined skill — including
305 * one written under the influence of a poisoned page — into the prompt context
306 * for every subsequent visit. The gate re-opens automatically the day L4 is
307 * rewired and writeSkill / recordSkillUse start receiving non-zero scores.
308 */
309export async function recordSkillUse(host: string, projectSlug: string, classifierFlagged: boolean): Promise<DomainSkillRow | null> {
310 const normalized = normalizeHost(host);
311 const rows = await readRows(projectFile(projectSlug));
312 const latest = resolveLatest(rows);
313 const current = latest.get(`project::${normalized}`);
314 if (!current) return null;
315 const useCount = current.use_count + 1;
316 const flagCount = current.flag_count + (classifierFlagged ? 1 : 0);
317 let state: SkillState = current.state;
318 if (
319 state === 'quarantined' &&
320 useCount >= PROMOTE_THRESHOLD &&
321 flagCount === 0 &&
322 current.classifier_score > 0
323 ) {
324 state = 'active';
325 }
326 const updated: DomainSkillRow = {
327 ...current,
328 state,
329 use_count: useCount,
330 flag_count: flagCount,
331 version: current.version + 1,
332 updated_ts: new Date().toISOString(),
333 };
334 await appendRow(projectFile(projectSlug), updated);
335 return updated;
336}
338/**
339 * Promote an active per-project skill to global. Explicit operator call only —
340 * never auto-promoted across project boundaries (T4).
341 */
342export async function promoteToGlobal(host: string, projectSlug: string): Promise<DomainSkillRow> {
343 const normalized = normalizeHost(host);
344 const rows = await readRows(projectFile(projectSlug));
345 const latest = resolveLatest(rows);
346 const current = latest.get(`project::${normalized}`);
347 if (!current) {
348 throw new Error(
349 `Cannot promote: no skill for ${normalized} in project ${projectSlug}.\n` +
350 'Cause: skill does not exist or is tombstoned.\n' +
351 'Action: $B domain-skill list to see what exists in this project.'
352 );
353 }
354 if (current.state !== 'active') {
355 throw new Error(
356 `Cannot promote: skill for ${normalized} is in state "${current.state}", expected "active".\n` +
357 `Cause: skill must be active in this project (used ${PROMOTE_THRESHOLD}+ times without flag) before global promotion.\n` +
358 'Action: use the skill in this project until it auto-promotes to active.'
359 );
360 }
361 const now = new Date().toISOString();
362 const globalRow: DomainSkillRow = {
363 ...current,
364 scope: 'global',
365 state: 'global',
366 version: 1, // global file has its own version line
367 use_count: 0,
368 flag_count: 0,
369 updated_ts: now,
370 };
371 await appendRow(globalFile(), globalRow);
372 return globalRow;
373}
375/**
376 * Rollback to a prior version (by sha256 OR previous version number).
377 * Re-emits the prior row as the latest, preserving the version counter monotonicity.
378 */
379export async function rollbackSkill(host: string, projectSlug: string, scope: SkillScope = 'project'): Promise<DomainSkillRow> {
380 const normalized = normalizeHost(host);
381 const file = scope === 'project' ? projectFile(projectSlug) : globalFile();
382 const rows = await readRows(file);
383 const matching = rows.filter((r) => r.host === normalized && r.scope === scope && !r.tombstone);
384 if (matching.length < 2) {
385 throw new Error(
386 `Cannot rollback: ${normalized} has fewer than 2 versions in ${scope} scope.\n` +
387 'Cause: no prior version to roll back to.\n' +
388 'Action: $B domain-skill rm to delete instead, or wait for a future revision to roll back from.'
389 );
390 }
391 // Sort by version desc; take second-latest as the rollback target.
392 matching.sort((a, b) => b.version - a.version);
393 const target = matching[1]!;
394 const newVersion = matching[0]!.version + 1;
395 const restored: DomainSkillRow = {
396 ...target,
397 version: newVersion,
398 updated_ts: new Date().toISOString(),
399 };
400 await appendRow(file, restored);
401 return restored;
402}
404/**
405 * List all non-tombstoned skills visible to a project (active project + active global).
406 */
407export async function listSkills(projectSlug: string): Promise<{ project: DomainSkillRow[]; global: DomainSkillRow[] }> {
408 const projectRows = await readRows(projectFile(projectSlug));
409 const globalRows = await readRows(globalFile());
410 const projectLatest = Array.from(resolveLatest(projectRows).values());
411 const globalLatest = Array.from(resolveLatest(globalRows).values()).filter((r) => r.state === 'global');
412 return { project: projectLatest, global: globalLatest };
413}
415/**
416 * Tombstone a skill. Append a tombstone row; compactor cleans up later.
417 */
418export async function deleteSkill(host: string, projectSlug: string, scope: SkillScope = 'project'): Promise<void> {
419 const normalized = normalizeHost(host);
420 const file = scope === 'project' ? projectFile(projectSlug) : globalFile();
421 const rows = await readRows(file);
422 const latest = resolveLatest(rows);
423 const current = latest.get(`${scope}::${normalized}`);
424 if (!current) {
425 throw new Error(
426 `Cannot delete: no skill for ${normalized} in ${scope} scope.\n` +
427 'Cause: skill does not exist or is already tombstoned.\n' +
428 'Action: $B domain-skill list to see what exists.'
429 );
430 }
431 const tombstone: DomainSkillRow = {
432 ...current,
433 version: current.version + 1,
434 updated_ts: new Date().toISOString(),
435 tombstone: true,
436 };
437 await appendRow(file, tombstone);
438}