← 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
A REVIEW-2026-05-04.mdA ecosystem.config.cjsM package-lock.jsonM package.jsonA public/favicon.svgM public/index.htmlM server.jsM src/llm.jsM src/stages/memory_write.jsM src/stages/scaffold.jsM viewer.js
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 →