[object Object]

← back to The Ai Factory

snapshot: 11 file(s) changed, +3 new, ~8 modified

cee93fb3f5f36488b8753c78972788843a110e18 · 2026-05-13 08:58:03 -0700 · Steve

Files touched

Diff

commit cee93fb3f5f36488b8753c78972788843a110e18
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:58:03 2026 -0700

    snapshot: 11 file(s) changed, +3 new, ~8 modified
---
 REVIEW-2026-05-04.md       | 62 ++++++++++++++++++++++++++++++++++++++++++++++
 ecosystem.config.cjs       | 19 ++++++++++++++
 package-lock.json          | 10 ++++++++
 package.json               |  1 +
 public/favicon.svg         |  4 +++
 public/index.html          |  2 ++
 server.js                  | 62 ++++++++++++++++++++++++++++++++++++++++------
 src/llm.js                 |  5 +++-
 src/stages/memory_write.js | 21 +++++++++++-----
 src/stages/scaffold.js     | 10 ++++++++
 viewer.js                  | 14 +++++++++--
 11 files changed, 193 insertions(+), 17 deletions(-)

diff --git a/REVIEW-2026-05-04.md b/REVIEW-2026-05-04.md
new file mode 100644
index 0000000..5d28db0
--- /dev/null
+++ b/REVIEW-2026-05-04.md
@@ -0,0 +1,62 @@
+# the-ai-factory — overnight debate-team review (2026-05-04)
+
+Code-reviewer + architect-reviewer parallel run. **6 patches applied** (3 P0 + 2 P1 + 1 P2 standing-rule fix). Architecture roadmap (factory-core extraction theme matches site-factory recommendation).
+
+## P0 patches APPLIED
+
+| # | File:line | Issue | Fix |
+|---|---|---|---|
+| 1 | `server.js:67,95` | `ADMIN_TOKEN` declared in .env but **no middleware enforced it**. POST /runs and POST /runs/:id/activate were unauth — any local process could trigger pipeline runs or activate artifacts into ~/.claude/ | Added `requireToken` middleware; both routes require `Authorization: Bearer $ADMIN_TOKEN`; 503 if token unset |
+| 2 | `server.js:124` | Activate handler used `art.artifact_name` from DB in `path.join(CLAUDE_AGENTS_DIR, name)` with no re-validation. Tampered DB row could write `~/.ssh/authorized_keys` | Added regex re-check + `path.resolve()` containment check (allowedRoot per artifact_type) |
+| 3 | `src/stages/scaffold.js:63` | Same risk in scaffold — `spec.artifact_name` from LLM used in path.join with no sandbox containment after the LLM emits it | Re-asserted regex + `path.resolve(SANDBOX_ROOT)` containment check before write |
+
+## P1 patches APPLIED
+
+| # | File:line | Issue | Fix |
+|---|---|---|---|
+| 4 | `server.js:31` | Wildcard CORS with comment "safe since loopback" — but CORS only restricts BROWSERS. Any malicious page user opens can fetch 127.0.0.1:9890 with credentials and trigger /runs | Allowlist `VIEWER_ORIGIN` (default `http://127.0.0.1:9891`) only; sets Vary + Allow-Credentials only on match |
+| 5 | `src/stages/memory_write.js:23` | LLM-generated `spec.scope` + `spec.triggers` written verbatim into `MEMORY.md`. Poisoned response could inject newlines + markdown headers (e.g. `\n# OVERWRITE`) corrupting Steve's memory index | `sanitize()` strips `\r\n` + collapses whitespace + caps length per field |
+
+## P2 patch APPLIED — standing-rule alignment
+
+| # | File:line | Was | Now |
+|---|---|---|---|
+| 6 | `src/llm.js:9` | `OLLAMA_HOST` defaulted to `http://127.0.0.1:11434` (Mac2) | `http://192.168.1.133:11434` (MS1) per `feedback_ollama_default_ms1.md` — Mac2 froze 2026-05-02 under v3+v5+codex GPU contention |
+
+`node --check` passes on all 4 modified files.
+
+## P1 / P2 deferred (in REVIEW)
+
+- `server.js:104` — `?force=true` undocumented bypass for critic gate. Replace with explicit `POST /runs/:id/force-activate` requiring `{reason}` body, logged at WARN.
+- `server.js:187` — `/runs/:id/events` unauth, leaks full pipeline payloads (specs, error stacks).
+- `src/pipeline.js:52,100` — no global pipeline timeout. If MS1 Ollama hangs, run sits in `running` forever holding pg connection.
+- `output/` not in `.gitignore`.
+- `package.json:15` — `@anthropic-ai/sdk` declared but never imported (contradicts standing rule "no Anthropic API"). Drop dep.
+- `scripts/run.js` — no max prompt-length guard before sending to Ollama.
+- No `helmet` on either server.
+
+## Architecture roadmap (deferred)
+
+**Architectural Impact: MEDIUM.** Pipeline disciplined (sandbox-then-promote, critic-iterate-recritic, manual activate gate). Structural problems mirror site-factory:
+
+### R1 — Extract `factory-core/` shared package
+**Same recommendation as site-factory review.** `src/llm.js` (ollama + claudeCli with semaphore) + `src/pipeline.js` skeleton (setStage / logEvent / finishRun) + sandbox-promote logic + Stage interface contract. site-factory + ai-factory + visual-factory all consume it. Land before visual-factory grows divergent copies. ~400 LOC dedup across 3 projects.
+
+### R2 — Delete `viewer.js` (15-LOC stub)
+Same anti-pattern as site-factory's sf-viewer. Mount `app.use('/ui', express.static(viewerDir))` in orchestrator. Saves a pm2 slot, port, log pair, and the CORS layer (`server.js:31-37` becomes "not needed" since UI is same-origin).
+
+### R3 — Move `memory_write` back into pipeline
+Currently runs inside the activate HTTP handler (`server.js:154-179`), recovers `spec` by string-matching `events.message='spec extracted'` (line 157). Fragile coupling — change the event message in `pipeline.js:48` and memory_write silently breaks. The 11-stage diagram in `pipeline.js:13-16` lies (stages 9 + 10 are deferred to a separate code path). Make it a real stage. Inject paths via config object.
+
+### R4 — Rename `smoke_test` → `lint_frontmatter`, add a real smoke test
+Current "smoke test" is a static frontmatter linter — never executes the artifact, never verifies Claude can load the skill. Real test: write to tmpdir under `~/.claude/skills/`, run `claude --list-skills | grep <name>`, delete. For subagent: `js-yaml.load` the frontmatter. The current "passing 6/6 checks" creates false confidence.
+
+### R5 — Replace `?force=true` with auditable explicit endpoint
+New `POST /runs/:id/force-activate` requiring `{reason: string}` in body, logged into `events` with `level='warn'`. The critic gate becomes meaningful.
+
+## Files touched
+
+- `/Users/stevestudio2/Projects/the-ai-factory/server.js`
+- `/Users/stevestudio2/Projects/the-ai-factory/src/stages/scaffold.js`
+- `/Users/stevestudio2/Projects/the-ai-factory/src/stages/memory_write.js`
+- `/Users/stevestudio2/Projects/the-ai-factory/src/llm.js`
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..9c8cf71
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,19 @@
+module.exports = {
+  apps: [{
+    name: "ai-factory-orchestrator",
+    script: "server.js",
+    cwd: __dirname,
+    env: {
+      NEXT_TELEMETRY_DISABLED: "1",
+      VITE_HOST: "0.0.0.0",
+      HOSTNAME: "0.0.0.0",
+      GUNICORN_BIND: "0.0.0.0:8000",
+      UVICORN_HOST: "0.0.0.0",
+      FLASK_RUN_HOST: "0.0.0.0",
+      BIND: "0.0.0.0",
+      HOST: "0.0.0.0",
+    },
+    autorestart: true,
+    max_memory_restart: "1G",
+  }],
+};
diff --git a/package-lock.json b/package-lock.json
index 07fb464..b24023a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
         "@anthropic-ai/sdk": "^0.32.0",
         "dotenv": "^16.4.5",
         "express": "^4.19.2",
+        "helmet": "^8.1.0",
         "pg": "^8.12.0",
         "playwright": "^1.59.1"
       }
@@ -598,6 +599,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
diff --git a/package.json b/package.json
index 4c96e53..ac3e94d 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
     "@anthropic-ai/sdk": "^0.32.0",
     "dotenv": "^16.4.5",
     "express": "^4.19.2",
+    "helmet": "^8.1.0",
     "pg": "^8.12.0",
     "playwright": "^1.59.1"
   }
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..3b2ae49
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">🤖</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 8c7d3df..bf893a1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,6 +1,8 @@
 <!doctype html>
 <html lang="en">
 <head>
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width,initial-scale=1" />
 <title>The AI Factory</title>
diff --git a/server.js b/server.js
index b315f6b..328e16a 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,7 @@
 
 require('dotenv').config();
 const express = require('express');
+const helmet = require('helmet');
 const { Pool } = require('pg');
 
 const PG_DATABASE = process.env.PG_DATABASE || 'ai_factory';
@@ -24,18 +25,49 @@ const CLAUDE_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
 const CLAUDE_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
 
 const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
 app.use(express.json({ limit: '1mb' }));
 
-// Loopback-only CORS so the viewer (:9891) can fetch /runs from this orchestrator (:9890).
-// Server binds to 127.0.0.1 — '*' is safe since no remote origin can hit us.
+// LAN/tailnet auth gate (skip /health for watchdog probes)
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
 app.use((req, res, next) => {
-  res.set('Access-Control-Allow-Origin', '*');
+  if (req.path === '/health' || req.path === '/healthz') return next();
+  if (req.get('authorization') === AUTH_HEADER) return next();
+  res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+  res.status(401).send('auth required');
+});
+
+// SECURITY (P1 fix 2026-05-04): wildcard CORS was "safe since loopback" but
+// CORS only restricts BROWSERS — any malicious page the user opens can fetch
+// 127.0.0.1:9890 with credentials and trigger /runs or /runs/:id/activate.
+// Allowlist the viewer origin only.
+const VIEWER_ORIGIN = process.env.VIEWER_ORIGIN || 'http://127.0.0.1:9891';
+app.use((req, res, next) => {
+  const origin = req.headers.origin;
+  if (origin === VIEWER_ORIGIN) {
+    res.set('Access-Control-Allow-Origin', origin);
+    res.set('Vary', 'Origin');
+    res.set('Access-Control-Allow-Credentials', 'true');
+  }
   res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
-  res.set('Access-Control-Allow-Headers', 'Content-Type');
+  res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
   if (req.method === 'OPTIONS') return res.sendStatus(204);
   next();
 });
 
+// SECURITY (P0 fix 2026-05-04): ADMIN_TOKEN was declared in .env but never
+// enforced — any local process could trigger /runs or activate artifacts into
+// ~/.claude/. Mutating routes now require Authorization: Bearer $ADMIN_TOKEN.
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
+function requireToken(req, res, next) {
+  if (!ADMIN_TOKEN) return res.status(503).json({ error: 'ADMIN_TOKEN not configured on server' });
+  const auth = String(req.headers['authorization'] || '');
+  if (auth !== `Bearer ${ADMIN_TOKEN}`) return res.status(401).json({ error: 'unauthorized' });
+  next();
+}
+
 const PORT = Number(process.env.ORCHESTRATOR_PORT || 9890);
 
 // Build pool config; only include `password` if one is actually set, so that
@@ -64,7 +96,7 @@ app.get('/runs', async (_req, res) => {
   }
 });
 
-app.post('/runs', async (req, res) => {
+app.post('/runs', requireToken, async (req, res) => {
   const { prompt, artifact_type = null, artifact_name = null } = req.body || {};
   if (!prompt || typeof prompt !== 'string') {
     return res.status(400).json({ error: 'prompt (string) required' });
@@ -92,7 +124,7 @@ app.post('/runs', async (req, res) => {
  *   - Reads the artifact location from the artifacts table — only files in our sandbox
  *     under ~/Projects/the-ai-factory/output/ are eligible.
  */
-app.post('/runs/:id/activate', async (req, res) => {
+app.post('/runs/:id/activate', requireToken, async (req, res) => {
   const runId = Number(req.params.id);
   const force = req.query.force === 'true';
   const overwrite = req.query.overwrite === 'true';
@@ -121,14 +153,28 @@ app.post('/runs/:id/activate', async (req, res) => {
       return res.status(400).json({ error: `refusing to activate from outside sandbox: ${src}` });
     }
 
-    let dest;
+    // SECURITY (P0 fix 2026-05-04): re-validate artifact_name from DB before
+    // building the dest path. Intake regex ran earlier but a tampered DB row
+    // (or a future bug in intake) could let `../../.ssh/authorized_keys`
+    // resolve outside CLAUDE_AGENTS_DIR / CLAUDE_SKILLS_DIR.
+    if (!/^[a-z0-9-]{2,60}$/.test(String(art.artifact_name || ''))) {
+      return res.status(400).json({ error: 'artifact_name failed validation' });
+    }
+    let dest, allowedRoot;
     if (art.artifact_type === 'subagent') {
+      allowedRoot = path.resolve(CLAUDE_AGENTS_DIR);
       dest = path.join(CLAUDE_AGENTS_DIR, `${art.artifact_name}.md`);
     } else if (art.artifact_type === 'skill') {
+      allowedRoot = path.resolve(CLAUDE_SKILLS_DIR);
       dest = path.join(CLAUDE_SKILLS_DIR, art.artifact_name, 'SKILL.md');
     } else {
       return res.status(400).json({ error: `unsupported artifact_type: ${art.artifact_type}` });
     }
+    // Defense-in-depth: confirm resolved dest stays under allowedRoot.
+    const resolvedDest = path.resolve(dest);
+    if (!resolvedDest.startsWith(allowedRoot + path.sep)) {
+      return res.status(400).json({ error: 'dest path traversal blocked' });
+    }
 
     let existed = false;
     try { await fs.access(dest); existed = true; } catch { /* ok */ }
@@ -196,6 +242,6 @@ app.get('/runs/:id/events', async (req, res) => {
   }
 });
 
-app.listen(PORT, '127.0.0.1', () => {
+app.listen(PORT, '0.0.0.0', () => {
   console.log(`[ai-factory] orchestrator listening on http://127.0.0.1:${PORT} (db=${PG_DATABASE})`);
 });
diff --git a/src/llm.js b/src/llm.js
index b980496..4efd0a2 100644
--- a/src/llm.js
+++ b/src/llm.js
@@ -6,7 +6,10 @@
 
 const { spawn } = require('node:child_process');
 
-const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+// P2 fix 2026-05-04: default to Mac Studio 1 per Steve's standing rule
+// `feedback_ollama_default_ms1.md` — bulk LLM workloads point at MS1, not Mac2.
+// Mac2 froze 2026-05-02 with v3+v5+codex on the same GPU.
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://192.168.1.133:11434';
 const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
 
 /**
diff --git a/src/stages/memory_write.js b/src/stages/memory_write.js
index e842a4c..6705673 100644
--- a/src/stages/memory_write.js
+++ b/src/stages/memory_write.js
@@ -20,13 +20,22 @@ function todayStamp() {
   return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
 }
 
+// SECURITY (P1 fix 2026-05-04): scope + triggers are LLM-generated. Without
+// sanitization a poisoned response could inject newlines/markdown headers
+// (e.g. `\n# OVERWRITE`) into MEMORY.md. Strip newlines, cap length.
+function sanitize(s, max = 200) {
+  return String(s ?? '').replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+}
 function buildLine({ runId, artifactType, artifactName, spec, dest }) {
-  const scope = (spec?.scope || '').replace(/\s+/g, ' ').trim();
-  const triggers = Array.isArray(spec?.triggers) && spec.triggers.length
-    ? ` triggers: ${spec.triggers.slice(0, 3).join(', ')};`
-    : '';
-  const surface = artifactType === 'subagent' ? '~/.claude/agents/' : '~/.claude/skills/';
-  return `- AI Factory shipped: \`${artifactName}\` (${artifactType}) — ${scope}${triggers} [run #${runId}, ${todayStamp()}, lives in ${surface}]`;
+  const safeName = sanitize(artifactName, 60);
+  const safeType = sanitize(artifactType, 20);
+  const scope = sanitize(spec?.scope, 200);
+  const triggerArr = Array.isArray(spec?.triggers)
+    ? spec.triggers.slice(0, 3).map(t => sanitize(t, 60)).filter(Boolean)
+    : [];
+  const triggers = triggerArr.length ? ` triggers: ${triggerArr.join(', ')};` : '';
+  const surface = safeType === 'subagent' ? '~/.claude/agents/' : '~/.claude/skills/';
+  return `- AI Factory shipped: \`${safeName}\` (${safeType}) — ${scope}${triggers} [run #${runId}, ${todayStamp()}, lives in ${surface}]`;
 }
 
 async function appendMemoryIndex(line) {
diff --git a/src/stages/scaffold.js b/src/stages/scaffold.js
index 36e1be3..5015a92 100644
--- a/src/stages/scaffold.js
+++ b/src/stages/scaffold.js
@@ -60,11 +60,21 @@ Write the file content now.`;
 
   const cleaned = stripFences(content).trim();
 
+  // SECURITY (P0 fix 2026-05-04): re-assert artifact_name validity + sandbox
+  // containment. Intake regex is the contract but a poisoned/prompt-injected
+  // qwen response could still emit a name that bypasses an upstream check.
+  if (!/^[a-z0-9-]{2,60}$/.test(String(spec?.artifact_name || ''))) {
+    throw new Error(`scaffold: invalid artifact_name: ${spec?.artifact_name}`);
+  }
   const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
   await fs.mkdir(runDir, { recursive: true });
   const outPath = isSubagent
     ? path.join(runDir, `${spec.artifact_name}.md`)
     : path.join(runDir, spec.artifact_name, 'SKILL.md');
+  const resolvedOut = path.resolve(outPath);
+  if (!resolvedOut.startsWith(path.resolve(SANDBOX_ROOT) + path.sep)) {
+    throw new Error(`scaffold path escaped sandbox: ${resolvedOut}`);
+  }
   await fs.mkdir(path.dirname(outPath), { recursive: true });
   await fs.writeFile(outPath, cleaned, 'utf8');
 
diff --git a/viewer.js b/viewer.js
index 17603ec..8167001 100644
--- a/viewer.js
+++ b/viewer.js
@@ -8,8 +8,18 @@ const path = require('path');
 const app = express();
 const PORT = Number(process.env.VIEWER_PORT || 9891);
 
+// LAN/tailnet auth gate
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+app.use((req, res, next) => {
+  if (req.path === '/healthz') return next();
+  if (req.get('authorization') === AUTH_HEADER) return next();
+  res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+  res.status(401).send('auth required');
+});
+
 app.use(express.static(path.join(__dirname, 'public')));
 
-app.listen(PORT, '127.0.0.1', () => {
-  console.log(`[ai-factory] viewer listening on http://127.0.0.1:${PORT}`);
+app.listen(PORT, '0.0.0.0', () => {
+  console.log(`[ai-factory] viewer listening on http://0.0.0.0:${PORT} (basic-auth gated)`);
 });

← 20486fe tighten .gitignore: add missing standing-rule patterns (tmp/  ·  back to The Ai Factory  ·  gitignore: broaden snapshot/backup patterns 0d5209c →