@tpsdev-ai/flair 0.8.3 → 0.10.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.
@@ -1,15 +1,97 @@
1
1
  import { Resource } from "@harperfast/harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
- import { join } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
4
5
  import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ /**
8
+ * Resolve the public URL operators reach this Flair on.
9
+ *
10
+ * Production deployments (Fabric, VPS-hosted, behind any reverse proxy)
11
+ * should set `FLAIR_PUBLIC_URL` in their launchd / systemd unit. The
12
+ * admin pane then shows the URL operators actually type — not the
13
+ * 127.0.0.1 binding address Harper sees internally — so the Endpoints
14
+ * table is copy-pasteable.
15
+ *
16
+ * Resolution order:
17
+ * 1. `FLAIR_PUBLIC_URL` env var (explicit override, always wins)
18
+ * 2. Request headers (X-Forwarded-Proto + X-Forwarded-Host, or Host)
19
+ * — derives from how the operator actually reached the page
20
+ * 3. `http://127.0.0.1:${HTTP_PORT}` — local-only installs fallback
21
+ *
22
+ * The request-header path closes the common case from flair#404:
23
+ * remote/Fabric deployments without FLAIR_PUBLIC_URL set rendered
24
+ * localhost URLs that operators couldn't copy-paste. Trusting the Host
25
+ * header is correct when Harper terminates TLS directly; behind a
26
+ * reverse proxy that doesn't set X-Forwarded-* headers correctly, the
27
+ * operator should set FLAIR_PUBLIC_URL explicitly (which wins).
28
+ */
29
+ function resolvePublicUrl(request) {
30
+ if (process.env.FLAIR_PUBLIC_URL) {
31
+ return process.env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
32
+ }
33
+ // Best-effort header derivation. Harper's request.headers exposes
34
+ // both .get(name) and .asObject (case-insensitive). Prefer X-Forwarded-*
35
+ // when present (caller is behind a proxy that set them); fall back
36
+ // to the direct Host header.
37
+ const getHeader = (name) => {
38
+ const h = request?.headers;
39
+ if (!h)
40
+ return undefined;
41
+ if (typeof h.get === "function")
42
+ return h.get(name) ?? h.get(name.toLowerCase()) ?? undefined;
43
+ if (typeof h === "object") {
44
+ const obj = h.asObject ?? h;
45
+ return obj[name] ?? obj[name.toLowerCase()] ?? undefined;
46
+ }
47
+ return undefined;
48
+ };
49
+ const fwdProto = getHeader("X-Forwarded-Proto");
50
+ const fwdHost = getHeader("X-Forwarded-Host");
51
+ const host = fwdHost ?? getHeader("Host");
52
+ if (host && /^[\w.\-:]+$/.test(host)) {
53
+ const scheme = fwdProto && (fwdProto === "http" || fwdProto === "https") ? fwdProto : "https";
54
+ // If no proxy headers and host has no port, assume http for safety —
55
+ // most production deployments are behind a proxy with X-Forwarded-Proto.
56
+ const effectiveScheme = fwdProto ? scheme : (host.includes(":") ? "http" : scheme);
57
+ return `${effectiveScheme}://${host}`;
58
+ }
59
+ return `http://127.0.0.1:${process.env.HTTP_PORT ?? "19926"}`;
60
+ }
61
+ /**
62
+ * Read the runtime package version from the bundled package.json so the
63
+ * Instance pane shows the real version (e.g. 0.8.3) rather than "dev" —
64
+ * `process.env.npm_package_version` is only populated inside `npm run`.
65
+ */
66
+ function resolveVersion() {
67
+ try {
68
+ const here = dirname(fileURLToPath(import.meta.url));
69
+ const candidates = [
70
+ join(here, "..", "..", "package.json"),
71
+ join(here, "..", "package.json"),
72
+ ];
73
+ for (const p of candidates) {
74
+ if (existsSync(p)) {
75
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
76
+ if (pkg.version)
77
+ return pkg.version;
78
+ }
79
+ }
80
+ }
81
+ catch { /* fall through */ }
82
+ return process.env.npm_package_version ?? "dev";
83
+ }
5
84
  /**
6
85
  * GET /AdminInstance — instance info, public key, version.
7
86
  */
8
87
  export class AdminInstance extends Resource {
9
88
  async get() {
10
- const version = process.env.npm_package_version ?? "dev";
11
- const httpPort = process.env.HTTP_PORT ?? "19926";
12
- const publicUrl = process.env.FLAIR_PUBLIC_URL ?? `http://127.0.0.1:${httpPort}`;
89
+ const version = resolveVersion();
90
+ // Pass the request through so URL resolution can derive from
91
+ // X-Forwarded-* / Host headers when FLAIR_PUBLIC_URL isn't set.
92
+ const ctx = this.getContext?.() ?? {};
93
+ const request = ctx.request ?? ctx;
94
+ const publicUrl = resolvePublicUrl(request);
13
95
  // Try to read instance public key
14
96
  let publicKey = "—";
15
97
  const keyDir = join(homedir(), ".flair", "keys");
@@ -41,10 +41,16 @@ export class AdminMemory extends Resource {
41
41
  if (subject) {
42
42
  conditions.push({ attribute: "subject", comparator: "equals", value: subject.toLowerCase() });
43
43
  }
44
- conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
44
+ // Note: Harper's `not_equal true` predicate doesn't match rows where
45
+ // `archived` is `false` *or* unset — boolean comparators behave
46
+ // unevenly across boolean / undefined / null storage states. We skip
47
+ // archived rows in the JS-side filter loop below instead, so the list
48
+ // view actually returns non-archived memories rather than zero.
45
49
  const searchQuery = conditions.length > 0 ? { conditions } : {};
46
50
  let count = 0;
47
51
  for await (const m of databases.flair.Memory.search(searchQuery)) {
52
+ if (m.archived === true)
53
+ continue;
48
54
  if (m.expiresAt && Date.parse(m.expiresAt) < Date.now())
49
55
  continue;
50
56
  if (query && !String(m.content || "").toLowerCase().includes(query.toLowerCase()))
@@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
4
  import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
5
5
  import { initFederationCleanup } from "./federation-cleanup.js";
6
+ import { classifyRecord } from "./federation-classify.js";
7
+ export { classifyRecord } from "./federation-classify.js";
6
8
  // Module-level nonce store for federation anti-replay.
7
9
  // Shared across FederationPair + FederationSync — nonces are globally unique
8
10
  // (generated by signBodyFresh per request with 128-bit random nonces).
@@ -268,6 +270,12 @@ export class FederationSync extends Resource {
268
270
  const startTime = Date.now();
269
271
  let merged = 0;
270
272
  let skipped = 0;
273
+ const skippedReasons = {};
274
+ const mergeErrors = [];
275
+ function recordSkip(reason) {
276
+ skipped++;
277
+ skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
278
+ }
271
279
  // Table name → Harper database table mapping
272
280
  const tableMap = {
273
281
  Memory: databases.flair.Memory,
@@ -275,66 +283,88 @@ export class FederationSync extends Resource {
275
283
  Agent: databases.flair.Agent,
276
284
  Relationship: databases.flair.Relationship,
277
285
  };
286
+ const knownTables = new Set(Object.keys(tableMap));
278
287
  for (const record of records) {
279
- const table = tableMap[record.table];
280
- if (!table) {
281
- skipped++;
282
- continue;
283
- }
284
- // Originator enforcement: peers can only push records they originated.
285
- // The hub can relay records from other peers (role === "hub"),
286
- // but spokes can only push their own records.
287
- const originator = record.originatorInstanceId ?? instanceId;
288
- if (originator !== instanceId && peer.role !== "hub") {
289
- skipped++;
290
- continue;
291
- }
292
288
  try {
293
- const local = await table.get(record.id);
294
- // Timestamp ceiling: reject records with updatedAt more than 5 minutes in the future.
295
- // Prevents attackers from using far-future timestamps to permanently win LWW.
296
- const fiveMinFromNow = new Date(Date.now() + 5 * 60 * 1000).toISOString();
297
- if (record.updatedAt > fiveMinFromNow) {
298
- skipped++;
289
+ const table = tableMap[record.table];
290
+ const local = table ? await table.get(record.id) : null;
291
+ const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
292
+ if (decision.action === "skip") {
293
+ recordSkip(decision.reason);
299
294
  continue;
300
295
  }
301
296
  const mergedData = mergeRecord(local, record);
302
- // Preserve originator for provenance
303
- mergedData._originatorInstanceId = originator;
297
+ mergedData._originatorInstanceId = decision.originator;
304
298
  mergedData._syncedFrom = instanceId;
305
299
  mergedData._syncedAt = new Date().toISOString();
306
300
  await table.put(mergedData);
307
301
  merged++;
308
302
  }
309
- catch {
310
- skipped++;
303
+ catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ recordSkip("merge_error");
306
+ // Cap the captured-error list so a hostile/buggy peer can't blow up
307
+ // the SyncLog row, but ALWAYS log to console so operators don't lose
308
+ // the per-record signal — the silent-swallow is the bug we're fixing.
309
+ if (mergeErrors.length < 10) {
310
+ mergeErrors.push(`${record.table}/${record.id}: ${msg}`);
311
+ }
312
+ console.warn(`[Federation] merge error for ${record.table}/${record.id} from ${instanceId}: ${msg}`);
311
313
  }
312
314
  }
313
- // Update peer sync cursor
314
- await databases.flair.Peer.put({
315
+ // Peer cursor update — distinguish liveness from progress:
316
+ // lastSyncAt → "we heard from this peer just now" (always update)
317
+ // lastMergeAt → "data actually flowed in" (only when merged > 0)
318
+ // Conflating them produced the "green dashboard while burning" failure
319
+ // mode — dogfood pass 2026-05-26 surfaced unconditional update masking
320
+ // 100%-skip syncs.
321
+ const nowIso = new Date().toISOString();
322
+ const peerUpdate = {
315
323
  ...peer,
316
- lastSyncAt: new Date().toISOString(),
317
- lastSyncCursor: lamportClock?.toString() ?? new Date().toISOString(),
324
+ lastSyncAt: nowIso,
325
+ lastSyncCursor: lamportClock?.toString() ?? nowIso,
318
326
  status: "connected",
319
- updatedAt: new Date().toISOString(),
320
- });
321
- // Log sync operation
327
+ updatedAt: nowIso,
328
+ };
329
+ if (merged > 0) {
330
+ peerUpdate.lastMergeAt = nowIso;
331
+ }
332
+ await databases.flair.Peer.put(peerUpdate);
333
+ // Log sync operation with skip-reason breakdown so the partial state
334
+ // is debuggable instead of a black box.
322
335
  try {
336
+ const errorParts = [];
337
+ if (Object.keys(skippedReasons).length > 0) {
338
+ errorParts.push("skipped: " +
339
+ Object.entries(skippedReasons)
340
+ .map(([reason, n]) => `${n} ${reason}`)
341
+ .join(", "));
342
+ }
343
+ if (mergeErrors.length > 0) {
344
+ errorParts.push("merge_errors: " + mergeErrors.join("; "));
345
+ }
346
+ const errorSummary = errorParts.length > 0 ? errorParts.join(" | ") : undefined;
347
+ const status = mergeErrors.length > 0
348
+ ? "partial"
349
+ : skipped > 0 ? "partial" : "success";
323
350
  await databases.flair.SyncLog.put({
324
351
  id: `sync_${Date.now()}_${randomBytes(4).toString("hex")}`,
325
352
  peerId: instanceId,
326
353
  direction: "pull",
327
354
  recordCount: merged,
328
- status: skipped > 0 ? "partial" : "success",
329
- error: skipped > 0 ? `${skipped} records skipped` : undefined,
355
+ skippedCount: skipped,
356
+ skippedReasons: JSON.stringify(skippedReasons),
357
+ status,
358
+ error: errorSummary,
330
359
  durationMs: Date.now() - startTime,
331
- createdAt: new Date().toISOString(),
360
+ createdAt: nowIso,
332
361
  });
333
362
  }
334
363
  catch { /* non-fatal */ }
335
364
  return {
336
365
  merged,
337
366
  skipped,
367
+ skippedReasons,
338
368
  total: records.length,
339
369
  durationMs: Date.now() - startTime,
340
370
  };
@@ -354,6 +384,7 @@ export class FederationPeers extends Resource {
354
384
  status: p.status,
355
385
  endpoint: p.endpoint,
356
386
  lastSyncAt: p.lastSyncAt,
387
+ lastMergeAt: p.lastMergeAt,
357
388
  relayOnly: p.relayOnly,
358
389
  pairedAt: p.pairedAt,
359
390
  });
@@ -1,34 +1,56 @@
1
1
  /**
2
2
  * MemoryMaintenance.ts — Maintenance worker for memory hygiene.
3
3
  *
4
- * POST /MemoryMaintenance/ — runs cleanup tasks:
4
+ * POST /MemoryMaintenance — runs cleanup tasks:
5
5
  * 1. Delete expired ephemeral memories (expiresAt < now)
6
6
  * 2. Archive old session memories (> 30 days, standard durability)
7
7
  * 3. Report stats
8
8
  *
9
- * Designed to run periodically (daily cron or heartbeat).
10
- * Requires admin auth.
9
+ * Designed to run periodically (daily cron, scheduler, or REM nightly cycle).
10
+ * Authenticated via Ed25519 (agent acts on own memories) or admin (system-wide).
11
+ *
12
+ * History: prior to slice-2 PR-3, this class used a static-ROUTE pattern
13
+ * (`export default class MemoryMaintenance` with `static ROUTE`/`METHOD`)
14
+ * that Harper 5.x does not auto-register. Both `flair rem light` and the
15
+ * REM nightly runner were returning "Not found" against the endpoint.
16
+ * Migrated to the standard `extends Resource` shape with `allowCreate()`
17
+ * to gate auth correctly.
11
18
  */
12
- export default class MemoryMaintenance {
13
- static ROUTE = "MemoryMaintenance";
14
- static METHOD = "POST";
19
+ import { Resource, databases } from "@harperfast/harper";
20
+ import { isAdmin } from "./auth-middleware.js";
21
+ export class MemoryMaintenance extends Resource {
22
+ /** POST requires auth — either an agent acting on its own memories, or admin. */
23
+ allowCreate() {
24
+ const ctx = this.getContext?.();
25
+ const request = ctx?.request ?? ctx;
26
+ return !!(request?.tpsAgent || request?.tpsAgentIsAdmin);
27
+ }
15
28
  async post(data) {
16
- const { databases } = this;
17
- const request = this.request;
18
- const { dryRun = false, agentId } = data || {};
19
- // Scope to authenticated agent. Admin can pass agentId for system-wide
20
- // maintenance; non-admin always scoped to their own agent.
21
- const authAgent = request?.headers?.get?.("x-tps-agent");
22
- const isAdmin = request?.tpsAgentIsAdmin === true;
23
- const targetAgent = isAdmin && agentId ? agentId : authAgent;
24
- if (!targetAgent && !isAdmin) {
25
- return { error: "agentId required" };
29
+ const { dryRun = false, agentId: bodyAgentId } = data || {};
30
+ const ctx = this.getContext?.();
31
+ const request = ctx?.request ?? ctx;
32
+ const actorId = request?.tpsAgent;
33
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true
34
+ || (actorId ? await isAdmin(actorId) : false);
35
+ // Scope rules:
36
+ // - Admin can pass agentId to maintain a specific agent (or omit it
37
+ // for fleet-wide maintenance).
38
+ // - Non-admin agents are scoped to their own memories — bodyAgentId
39
+ // either matches the authenticated agent or is ignored.
40
+ const targetAgent = callerIsAdmin
41
+ ? bodyAgentId
42
+ : actorId;
43
+ if (!targetAgent && !callerIsAdmin) {
44
+ return new Response(JSON.stringify({ error: "agentId required" }), {
45
+ status: 400,
46
+ headers: { "content-type": "application/json" },
47
+ });
26
48
  }
27
49
  const now = new Date();
28
50
  const stats = { expired: 0, archived: 0, total: 0, errors: 0, agent: targetAgent || "all" };
29
51
  try {
30
52
  for await (const record of databases.flair.Memory.search()) {
31
- // Skip records not belonging to target agent (unless admin running system-wide)
53
+ // Skip records not belonging to target agent (unless admin running fleet-wide).
32
54
  if (targetAgent && record.agentId !== targetAgent)
33
55
  continue;
34
56
  stats.total++;
@@ -48,9 +70,9 @@ export default class MemoryMaintenance {
48
70
  }
49
71
  continue;
50
72
  }
51
- // 2. Archive old standard session memories (> 30 days)
52
- // These are low-value session notes that weren't promoted to persistent.
53
- // Archiving removes them from search results but keeps the data.
73
+ // 2. Archive old standard session memories (> 30 days). These are
74
+ // low-value session notes that weren't promoted to persistent.
75
+ // Soft-archive removes them from search results but keeps the data.
54
76
  if (record.durability === "standard" &&
55
77
  record.type === "session" &&
56
78
  !record.archived &&
@@ -60,7 +82,6 @@ export default class MemoryMaintenance {
60
82
  if (ageDays > 30) {
61
83
  if (!dryRun) {
62
84
  try {
63
- // Soft archive — set archived flag, keep data
64
85
  await databases.flair.Memory.update(record.id, {
65
86
  ...record,
66
87
  archived: true,
@@ -80,11 +101,18 @@ export default class MemoryMaintenance {
80
101
  }
81
102
  }
82
103
  catch (err) {
83
- return { error: err.message, stats };
104
+ return new Response(JSON.stringify({ error: err.message, stats }), { status: 500, headers: { "content-type": "application/json" } });
84
105
  }
106
+ // Flatten the historical { stats } wrapper into the top level so callers
107
+ // can read `.expired` / `.archived` directly. The wrapper shape is kept
108
+ // for backward compatibility with `flair rem light`.
85
109
  return {
86
110
  message: dryRun ? "Dry run complete" : "Maintenance complete",
87
111
  stats,
112
+ expired: stats.expired,
113
+ archived: stats.archived,
114
+ total: stats.total,
115
+ errors: stats.errors,
88
116
  };
89
117
  }
90
118
  }
@@ -36,6 +36,12 @@ function futureISO(ms) {
36
36
  }
37
37
  // ─── Discovery metadata ──────────────────────────────────────────────────────
38
38
  export class OAuthMetadata extends Resource {
39
+ // OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
40
+ // it be accessible without authentication so clients can bootstrap their
41
+ // flow. `allowRead() { return true }` opens Harper's role gate so remote
42
+ // operators (e.g. Fabric-hosted Flair) get the metadata document instead
43
+ // of a 401 from Harper's intrinsic auth layer. Same pattern as Health (#386).
44
+ allowRead() { return true; }
39
45
  async get() {
40
46
  const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
41
47
  return {
@@ -65,6 +71,10 @@ export class OAuthMetadata extends Resource {
65
71
  }
66
72
  // ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
67
73
  export class OAuthRegister extends Resource {
74
+ // Dynamic Client Registration (RFC 7591) — clients must be able to register
75
+ // anonymously to bootstrap. Validation (redirect_uri allow-list, etc.)
76
+ // happens in post(). Pattern matches FederationPair.allowCreate (#299).
77
+ allowCreate() { return true; }
68
78
  async post(data) {
69
79
  const redirectUris = data?.redirect_uris ?? [];
70
80
  const clientName = data?.client_name ?? "Unknown Client";
@@ -103,6 +113,11 @@ export class OAuthRegister extends Resource {
103
113
  }
104
114
  // ─── Authorization endpoint ──────────────────────────────────────────────────
105
115
  export class OAuthAuthorize extends Resource {
116
+ // OAuth authorize endpoint must accept anonymous GET/POST — the whole
117
+ // point of authorize is to start an unauthenticated user's flow. PKCE
118
+ // and state-parameter validation in the handler enforce real auth.
119
+ allowRead() { return true; }
120
+ allowCreate() { return true; }
106
121
  async get() {
107
122
  // In 1.0, this returns a simple HTML consent page.
108
123
  // The user (Nathan) approves or denies, which POSTs back.
@@ -202,6 +217,10 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
202
217
  }
203
218
  // ─── Token endpoint ──────────────────────────────────────────────────────────
204
219
  export class OAuthToken extends Resource {
220
+ // OAuth token exchange — client must hit this without prior Harper auth
221
+ // since they're trading code/refresh for a token. Authentication is in
222
+ // the post() handler via PKCE verifier + client_secret_basic.
223
+ allowCreate() { return true; }
205
224
  async post(data) {
206
225
  const grantType = data?.grant_type;
207
226
  if (grantType === "authorization_code") {
@@ -359,6 +378,9 @@ export class OAuthToken extends Resource {
359
378
  }
360
379
  // ─── Revocation endpoint ─────────────────────────────────────────────────────
361
380
  export class OAuthRevoke extends Resource {
381
+ // RFC 7009 — token revocation accepts the token itself as proof; clients
382
+ // may not have a separate auth credential. Handler validates the token.
383
+ allowCreate() { return true; }
362
384
  async post(data) {
363
385
  const token = data?.token;
364
386
  if (!token) {
@@ -10,6 +10,10 @@ const HTML = readFileSync(HTML_PATH, "utf8");
10
10
  * The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
11
11
  */
12
12
  export class ObservationCenter extends Resource {
13
+ // The dashboard shell is just HTML + inline JS; the JS prompts the user
14
+ // for admin-pass and authenticates each API call. The shell itself is
15
+ // intentionally public so it can render before auth happens.
16
+ allowRead() { return true; }
13
17
  async get() {
14
18
  return new Response(HTML, {
15
19
  status: 200,
@@ -1,101 +1,44 @@
1
1
  import { Resource } from "@harperfast/harper";
2
- const SHELL_PATTERNS = [
3
- { regex: /\bexec\s*\(/, type: "shell_command" },
4
- { regex: /\bspawn\s*\(/, type: "shell_command" },
5
- { regex: /\bsystem\s*\(/, type: "shell_command" },
6
- { regex: /`[^`]*`/, type: "shell_backtick" },
7
- { regex: /\bchild_process\b/, type: "shell_command" },
8
- ];
9
- const NETWORK_PATTERNS = [
10
- { regex: /\bfetch\s*\(/, type: "network_call" },
11
- { regex: /\bcurl\b/, type: "network_call" },
12
- { regex: /https?:\/\//, type: "url_reference" },
13
- { regex: /\bXMLHttpRequest\b/, type: "network_call" },
14
- { regex: /\baxios\b/, type: "network_call" },
15
- ];
16
- const FS_PATTERNS = [
17
- { regex: /\bfs\.write/, type: "fs_write" },
18
- { regex: /\bwriteFile/, type: "fs_write" },
19
- { regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
20
- ];
21
- const ENV_PATTERNS = [
22
- { regex: /\bprocess\.env\b/, type: "env_access" },
23
- { regex: /\$ENV\b/, type: "env_access" },
24
- { regex: /\$\{?\w+\}?/, type: "env_variable" },
25
- ];
26
- const ENCODING_PATTERNS = [
27
- { regex: /\batob\s*\(/, type: "base64_decode" },
28
- { regex: /\bbtoa\s*\(/, type: "base64_encode" },
29
- { regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
30
- { regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
31
- { regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
32
- { regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
33
- ];
34
- // Unicode zero-width and homoglyph detection (raw chars)
35
- const UNICODE_PATTERNS = [
36
- { regex: /[\u200B-\u200F\u2060\uFEFF]/, type: "zero_width_char" },
37
- { regex: /[\u0410-\u044F]/, type: "cyrillic_homoglyph" }, // Cyrillic chars that look like Latin
38
- ];
39
- const ALL_PATTERNS = [
40
- ...SHELL_PATTERNS,
41
- ...NETWORK_PATTERNS,
42
- ...FS_PATTERNS,
43
- ...ENV_PATTERNS,
44
- ...ENCODING_PATTERNS,
45
- ...UNICODE_PATTERNS,
46
- ];
47
- function assessRisk(violations) {
48
- if (violations.length === 0)
49
- return "low";
50
- const types = new Set(violations.map((v) => v.type));
51
- const hasShell = types.has("shell_command") || types.has("shell_backtick");
52
- const hasNetwork = types.has("network_call");
53
- const hasFs = types.has("fs_write") || types.has("fs_redirect");
54
- const hasZeroWidth = types.has("zero_width_char");
55
- const hasHomoglyph = types.has("cyrillic_homoglyph");
56
- // Critical: shell + encoding, or zero-width/homoglyph obfuscation
57
- if ((hasShell && (types.has("base64_decode") || types.has("hex_decode"))) ||
58
- hasZeroWidth || hasHomoglyph) {
59
- return "critical";
60
- }
61
- // High: direct shell commands or fs writes
62
- if (hasShell || hasFs)
63
- return "high";
64
- // Medium: network calls or encoding without shell
65
- if (hasNetwork || types.has("base64_decode") || types.has("hex_decode"))
66
- return "medium";
67
- return "low";
68
- }
2
+ import { scanSkillContent } from "./scan/skill-scanner.js";
3
+ /**
4
+ * POST /SkillScan/
5
+ *
6
+ * Static analysis of skill content for security violations.
7
+ * Scans for shell commands, network calls, fs writes, env access,
8
+ * encoded payloads, zero-width chars, and homoglyphs.
9
+ *
10
+ * Request: { content: string }
11
+ * Response: { safe, violations, riskLevel }
12
+ *
13
+ * Auth: any authenticated agent (read-only analysis).
14
+ * Size limit: 8KB (8192 bytes).
15
+ *
16
+ * Scanner logic lives in `./scan/skill-scanner.ts` (pure module, no Harper
17
+ * runtime deps) so unit tests can exercise it without instantiating the
18
+ * Harper database.
19
+ *
20
+ * Markdown awareness:
21
+ * - Inline `single-token` backticks (markdown identifier convention) are NOT
22
+ * flagged. Only inline backticks whose content actually looks shell-ish
23
+ * (whitespace, pipe, semicolon, env-var, $(), &&, ||) fire shell_backtick.
24
+ * - Fenced ```code blocks``` with no language hint or a shell-family
25
+ * language (sh|bash|shell|zsh) are scanned for shell patterns. Non-shell
26
+ * language hints (json, yaml, ts, py, graphql, etc.) skip the shell
27
+ * patterns but keep network/fs/encoding/unicode detection active — those
28
+ * are language-agnostic attack surfaces.
29
+ * - This prevents the documentation-as-skill false-positive class without
30
+ * weakening detection on actual shell content.
31
+ */
69
32
  export class SkillScan extends Resource {
70
- async post(data, context) {
33
+ async post(data, _context) {
71
34
  const { content } = data || {};
72
35
  if (!content || typeof content !== "string") {
73
36
  return new Response(JSON.stringify({ error: "content (string) required" }), { status: 400, headers: { "Content-Type": "application/json" } });
74
37
  }
75
- // 8KB size limit
76
38
  const byteLength = new TextEncoder().encode(content).length;
77
39
  if (byteLength > 8192) {
78
40
  return new Response(JSON.stringify({ error: `Content exceeds 8KB limit (${byteLength} bytes)` }), { status: 413, headers: { "Content-Type": "application/json" } });
79
41
  }
80
- const lines = content.split("\n");
81
- const violations = [];
82
- for (let i = 0; i < lines.length; i++) {
83
- const line = lines[i];
84
- for (const pattern of ALL_PATTERNS) {
85
- if (pattern.regex.test(line)) {
86
- violations.push({
87
- type: pattern.type,
88
- line: i + 1,
89
- content: line.trim().slice(0, 200),
90
- });
91
- }
92
- }
93
- }
94
- const riskLevel = assessRisk(violations);
95
- return {
96
- safe: violations.length === 0,
97
- violations,
98
- riskLevel,
99
- };
42
+ return scanSkillContent(content);
100
43
  }
101
44
  }
@@ -2,7 +2,31 @@
2
2
  * Shared HTML layout for the Flair web admin.
3
3
  * Server-rendered, no JS framework. Minimal CSS for Nathan-grade UX.
4
4
  */
5
- const VERSION = process.env.npm_package_version ?? "dev";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ // Resolve the runtime package version from package.json — process.env.npm_package_version
9
+ // is only populated inside `npm run`, so it returned "dev" in production and rendered
10
+ // "vdev" in the admin sidebar footer for anyone running the published binary.
11
+ function resolveVersion() {
12
+ try {
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const candidates = [
15
+ join(here, "..", "..", "package.json"),
16
+ join(here, "..", "package.json"),
17
+ ];
18
+ for (const p of candidates) {
19
+ if (existsSync(p)) {
20
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
21
+ if (pkg.version)
22
+ return pkg.version;
23
+ }
24
+ }
25
+ }
26
+ catch { /* fall through */ }
27
+ return process.env.npm_package_version ?? "dev";
28
+ }
29
+ const VERSION = resolveVersion();
6
30
  /** Escape HTML special characters to prevent stored XSS. */
7
31
  export function esc(str) {
8
32
  if (!str)