From d23ce5eeacd128388191779f8a8efd1ea2f452ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:43:52 +0000 Subject: [PATCH 01/10] fix(onboarding): safe settings write, hook-path parity, state/source separation - P0-01: mergeSettings refuses to overwrite a present-but-unparseable settings file (was silently treated as empty); backs up the existing file and writes atomically (temp + rename); only writes when something actually changed. - P0-02: resolve template hook/statusline commands from the installed package (~/.forge/ -> /global/) so npm-global installs don't reference an unmaterialized ~/.forge. - P0-03: recall default store moves to the XDG state dir (never the source tree); install.sh separates read-only assets (~/.forge) from mutable state (FORGE_HOME) and migrates any personal recall out of global/recall/facts. - S-02: installer runs argv directly instead of eval "$*". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- global/guards/recall-load.sh | 5 +- install.sh | 48 +++++++++++++------ src/cli.js | 10 +++- src/docs_check.js | 90 +++++++++++++++++++++++++++--------- src/init.js | 79 +++++++++++++++++++++++++++---- src/recall.js | 9 +++- test/init.test.js | 34 +++++++++++++- 7 files changed, 227 insertions(+), 48 deletions(-) diff --git a/global/guards/recall-load.sh b/global/guards/recall-load.sh index 872ef75..d50349f 100755 --- a/global/guards/recall-load.sh +++ b/global/guards/recall-load.sh @@ -3,8 +3,9 @@ # stdout from a SessionStart hook is added to the session context. set -euo pipefail -# Prefer the Forge recall store; fall back to the legacy memory index. -MEM="${FORGE_HOME:-$HOME/.forge}/recall/MEMORY.md" +# Prefer the Forge recall store; fall back to the legacy memory index. The store lives in +# the XDG state dir (not the source tree) — must match recall.js defaultStore() (P0-03). +MEM="${FORGE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/forgekit}/recall/MEMORY.md" [ -f "$MEM" ] || MEM="$HOME/.claude/memory/MEMORY.md" [ -f "$MEM" ] || exit 0 diff --git a/install.sh b/install.sh index 26bde50..74b59e3 100644 --- a/install.sh +++ b/install.sh @@ -8,7 +8,11 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -FORGE_HOME="${FORGE_HOME:-$HOME/.forge}" +# Read-only assets (guards/statusline/tools) — a symlink into the bundle so hooks resolve. +FORGE_ASSETS="${FORGE_ASSETS:-$HOME/.forge}" +# Mutable personal state (recall) — a REAL dir in the XDG state home, NEVER inside the +# source tree. Keeping these separate is the whole point of P0-03. +FORGE_HOME="${FORGE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/forgekit}" CLAUDE_DIR="$HOME/.claude" BIN_DIR="$HOME/.local/bin" STAMP="$(date +%Y%m%d-%H%M%S)" @@ -24,17 +28,18 @@ for arg in "$@"; do done say() { printf ' %s\n' "$*"; } -act() { if [ "$DRY" = 1 ]; then say "[dry-run] $*"; else eval "$*"; fi; } +# Run argv directly (no eval — S-02). In dry-run, print the argv instead of executing. +act() { if [ "$DRY" = 1 ]; then say "[dry-run] $*"; else "$@"; fi; } # link SRC DEST — back up an existing real file/dir, then symlink. link() { local src="$1" dest="$2" [ -e "$src" ] || { say "skip (missing in bundle): $src"; return; } if [ -e "$dest" ] && [ ! -L "$dest" ]; then - act "mv \"$dest\" \"$dest.forge-bak-$STAMP\""; say "backed up existing $dest" + act mv "$dest" "$dest.forge-bak-$STAMP"; say "backed up existing $dest" fi - act "mkdir -p \"$(dirname "$dest")\"" - act "ln -sfn \"$src\" \"$dest\"" + act mkdir -p "$(dirname "$dest")" + act ln -sfn "$src" "$dest" say "linked $dest -> $src" } @@ -42,13 +47,29 @@ link() { unlink_ours() { local dest="$1" if [ -L "$dest" ] && case "$(readlink "$dest")" in "$REPO"/*) true;; *) false;; esac; then - act "rm -f \"$dest\""; say "removed $dest" + act rm -f "$dest"; say "removed $dest" + fi +} + +# One-shot migration: older installs symlinked ~/.forge -> /global, so personal +# recall facts were written into the source tree (global/recall/facts). Move them out. +migrate_recall() { + local old="$REPO/global/recall/facts" + [ -d "$old" ] || return 0 + act mkdir -p "$FORGE_HOME/recall" + if [ ! -e "$FORGE_HOME/recall/facts" ]; then + act mv "$old" "$FORGE_HOME/recall/facts" + say "migrated personal recall -> $FORGE_HOME/recall/facts (out of the source tree)" + else + say "note: personal recall found in the source tree at $old; $FORGE_HOME/recall/facts already exists — merge by hand" fi } install_forge() { say "Installing Forge from $REPO" - link "$REPO/global" "$FORGE_HOME" + link "$REPO/global" "$FORGE_ASSETS" + act mkdir -p "$FORGE_HOME" # mutable state home (recall) — real dir, outside the source tree + migrate_recall for d in "$REPO"/global/tools/*/; do [ -d "$d" ] && link "$d" "$CLAUDE_DIR/skills/$(basename "$d")"; done for f in "$REPO"/global/crew/*.md; do [ -e "$f" ] && link "$f" "$CLAUDE_DIR/agents/$(basename "$f")"; done link "$REPO/src/cli.js" "$BIN_DIR/forge" @@ -61,16 +82,16 @@ install_forge() { Done. Guards + statusline need ONE manual merge into $CLAUDE_DIR/settings.json (kept manual so your existing settings are never clobbered): - "statusLine": { "type": "command", "command": "bash $FORGE_HOME/statusline.sh" }, + "statusLine": { "type": "command", "command": "bash $FORGE_ASSETS/statusline.sh" }, "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", - "command": "bash $FORGE_HOME/guards/cortex.sh preflight" } ] } ], + "command": "bash $FORGE_ASSETS/guards/cortex.sh preflight" } ] } ], "PreToolUse": [ { "matcher": "Edit|Write|MultiEdit|Bash", - "hooks": [ { "type": "command", "command": "bash $FORGE_HOME/guards/protect-paths.sh" } ] } ], + "hooks": [ { "type": "command", "command": "bash $FORGE_ASSETS/guards/protect-paths.sh" } ] } ], "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", - "hooks": [ { "type": "command", "command": "bash $FORGE_HOME/guards/format-on-edit.sh" } ] } ], + "hooks": [ { "type": "command", "command": "bash $FORGE_ASSETS/guards/format-on-edit.sh" } ] } ], "Stop": [ { "hooks": [ { "type": "command", - "command": "bash $FORGE_HOME/guards/completion-gate.sh" } ] } ] + "command": "bash $FORGE_ASSETS/guards/completion-gate.sh" } ] } ] } Or install the plugin instead (guards auto-wire): /plugin marketplace add then /plugin install forgekit. @@ -83,7 +104,8 @@ uninstall_forge() { for d in "$REPO"/global/tools/*/; do [ -d "$d" ] && unlink_ours "$CLAUDE_DIR/skills/$(basename "$d")"; done for f in "$REPO"/global/crew/*.md; do [ -e "$f" ] && unlink_ours "$CLAUDE_DIR/agents/$(basename "$f")"; done unlink_ours "$BIN_DIR/forge" - unlink_ours "$FORGE_HOME" + unlink_ours "$FORGE_ASSETS" + say "Personal state in $FORGE_HOME is left untouched (remove it by hand if you want)." say "Done. Any backed-up files remain as *.forge-bak-* next to their originals." } diff --git a/src/cli.js b/src/cli.js index 55a61ea..1cfa4a4 100755 --- a/src/cli.js +++ b/src/cli.js @@ -60,12 +60,18 @@ async function run(argv) { console.log( ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, ); - if (settings?.action === "merged" && "added" in settings) { - console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`); + if ((settings?.action === "merged" || settings?.action === "created") && "added" in settings) { + const verb = settings.action === "created" ? "created" : "merged"; + const what = settings.added.length ? settings.added.join(", ") : "defaults"; + console.log(` settings: ${verb} ${what} into ${settings.path}`); + if ("backup" in settings && settings.backup) + console.log(` backup: ${settings.backup}`); } else if (settings?.action === "unchanged" && "path" in settings) { console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); + } else if (settings?.action === "error") { + console.log(` settings: NOT written — ${settings.reason}`); } if (detected) { console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); diff --git a/src/docs_check.js b/src/docs_check.js index a74db4a..8e1cb4e 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -13,15 +13,27 @@ import { TOOLS } from "./mcp_tools.js"; import { MODELS } from "./model_tiers.js"; /** The user-facing prose docs every claim is reconciled against. */ -const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md"]; +const DOC_FILES = [ + "README.md", + "docs/GUIDE.md", + "ARCHITECTURE.md", + "ROADMAP.md", +]; // Env vars read in src that are NOT user-facing contract: child-process plumbing and // values injected by host tools rather than set by users. -const INTERNAL_ENV = new Set(["_FORGE_LLM_KEY", "FORGE_EMBED_KEY", "CLAUDE_PLUGIN_ROOT"]); +const INTERNAL_ENV = new Set([ + "_FORGE_LLM_KEY", + "FORGE_EMBED_KEY", + "CLAUDE_PLUGIN_ROOT", + // Standard XDG base-dir var forge honors for its state home — not forge's own surface. + "XDG_STATE_HOME", +]); // Prefixes that mark an env var as OURS to document. A doc may freely mention other // tools' vars (GITHUB_TOKEN, PATH) — those aren't claims about forge's own surface. -const ENV_PREFIX_RE = /\b((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX)_[A-Z0-9_]+)\b/g; +const ENV_PREFIX_RE = + /\b((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX)_[A-Z0-9_]+)\b/g; function readDoc(root, rel) { const p = join(root, rel); @@ -44,8 +56,12 @@ export function envVarsRead(root = BRAND.root) { const vars = new Set(); for (const file of srcFiles(root)) { const text = readFileSync(file, "utf8"); - for (const m of text.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) vars.add(m[1]); - for (const m of text.matchAll(/process\.env\[["']([A-Z_][A-Z0-9_]*)["']\]/g)) vars.add(m[1]); + for (const m of text.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) + vars.add(m[1]); + for (const m of text.matchAll( + /process\.env\[["']([A-Z_][A-Z0-9_]*)["']\]/g, + )) + vars.add(m[1]); } const guards = join(root, "global", "guards"); if (existsSync(guards)) { @@ -80,7 +96,9 @@ function checkCommands(docs, issues) { } } for (const [file, text] of Object.entries(docs)) { - for (const m of text.matchAll(new RegExp(`\`${BRAND.cli} ([a-z][a-z-]*)\\b`, "g"))) { + for (const m of text.matchAll( + new RegExp(`\`${BRAND.cli} ([a-z][a-z-]*)\\b`, "g"), + )) { const name = m[1]; if (!(name in COMMANDS) && !HIDDEN_COMMANDS.includes(name)) { issues.push({ @@ -110,7 +128,11 @@ function checkEnvVars(root, docs, issues) { const documented = new Set(); for (const [file, text] of Object.entries(docs)) { for (const m of text.matchAll(ENV_PREFIX_RE)) { - if (!documented.has(`${file}:${m[1]}`) && !read.has(m[1]) && !INTERNAL_ENV.has(m[1])) { + if ( + !documented.has(`${file}:${m[1]}`) && + !read.has(m[1]) && + !INTERNAL_ENV.has(m[1]) + ) { documented.add(`${file}:${m[1]}`); issues.push({ check: "env-vars", @@ -168,7 +190,12 @@ function markdownFiles(root) { if (!existsSync(root)) return []; return readdirSync(root, { recursive: true }) .map(String) - .filter((f) => f.endsWith(".md") && !f.includes("node_modules") && !f.startsWith(".git/")); + .filter( + (f) => + f.endsWith(".md") && + !f.includes("node_modules") && + !f.startsWith(".git/"), + ); } // The branded Mermaid theme every diagram shares (see README's `%%{init …}%%`). Without it @@ -192,7 +219,10 @@ function checkDiagrams(root, issues) { for (const m of text.matchAll(MERMAID_BLOCK_RE)) { // An intentional example block (e.g. docs showing what a BAD diagram looks like) opts // out with an HTML comment `` on the line before the fence. - if (/docs-check-ignore/.test(text.slice(Math.max(0, m.index - 80), m.index))) continue; + if ( + /docs-check-ignore/.test(text.slice(Math.max(0, m.index - 80), m.index)) + ) + continue; const block = m[1]; if (!block.includes("%%{init")) { issues.push({ @@ -236,9 +266,13 @@ function checkChangelog(root, issues) { const text = readDoc(root, "CHANGELOG.md"); if (!text) return; const sections = [ - ...text.matchAll(/^## \[([^\]]+)\][^\n]*\n([\s\S]*?)(?=^## \[|\n*$(?![\s\S]))/gm), + ...text.matchAll( + /^## \[([^\]]+)\][^\n]*\n([\s\S]*?)(?=^## \[|\n*$(?![\s\S]))/gm, + ), ]; - const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; + const version = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ).version; const released = sections.filter((s) => s[1].toLowerCase() !== "unreleased"); if (released.length && released[0][1] !== version) { issues.push({ @@ -258,13 +292,18 @@ function checkChangelog(root, issues) { } const unreleased = sections.find((s) => s[1].toLowerCase() === "unreleased"); if (unreleased && !unreleased[2].trim()) { - const srcT = Number(git(root, ["log", "-1", "--format=%ct", "--", "src"]) || 0); - const clT = Number(git(root, ["log", "-1", "--format=%ct", "--", "CHANGELOG.md"]) || 0); + const srcT = Number( + git(root, ["log", "-1", "--format=%ct", "--", "src"]) || 0, + ); + const clT = Number( + git(root, ["log", "-1", "--format=%ct", "--", "CHANGELOG.md"]) || 0, + ); if (srcT && clT && srcT > clT) { issues.push({ check: "changelog", severity: "error", - detail: "src changed since CHANGELOG.md was last touched, but [Unreleased] is empty", + detail: + "src changed since CHANGELOG.md was last touched, but [Unreleased] is empty", }); } } @@ -300,7 +339,8 @@ function measuredTimings(root) { const set = new Set(); for (const line of readDoc(root, "reports/benchmarks.md").split("\n")) { if (!line.startsWith("|")) continue; // table rows only — not the prose above it - for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) set.add(`${m[1]} ${m[2]}`); + for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) + set.add(`${m[1]} ${m[2]}`); } return set; } @@ -353,8 +393,10 @@ function headingSlug(text) { // (``, `name=…`, or a `{#custom-id}` suffix). function anchorsFor(text) { const set = new Set(); - for (const m of text.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) set.add(headingSlug(m[1])); - for (const m of text.matchAll(/\b(?:id|name)=["']([\w-]+)["']/g)) set.add(m[1].toLowerCase()); + for (const m of text.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) + set.add(headingSlug(m[1])); + for (const m of text.matchAll(/\b(?:id|name)=["']([\w-]+)["']/g)) + set.add(m[1].toLowerCase()); for (const m of text.matchAll(/\{#([\w-]+)\}/g)) set.add(m[1].toLowerCase()); return set; } @@ -401,7 +443,8 @@ function checkLinks(root, issues) { let targetRel; if (!path) targetRel = rel; // same-file anchor - else if (path.endsWith(".md")) targetRel = normalize(join(dirname(rel), path)); + else if (path.endsWith(".md")) + targetRel = normalize(join(dirname(rel), path)); else continue; // .html/.pdf/code target — can't resolve headings, skip const anchors = anchorsOf(targetRel); if (anchors == null) continue; // unreadable/missing target — file existence is another matter @@ -433,7 +476,9 @@ function checkRoadmap(root, issues) { try { // Parse leading integers per component so a prerelease tag ("1.2.3-beta.1") still yields // [1,2,3], not NaN; guard the read so a missing/corrupt manifest can't abort the whole check. - const raw = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || ""; + const raw = + JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || + ""; const pv = raw.match(/(\d+)\.(\d+)(?:\.(\d+))?/); if (!pv) return; pkg = [Number(pv[1]), Number(pv[2]), Number(pv[3] || 0)]; @@ -442,7 +487,8 @@ function checkRoadmap(root, issues) { } const behind = road[0] < pkg[0] || - (road[0] === pkg[0] && (road[1] < pkg[1] || (road[1] === pkg[1] && road[2] < pkg[2]))); + (road[0] === pkg[0] && + (road[1] < pkg[1] || (road[1] === pkg[1] && road[2] < pkg[2]))); if (behind) { issues.push({ check: "roadmap", @@ -476,7 +522,9 @@ function checkCrosswalk(root, issues) { }); return; } - const known = new Set(srcFiles(root).map((f) => String(f).split(/[\\/]/).pop())); + const known = new Set( + srcFiles(root).map((f) => String(f).split(/[\\/]/).pop()), + ); for (const dir of [join("global", "guards"), "hooks"]) { const d = join(root, dir); if (!existsSync(d)) continue; diff --git a/src/init.js b/src/init.js index 4606548..6527e17 100644 --- a/src/init.js +++ b/src/init.js @@ -2,10 +2,12 @@ // state in one command; catalog is the "Start Here" index of everything active. import { appendFileSync, + copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, + renameSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -37,19 +39,49 @@ export function ensureLedgerGitattributes(targetRoot = process.cwd()) { const FORGE_SETTINGS_MARKER = "forge-managed"; +/** Rewrite the template's `~/.forge/…` hook + statusline commands to the ACTUAL installed + * package location. The npm global-install path never creates `~/.forge`, so a literal + * `~/.forge/guards/*.sh` reference points at nothing (P0-02). `~/.forge` is the `global/` + * dir (that's what install.sh symlinks), so `~/.forge/X` resolves to `/global/X`. */ +function resolveManagedPaths(template) { + const base = join(BRAND.root, "global"); + const fix = (cmd) => (typeof cmd === "string" ? cmd.replaceAll("~/.forge/", `${base}/`) : cmd); + if (template.statusLine?.command) template.statusLine.command = fix(template.statusLine.command); + for (const entries of Object.values(template.hooks || {})) { + for (const entry of entries) { + for (const h of entry.hooks || []) h.command = fix(h.command); + } + } + return template; +} + function loadTemplate() { const path = join(BRAND.root, "global", "settings.template.json"); - return JSON.parse(readFileSync(path, "utf8")); + return resolveManagedPaths(JSON.parse(readFileSync(path, "utf8"))); } -function readJsonSafe(path) { +/** Read an existing settings file, distinguishing a MISSING file (safe to treat as empty + * and create) from one that is PRESENT BUT UNPARSEABLE (must never be silently replaced — + * P0-01). Returns `{status, data}` with status `missing` | `ok` | `corrupt`. */ +function readExistingSettings(path) { + let raw; + try { + raw = readFileSync(path, "utf8"); + } catch { + return { status: "missing", data: {} }; + } try { - return JSON.parse(readFileSync(path, "utf8")); + return { status: "ok", data: JSON.parse(raw) }; } catch { - return null; + return { status: "corrupt", data: null }; } } +/** Filesystem-safe timestamp for backup filenames. */ +function stamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + /** Deduplicated union of two string arrays. */ function unionStrings(a = [], b = []) { const set = new Set(a); @@ -97,7 +129,19 @@ export function mergeSettings({ settingsPath, noSettings } = {}) { if (noSettings) return { action: "skipped", reason: "--no-settings" }; const target = settingsPath || join(homedir(), ".claude", "settings.json"); const template = loadTemplate(); - const existing = readJsonSafe(target) || {}; + const { status, data } = readExistingSettings(target); + // Present-but-unparseable: refuse rather than overwrite the user's real (if broken) file. + if (status === "corrupt") { + return { + action: "error", + path: target, + reason: + "existing settings file is present but not valid JSON — refusing to overwrite. " + + "Fix or remove it, or re-run with --no-settings.", + }; + } + const existing = data; + const before = JSON.stringify(existing); const report = { added: [], unchanged: [], path: target }; // Hooks @@ -137,13 +181,32 @@ export function mergeSettings({ settingsPath, noSettings } = {}) { // Mark as forge-managed (metadata, won't affect Claude Code) existing._forge = FORGE_SETTINGS_MARKER; - // Write back + // Nothing to do — don't rewrite (or back up) an already-current file. + if (status !== "missing" && JSON.stringify(existing) === before) { + return { + action: "unchanged", + path: target, + added: [], + unchanged: report.unchanged, + }; + } + + // Back up any existing file, then write atomically (temp + rename) so a crash mid-write + // can never leave a truncated settings.json (P0-01). const dir = dirname(target); mkdirSync(dir, { recursive: true }); - writeFileSync(target, `${JSON.stringify(existing, null, 2)}\n`); + let backup = null; + if (status === "ok" && existsSync(target)) { + backup = `${target}.forge-bak-${stamp()}`; + copyFileSync(target, backup); + } + const tmp = `${target}.forge-tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(existing, null, 2)}\n`); + renameSync(tmp, target); return { - action: report.added.length ? "merged" : "unchanged", + action: report.added.length ? "merged" : "created", + backup, ...report, }; } diff --git a/src/recall.js b/src/recall.js index 654518c..6e38877 100644 --- a/src/recall.js +++ b/src/recall.js @@ -17,7 +17,14 @@ import { hasSecret, SECRET_RE } from "./secrets.js"; export { hasSecret, SECRET_RE }; export function defaultStore() { - return join(process.env.FORGE_HOME || join(homedir(), ".forge"), "recall"); + // Mutable personal memory. FORGE_HOME override wins (tests + recall-load.sh rely on it); + // otherwise the XDG state dir — NEVER inside the install/source tree (P0-03). The old + // default (~/.forge) was symlinked by install.sh into the clone, leaking personal facts + // into the repo working tree. + if (process.env.FORGE_HOME) return join(process.env.FORGE_HOME, "recall"); + const xdg = process.env.XDG_STATE_HOME; + const base = xdg ? join(xdg, "forgekit") : join(homedir(), ".local", "state", "forgekit"); + return join(base, "recall"); } const factsDir = (store) => join(store, "facts"); diff --git a/test/init.test.js b/test/init.test.js index 8e6adb5..296017a 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -38,7 +38,10 @@ test("mergeSettings deduplicates plugin-style and settings-style hooks", () => { Stop: [ { hooks: [ - { type: "command", command: '"${CLAUDE_PLUGIN_ROOT}"/global/guards/lean-guard.sh' }, + { + type: "command", + command: '"${CLAUDE_PLUGIN_ROOT}"/global/guards/lean-guard.sh', + }, ], }, ], @@ -61,6 +64,35 @@ test("mergeSettings deduplicates plugin-style and settings-style hooks", () => { assert.equal(leanCount, 1, "lean-guard.sh must not duplicate"); }); +test("mergeSettings refuses to overwrite a present-but-unparseable settings file", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-corrupt-")); + const settingsPath = join(tmp, ".claude", "settings.json"); + mkdirSync(dirname(settingsPath), { recursive: true }); + const original = "{ this is not valid json "; + writeFileSync(settingsPath, original); + const result = mergeSettings({ settingsPath }); + assert.equal(result.action, "error", "corrupt file must not be silently overwritten"); + assert.equal(readFileSync(settingsPath, "utf8"), original, "original bytes preserved"); +}); + +test("mergeSettings backs up an existing valid file and resolves guard paths absolutely", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-backup-")); + const settingsPath = join(tmp, ".claude", "settings.json"); + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ model: "sonnet" })); + const result = mergeSettings({ settingsPath }); + assert.equal(result.action, "merged"); + assert.ok(result.backup && existsSync(result.backup), "a timestamped backup was written"); + const merged = JSON.parse(readFileSync(settingsPath, "utf8")); + const cmds = (merged.hooks?.UserPromptSubmit || []).flatMap((e) => + (e.hooks || []).map((h) => h.command), + ); + assert.ok( + cmds.some((c) => c.includes("/global/guards/") && !c.includes("~/.forge/")), + "hook commands resolve to the installed package, not the unmaterialized ~/.forge", + ); +}); + test("catalog indexes tools (with a why), crew, and guards", () => { const c = catalog(); assert.ok( From a6c3d9f597aa72a278e071b209ccd96187b573a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:47:48 +0000 Subject: [PATCH 02/10] docs+wording: honest claims, terminology renames, drop scanner certification language Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- README.md | 136 +++++++++++--------- ROADMAP.md | 4 +- docs/GUIDE.md | 55 ++++---- docs/audit/REMEDIATION.md | 39 ++++++ mintlify/concepts/proof-carrying-memory.mdx | 7 + mintlify/concepts/verification-gates.mdx | 9 ++ mintlify/guides/zero-config-onboarding.mdx | 10 +- mintlify/introduction.mdx | 14 +- src/consensus.js | 22 ++-- src/preflight.js | 9 +- src/skillgate.js | 39 +++++- test/skillgate.test.js | 22 ++++ 12 files changed, 252 insertions(+), 114 deletions(-) create mode 100644 docs/audit/REMEDIATION.md diff --git a/README.md b/README.md index 4b7c66e..fce932c 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,26 @@ Forge is one shared brain for your AI coding agents. It gives a stateless model three things it structurally lacks — memory, foresight, and enforced guardrails — and delivers them into every tool you use. -> The cognitive substrate every frozen model is missing — proof-carrying memory, impact -> foresight, and enforced guardrails — authored once and delivered as native config to -> Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and Continue (plus -> MCP config for Roo and VS Code). - -> **Status: beta.** The core (`init`, `sync`, `substrate`, `impact`, `ledger`, guards) is -> tested and in daily use; some flags may change before `1.0`. +> The cognitive substrate every frozen model is missing — evidence-referenced, +> content-addressed memory (we call it "proof-carrying memory" / PCM — see the honesty note +> below), heuristic impact foresight, and enforced guardrails — authored once and delivered +> as native config to Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and +> Continue (plus MCP config for Roo and VS Code). + +> **Status: beta — read before you rely on it.** +> +> - The core (`init`, `sync`, `substrate`, `impact`, `ledger`, guards) is tested and in daily +> use; some flags may change before `1.0`. +> - **Claude Code is the deepest-tested integration** (full plugin, ambient `UserPromptSubmit` +> guards). The other eight tools receive native config plus MCP tools, but have had less +> real-world exercise. +> - **Impact/blast-radius analysis is heuristic** — a regex-approximate, conservative code +> graph, not a sound call graph. Treat its output as advisory. +> - **"Proof-carrying memory" is a name, not a formal proof.** Claims are content-addressed and +> carry evidence references; confidence moves only when independent oracles (tests, CI, a +> human) raise it. There is no theorem-prover in the loop. +> - Some integrations shell out — `forge harden`, `forge scan`, and the git-native ledger +> assume **Bash, Git, and (for a few paths) `jq`** are available. ## Start in 60 seconds @@ -93,14 +106,16 @@ so a wrong lesson decays out instead of ossifying. Full design: The day-to-day value first — the substrate gives a frozen model what it can't hold itself: -- **Memory that persists across sessions and teammates.** Every lesson, fact, and verified - reuse is _proof-carrying memory (PCM)_ — a claim that carries its own evidence and is only - trusted once independent oracles raise its confidence above a floor. Wrong lessons decay - out instead of ossifying. -- **Foresight before you break things.** Ask "what does changing `verifyToken` break?" and - get the _blast radius_ — the set of files an edit is predicted to impact, read from the - code graph, including coupled files you never named. -- **Guardrails that can't be forgotten.** Deterministic hooks enforce the rules a model must +- **Memory that persists across sessions and teammates.** _[Implemented]_ Every lesson, fact, + and verified reuse is _proof-carrying memory (PCM)_ — our name for **evidence-referenced, + content-addressed memory**: a claim that carries references to its own evidence and is only + trusted once independent oracles raise its confidence above a floor (the "proof" is that + evidence trail, not a formal proof). Wrong lessons decay out instead of ossifying. +- **Foresight before you break things.** _[Heuristic]_ Ask "what does changing `verifyToken` + break?" and get the _blast radius_ — the set of files an edit is predicted to impact, read + from a regex-approximate (conservative, not sound) code graph, including coupled files you + never named. +- **Guardrails that can't be forgotten.** _[Implemented]_ Deterministic hooks enforce the rules a model must never break (protected paths, cost budget, doom loops) — they survive a context compaction the way `CLAUDE.md` prose does not. - **Work that finishes end to end.** A completion gate blocks "done" once per session when @@ -120,9 +135,9 @@ an assumption until measured_. - **Blast radius in 0.43 ms** (warm code-graph). On 6 hand-labeled cases from this repo's real import graph: recall **0.97** vs **0.33** for looking at the edited file alone. -- **A full pre-action gate in 118 ms** — assumption check, routing, reuse lookup, context - assembly, blast radius, scope, and goal anchor in one deterministic pass, no LLM call. On - Claude Code it runs on **every prompt, automatically**. +- **A full pre-action gate in 118 ms** (median on this repo, warm) — assumption check, routing, + reuse lookup, context assembly, blast radius, scope, and goal anchor in one deterministic + pass, no LLM call. On Claude Code it runs on **every prompt, automatically**. - **62.1% cost saved vs always-premium** — from the white paper's live routing prototype on real models (paper §9; that's the paper's measurement, not this repo's — `forge cost --stages` reports only _your_ measured stages). @@ -173,50 +188,49 @@ Output is plain text when piped; on a TTY it adds brand-palette color and confid meters. `NO_COLOR` turns color off, `FORCE_COLOR=1` forces it on (e.g. in CI, `0` forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. -| Group | Command | Does | +| Group | Command | Does | | -------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **Config layer** | `forge init` | emit every tool's native config from one source | -| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | -| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | -| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | -| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | -| | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | -| | `forge catalog` | Start-Here index of every tool / crew / guard | -| | `forge brand` | print the brand token map | -| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | -| | `forge recall` | cross-session personal memory — list / add / consolidate | -| | `forge remember` | durable, repo-committable fact | -| | `forge brain` | portable project-memory index | -| | `forge cortex` | self-correcting lessons — `status` / `why` | -| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | -| | `forge reuse` | proof-carrying code cache — query / mint / stats | -| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | -| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | -| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | -| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | -| | `forge preflight` | assumption / info-gap check | -| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | -| | `forge impact` | predict blast radius for a symbol or file | -| | `forge scope` | cluster + surface coupled files | -| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | -| | `forge context` | budgeted context assembly + completeness gate | -| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | -| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | -| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | -| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | -| | `forge lean` | scope-minimality footprint (advisory) | -| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | -| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | -| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | -| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | -| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | -| | `forge spec` | spec-as-contract drift — init / lock / check | -| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | -| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | -| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | -| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | - +| **Config layer** | `forge init` | emit every tool's native config from one source | +| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | +| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | +| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | +| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | +| | `forge config` | provider setup — show / switch / add providers, set the default model | +| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | +| | `forge catalog` | Start-Here index of every tool / crew / guard | +| | `forge brand` | print the brand token map | +| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | +| | `forge recall` | cross-session personal memory — list / add / consolidate | +| | `forge remember` | durable, repo-committable fact | +| | `forge brain` | portable project-memory index | +| | `forge cortex` | self-correcting lessons — `status` / `why` | +| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | +| | `forge reuse` | proof-carrying code cache — query / mint / stats | +| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | +| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | +| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | +| | `forge preflight` | assumption / info-gap check | +| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | +| | `forge impact` | predict blast radius for a symbol or file | +| | `forge scope` | cluster + surface coupled files | +| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | +| | `forge context` | budgeted context assembly + completeness gate | +| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | +| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | +| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | +| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | +| | `forge lean` | scope-minimality footprint (advisory) | +| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | +| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | +| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | +| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | +| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | +| | `forge spec` | spec-as-contract drift — init / lock / check | +| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | +| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | +| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | +| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | **→ Every command with a worked example and real output: [`docs/GUIDE.md`](docs/GUIDE.md).** diff --git a/ROADMAP.md b/ROADMAP.md index 7b18a9a..c5f50c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ confidence only from independent oracles, and merges across teammates conflict-f - **Zero-config provider auto-detection** — `autoDetectProvider()` probes env vars for LiteLLM (local + hosted gateway), OpenRouter, and Anthropic (key, auth token, or custom base URL); `forge init` reports what it found, no manual config needed. - (OpenAI and Gemini detection shipped later as a zero-config fallback — see CHANGELOG.) + (OpenAI and Gemini detection shipped later as a low-configuration auto-detect fallback — see CHANGELOG.) - **Hosted LiteLLM gateway** — `emitGatewayConfig()` writes a `litellm.config.yaml` exposing the complexity tiers as model aliases; point `ANTHROPIC_BASE_URL` at the proxy and every model call routes through it. @@ -68,7 +68,7 @@ confidence only from independent oracles, and merges across teammates conflict-f local state; the remaining step is retiring them so the ledger is the only store. - **OpenAI + Gemini provider detection** — extend `autoDetectProvider()` beyond Anthropic/OpenRouter/LiteLLM (`OPENAI_API_KEY`, `GEMINI_API_KEY`) with the same - zero-config contract. + guided, low-configuration auto-detect contract. - **Playwright loop** — still open: interaction checks and feeding verdicts back as oracle evidence on design claims (fingerprinting itself shipped as `forge uicheck visual`). diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9ea8ce9..925e061 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -231,12 +231,13 @@ happens when the measurement earns it — an assertion never does. ### `forge config` — provider setup Shows, switches, and registers model providers, and sets the default model. Forge -auto-detects the provider from the environment with zero config — the priority order is +auto-detects the provider from the environment (guided, low-configuration onboarding — no +manual config file needed in the common case) — the priority order is `LITELLM_BASE_URL` → `ANTHROPIC_BASE_URL` (a URL that answers `/health` or names a gateway is classified as one) → `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `ANTHROPIC_AUTH_TOKEN` → `OPENAI_API_KEY` → `GEMINI_API_KEY` (or `GOOGLE_API_KEY`). Anthropic credentials win when present (forge is Claude-native); OpenAI and Gemini are -picked up as the zero-config fallback when they are the only key set, and are reached +picked up as the low-configuration auto-detect fallback when they are the only key set, and are reached over their OpenAI-compatible chat/completions surface. An explicit `.forge/providers.json` always wins over detection. @@ -260,7 +261,7 @@ own names (`bedrock-claude-haiku`, `prod-sonnet-5`). When a non-default gateway set, Forge asks it once per process (`GET /v1/models`) and scores each advertised model against every tier's family — the family word (haiku/sonnet/opus/fable) gates the match, the overlap score picks the best id — then remaps each tier onto a real gateway model. It is a -silent, zero-config fallback: no gateway, an unreachable `/v1/models`, or no family match and +silent, low-configuration fallback: no gateway, an unreachable `/v1/models`, or no family match and the stock IDs are used unchanged; direct `api.anthropic.com` sessions never probe. An explicit model in `.forge/providers.json` (or `ANTHROPIC_MODEL`) always wins over the remap. `forge doctor` prints the resolved `tier→model` mapping under **gateway models** so you can verify it @@ -431,12 +432,14 @@ Forge verify independent lenses over the same diff — the test suite, unknown symbols, atlas dependents the diff never touched, code-without-docs drift, secret-shaped tokens in the added lines, spec-lock drift, and (opt-in) a reviewer panel — and aggregates them -the way the lesson miner scores mistakes: noisy-OR `P(defect) = 1 − ∏(1 − wᵢsᵢ)` with -a **cross-family gate**, so any number of correlated structural signals stays advisory -while a failing test suite or a leaked secret blocks on its own. Every run reports the -Theorem-D residual `∏(1 − cⱼ)` over the lenses that actually ran — how much -silent-miss probability a PASS still carries — and extends `.forge/provenance.json` -with the per-lens evidence plus one `stage:"verify"` metrics record. +the way the lesson miner scores mistakes: a noisy-OR **defect risk score (heuristic)**, +`p = 1 − ∏(1 − wᵢsᵢ)` (shown as `P(defect)` in the CLI), with a **cross-family gate**, so +any number of correlated structural signals stays advisory while a failing test suite or a +leaked secret blocks on its own. `p` is a calibrated heuristic, not a measured probability +of defect. Every run reports the `residual` `∏(1 − cⱼ)` over the lenses that actually ran +— the **remaining unchecked weight**, i.e. how much silent-miss weight a PASS still leaves +uncovered — and extends `.forge/provenance.json` with the per-lens evidence plus one +`stage:"verify"` metrics record. `--llm` (or `FORGE_LLM=1`) adds the reviewer lens: three independent model samples over the added lines, strict-majority vote, abstaining honestly when fewer than half @@ -518,8 +521,8 @@ $ forge radar Go: currency probe is Node-first ``` -Rings are a **formula over registry evidence** (mizan — every ring ships the evidence that -earned it): `staleness = 1 − 0.5^(daysSincePublish/540)` (a 540-day half-life), major-version +Rings are a **formula over registry evidence** (_mizan_ — a philosophical/ethical framing of +weighed judgment, not a technical authority; every ring ships the evidence that earned it): `staleness = 1 − 0.5^(daysSincePublish/540)` (a 540-day half-life), major-version lag, open security advisories (severity-weighted), and maintainer deprecation. Repo _usage_ (import-sites from the atlas) is **stakes, not risk** — it only sorts output, never the score. Hard rules: **deprecated or a critical advisory → `hold`** regardless of freshness; fewer than @@ -1222,8 +1225,8 @@ code reads but this table misses fails CI on the forge repo): | `ANTHROPIC_MODEL` / `FORGE_MODEL` | pin one model — bypasses tier routing entirely | | `LITELLM_BASE_URL` / `LITELLM_API_KEY` | hosted LiteLLM gateway endpoint + key (highest detection priority) | | `OPENROUTER_API_KEY` | OpenRouter provider | -| `OPENAI_API_KEY` | OpenAI provider (OpenAI-compatible chat/completions); zero-config fallback after Anthropic | -| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Google Gemini provider via its OpenAI-compatible endpoint; zero-config fallback after Anthropic | +| `OPENAI_API_KEY` | OpenAI provider (OpenAI-compatible chat/completions); low-configuration auto-detect fallback after Anthropic | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Google Gemini provider via its OpenAI-compatible endpoint; low-configuration auto-detect fallback after Anthropic | | `FORGE_LLM` | `1` enables the LLM proposer layer (off = fully deterministic) | | `FORGE_LLM_AMBIENT` | `1` lets the ambient hook use the proposer too | | `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages or OpenAI-compatible, per the resolved provider) instead of the `claude` CLI; automatic when the CLI is absent | @@ -1238,19 +1241,19 @@ code reads but this table misses fails CI on the forge repo): | `FORGE_LOOP_THRESHOLD` | identical tool calls before the doom-loop guard speaks (default 4) | | `FORGE_LEAN_THRESHOLD` | lines-per-task-word ratio the lean guard nudges at | | `FORGE_VERIFY_TIMEOUT_MS` | verify test-run timeout (default 600000) | -| `FORGE_RADAR` | `0` disables the pre-edit dependency-currency advisory | -| `FORGE_RADAR_TTL_H` | `forge radar` cache TTL in hours (default 24) | -| `FORGE_SKILLGATE_NOEXTERNAL` | `1` skips the external scanner in `forge scan` (heuristic only) | -| `ENABLE_CORTEX_DISTILL` | `1` distills new lessons into prose via a cheap model call | -| `FORGE_STOPGATE` | `0` disables the Stop completion gate (code-without-docs block) | -| `FORGE_COMMIT_GATE` | commit-gate mode: `warn` (default — print findings, allow), `block` (refuse the commit), `0` (off); a detected secret blocks in every mode | -| `FORGE_INTENT` | `0` disables intent protocol cards on prompts | -| `FORGE_VERBOSE` | `1` restores the `Forge ` title line on command output (also `--verbose`) | -| `NO_COLOR` | set (non-empty) disables ANSI color in CLI output — the [no-color.org](https://no-color.org) convention | -| `FORCE_COLOR` | forces CLI color on even when piped, e.g. in CI (`0` forces off) — takes precedence over `NO_COLOR` | -| `TERM` / `COLORTERM` | `TERM=dumb` disables color; `COLORTERM=truecolor`/`24bit` upgrades to the brand palette's 24-bit hues | -| `FORGE_NO_UPDATE_CHECK` | `1` silences the `forge doctor` update notice | -| `FORGE_DEBUG` | `1` writes fail-safe error details to stderr instead of swallowing them | +| `FORGE_RADAR` | `0` disables the pre-edit dependency-currency advisory | +| `FORGE_RADAR_TTL_H` | `forge radar` cache TTL in hours (default 24) | +| `FORGE_SKILLGATE_NOEXTERNAL` | `1` skips the external scanner in `forge scan` (heuristic only) | +| `ENABLE_CORTEX_DISTILL` | `1` distills new lessons into prose via a cheap model call | +| `FORGE_STOPGATE` | `0` disables the Stop completion gate (code-without-docs block) | +| `FORGE_COMMIT_GATE` | commit-gate mode: `warn` (default — print findings, allow), `block` (refuse the commit), `0` (off); a detected secret blocks in every mode | +| `FORGE_INTENT` | `0` disables intent protocol cards on prompts | +| `FORGE_VERBOSE` | `1` restores the `Forge ` title line on command output (also `--verbose`) | +| `NO_COLOR` | set (non-empty) disables ANSI color in CLI output — the [no-color.org](https://no-color.org) convention | +| `FORCE_COLOR` | forces CLI color on even when piped, e.g. in CI (`0` forces off) — takes precedence over `NO_COLOR` | +| `TERM` / `COLORTERM` | `TERM=dumb` disables color; `COLORTERM=truecolor`/`24bit` upgrades to the brand palette's 24-bit hues | +| `FORGE_NO_UPDATE_CHECK` | `1` silences the `forge doctor` update notice | +| `FORGE_DEBUG` | `1` writes fail-safe error details to stderr instead of swallowing them | --- diff --git a/docs/audit/REMEDIATION.md b/docs/audit/REMEDIATION.md new file mode 100644 index 0000000..aa77aaa --- /dev/null +++ b/docs/audit/REMEDIATION.md @@ -0,0 +1,39 @@ +# Audit remediation record + +A concise record of the honesty / wording audit and what was addressed in this change. +Status legend: **Done** (shipped here) · **Deferred** (tracked, out of scope for this pass). + +## Wording & terminology (this change — Done) + +| # | Item | Resolution | Status | +| ---- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| S-01 | Skill scanner printed a certification-style "ok to install" verdict, and a HIGH-severity finding read as safe | `src/skillgate.js` now returns `verdict` (honest: "No critical signature detected — this is NOT a safety certification…"), a `high` flag, and a `safe` boolean that is false when critical **or** high findings exist. External scanner stays opt-in with no new network calls. Tests added in `test/skillgate.test.js`. | Done | +| T-01 | `P(defect)` label implies a measured probability | `src/consensus.js` comments/JSDoc reframe the `p` key as a **defect risk score (heuristic)**; docs (`docs/GUIDE.md`) qualify every mention. Internal key `p` kept (read by `src/cli.js` + metrics + tests). | Done | +| T-02 | `residual` label implies a proof-grade bound | Reframed as the **remaining unchecked-weight bound** in `src/consensus.js` comments and `docs/GUIDE.md`. Internal key `residual` kept for the same readers. | Done | +| T-03 | Preflight "probability that the task is sufficiently specified" | `src/preflight.js` JSDoc/comments now call it a **specification completeness heuristic score** — explicitly not a probability. Numeric value unchanged. | Done | +| D-01 | "proof-carrying memory" overclaims a formal proof | README, `mintlify/introduction.mdx`, and `mintlify/concepts/proof-carrying-memory.mdx` reframe it as **evidence-referenced, content-addressed memory** (the "proof" is an evidence trail, not a machine-checked proof; no theorem prover in the loop). Term kept as a named concept. | Done | +| D-02 | "zero-config" overclaims zero-touch setup | README status block, ROADMAP, `docs/GUIDE.md`, and `mintlify/guides/zero-config-onboarding.mdx` reworded to **guided / low-configuration onboarding**. Page slug kept to avoid breaking links. | Done | +| D-03 | Benchmark numbers not always scoped | README numbers now carry sample size/scope in-sentence (blast-radius recall n=6 on one JS repo; 118 ms median on this repo, warm; the 62.1% figure remains attributed to the white paper's prototype, not this repo). | Done | +| D-04 | Claude-first maturity not stated prominently | README status block and `mintlify/introduction.mdx` state Claude Code is the deepest-tested integration; other tools have had less real-world exercise. | Done | +| D-05 | Formal-proof vs empirical claims blurred; heuristic impact not flagged | Impact/blast-radius labelled **[Heuristic]** (regex-approximate, conservative, not a sound call graph) in README; formal statements kept separate from measured ones. | Done | +| D-06 | Qur'anic framing could read as technical authority | `_mizan_` in `docs/GUIDE.md` and `src/consensus.js` now explicitly labelled a **philosophical/ethical framing**, not a technical guarantee. | Done | +| D-07 | Some integrations silently assume shell tooling | README status block notes several paths need **Bash, Git, and (a few) `jq`**. | Done | +| D-08 | Feature status labels inconsistent | Introduced **Implemented / Heuristic / Formally-modelled / Experimental / Planned** labels where feature descriptions were touched (README "What you get"). | Done | + +## Note on CLI display strings (handoff) + +The rendered labels `P(defect):`, `residual:`, and the scanner's `ok to install` line are +printed in `src/cli.js`, which is owned by a parallel workstream and was **not** edited here. +The honest source-of-truth values now exist on the returned objects (`skillgate.scan().verdict` +/ `.safe`; consensus `p` / `residual` documented as heuristic). Wiring `src/cli.js` to render +`r.verdict` and relabel `P(defect)` → "defect risk score (heuristic)" / `residual` → +"remaining unchecked weight" is a one-line follow-up for that workstream. + +## Deferred (tracked, not in this pass) + +- **External multi-language benchmark** — impact/recall numbers remain n=6 on one JavaScript + repo; a cross-language, multi-repo benchmark is deferred. +- **Multi-OS runtime certification** — behaviour is exercised on Linux/macOS shells; a + formal multi-OS (incl. Windows) certification matrix is deferred. +- **Monorepo package split** — splitting the single package into separately versioned + packages is deferred. diff --git a/mintlify/concepts/proof-carrying-memory.mdx b/mintlify/concepts/proof-carrying-memory.mdx index 796b21f..22abe03 100644 --- a/mintlify/concepts/proof-carrying-memory.mdx +++ b/mintlify/concepts/proof-carrying-memory.mdx @@ -8,6 +8,13 @@ _claim_ that carries its own evidence. It is trusted only once independent oracl (tests, CI, a human accept/revert) raise its confidence above a floor. A wrong lesson decays out instead of ossifying. + + "Proof-carrying memory" is our name for **evidence-referenced, content-addressed + memory** — a claim addressed by the hash of its content and linked to the oracle + outcomes that back it. The "proof" is that evidence trail plus the confidence rule, **not + a formal machine-checked proof**; there is no theorem prover in the loop. + + ## One store, many writers All memory subsystems converge on one store. `recall`, `remember`/`brain`, `cortex` diff --git a/mintlify/concepts/verification-gates.mdx b/mintlify/concepts/verification-gates.mdx index 1d50292..9475e48 100644 --- a/mintlify/concepts/verification-gates.mdx +++ b/mintlify/concepts/verification-gates.mdx @@ -60,6 +60,15 @@ Before installing a skill or MCP server, vet it for injection, RCE, or exfiltrat forge scan ``` + + A clean scan is **not a safety certification.** The built-in heuristic only catches + known attack shapes (critical) and a few high-severity patterns; a pass means _"no + critical signature detected"_, not _"safe to install"_. Always review the source, + permissions, package provenance, and network behaviour yourself. A **high**-severity + finding is not marked safe even though it does not hard-block. The external scanner is + opt-in and runs no network call unless you enable it. + + ## Hardening — `forge harden` Wire the security controls that keep secrets and unsafe changes out: diff --git a/mintlify/guides/zero-config-onboarding.mdx b/mintlify/guides/zero-config-onboarding.mdx index 48d5cbb..0937ca7 100644 --- a/mintlify/guides/zero-config-onboarding.mdx +++ b/mintlify/guides/zero-config-onboarding.mdx @@ -1,10 +1,12 @@ --- -title: "Zero-config onboarding" -description: "Five minutes to productive: install once, configure a repo once, do a task, and watch the ledger start paying off on day two." +title: "Guided, low-configuration onboarding" +description: "Five minutes to productive: install once, configure a repo once, do a task, and watch the ledger start paying off on day two — guided, low-configuration, not zero-touch." --- -Forge is designed so a new repo is productive in about five minutes. Install once, -configure a repo once, do a task, and the ledger starts paying off on day two. +Forge aims for **guided, low-configuration onboarding** — a new repo is usually productive +in about five minutes. Install once, configure a repo once, do a task, and the ledger starts +paying off on day two. (It is low-configuration, not zero-configuration: you still install +the CLI, run `forge init` in each repo, and some paths assume Bash, Git, and `jq`.) ```mermaid flowchart TD diff --git a/mintlify/introduction.mdx b/mintlify/introduction.mdx index b70f300..acdcbd1 100644 --- a/mintlify/introduction.mdx +++ b/mintlify/introduction.mdx @@ -7,9 +7,11 @@ description: "Forge is the cognitive substrate every stateless model is missing context window, wiped every call. It has no memory of what your team learned, no foresight about what an edit will break, and no enforced guardrails. Forge (`@codewithjuber/forgekit`) is the **cognitive substrate** — the layer that runs -_before_ the model edits code, supplying proof-carrying memory, impact foresight, and -enforced guardrails — and a **cross-tool config compiler** that delivers that brain as -native config into every tool at once. +_before_ the model edits code, supplying evidence-referenced, content-addressed memory (we +call it "proof-carrying memory"), heuristic impact foresight, and enforced guardrails — and +a **cross-tool config compiler** that delivers that brain as native config into every tool +at once. Claude Code is the deepest-tested integration; the others receive native config and +MCP tools with less real-world exercise. @@ -69,8 +71,10 @@ state between calls; Forge is the state. ## What you get - **Memory that persists across sessions and teammates.** Every lesson, fact, and - verified reuse is _proof-carrying memory (PCM)_ — a claim trusted only once independent - oracles raise its confidence above a floor. + verified reuse is _proof-carrying memory (PCM)_ — our name for evidence-referenced, + content-addressed memory: a claim that carries references to its evidence and is trusted + only once independent oracles raise its confidence above a floor. The "proof" is that + evidence trail, not a formal proof. - **Foresight before you break things.** Ask "what does changing `verifyToken` break?" and get the blast radius from the code graph, including coupled files you never named. - **Guardrails that can't be forgotten.** Deterministic hooks enforce protected paths, diff --git a/src/consensus.js b/src/consensus.js index c8d6f2b..6b6c536 100644 --- a/src/consensus.js +++ b/src/consensus.js @@ -1,14 +1,16 @@ // forge consensus — multi-lens verification (`verify --deep`). Where plain `verify` // asks one oracle (the project's tests) plus one heuristic (unknown symbols), this // module runs a LENSES table of independent checks and aggregates them exactly the -// way lessons.js scores mistakes: noisy-OR P(defect) = 1 − ∏(1 − wᵢsᵢ) with a -// cross-family gate (≥2 evidence families, or a solo-trusted lens) so a pile of -// correlated structural signals can never block on its own. +// way lessons.js scores mistakes: a noisy-OR defect risk score (heuristic), p = 1 − +// ∏(1 − wᵢsᵢ) — the field is `p` — with a cross-family gate (≥2 evidence families, or a +// solo-trusted lens) so a pile of correlated structural signals can never block on its +// own. `p` is a calibrated heuristic, NOT a proof or a measured defect probability. // -// Mizan (weighed judgment): the verdict ships WITH its evidence. Every lens reports -// whether it ran and what it saw, and the Theorem-D residual ∏ⱼ(1 − cⱼ) over the -// lenses that actually ran states how much silent-miss probability remains even on -// PASS — a green light is a measured claim, never a vibe. The reviewer lens (LLM +// Mizan (weighed judgment — a philosophical/ethical framing, not a technical guarantee): +// the verdict ships WITH its evidence. Every lens reports whether it ran and what it saw, +// and the remaining-unchecked-weight bound ∏ⱼ(1 − cⱼ) over the lenses that actually ran +// (the field is `residual`) states how much silent-miss weight remains even on PASS — a +// green light is an evidenced heuristic claim, never a vibe, and never a proof. The reviewer lens (LLM // majority-of-N) is opt-in, fail-safe, and can never block alone: it is a proposer // in the adjudicate.js sense, one voice among deterministic checks. import { mkdirSync, writeFileSync } from "node:fs"; @@ -51,10 +53,12 @@ export const BLOCK_THRESHOLD = 0.5; /** * Aggregate lens events — byte-for-byte the scoreMistake shape (lessons.js): * noisy-OR over firing lenses (bounded in [0,1), so many weak signals can't fake - * one strong one) + the cross-family gate. `residual` is the Theorem-D term - * ∏ⱼ(1 − cⱼ) over every lens that RAN (firing or clean): what a PASS still misses. + * one strong one) + the cross-family gate. `p` is the defect risk score (heuristic). + * `residual` is the remaining-unchecked-weight bound ∏ⱼ(1 − cⱼ) over every lens that + * RAN (firing or clean): the share of silent-miss weight a PASS still leaves uncovered. * @param {LensEvent[]} events * @returns {{p:number, fires:boolean, families:string[], residual:number, block:boolean}} + * `p` = defect risk score (heuristic); `residual` = remaining unchecked weight. */ export function aggregate(events) { const ran = (events ?? []).filter((e) => e && LENSES[e.lens] && e.ran !== false); diff --git a/src/preflight.js b/src/preflight.js index a51ba6e..c25116c 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -160,7 +160,7 @@ export function ambiguityMarkers(text) { return [...new Set(out)]; } -// M2 completeness model — a logistic (log-odds) estimator of specification completeness s(x), +// M2 completeness model — a logistic (log-odds) specification completeness heuristic score s(x), // replacing the older additive scorer whose magic coefficients + discontinuous word-count steps // the audit flagged as "graded-but-uncalibrated". Each feature contributes a signed amount to the // log-odds and the sigmoid maps the sum to [0,1] — so the estimate is smooth (no step jumps), @@ -198,7 +198,12 @@ export function completenessFeatures(task) { }; } -/** Completeness s(x) ∈ (0,1) as a logistic over the feature vector. */ +/** + * Specification completeness heuristic score s(x) ∈ (0,1) — a logistic over the feature + * vector. It is a calibrated heuristic that reframes how completely a task is specified; + * it is NOT a probability that the task is sufficiently specified, and carries no + * empirical guarantee. Only the numeric scale is [0,1]. + */ export function completenessScore(features, weights = COMPLETENESS_WEIGHTS) { const z = weights.bias + diff --git a/src/skillgate.js b/src/skillgate.js index 13b9e44..318b460 100644 --- a/src/skillgate.js +++ b/src/skillgate.js @@ -1,7 +1,8 @@ -// forge skill-gate — vet a skill / MCP config BEFORE it's installed. ToxicSkills -// found ~36% of skills flawed / 13.4% critical, ~60% of the risk in the instruction -// (markdown) layer — so string prose matters as much as code. Prefer a real scanner -// (Snyk agent-scan) when present; otherwise a built-in heuristic catches the loud cases. +// forge skill-gate — vet a skill / MCP config BEFORE it's installed. The external +// ToxicSkills study reported ~36% of skills flawed / 13.4% critical, with ~60% of the +// risk in the instruction (markdown) layer — so string prose matters as much as code. +// Prefer a real scanner (Snyk agent-scan) when present; otherwise a built-in heuristic +// catches the loud, KNOWN-shape cases. A clean pass is NOT a safety certification. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; @@ -70,6 +71,19 @@ export function heuristicScan(text) { return findings; } +/** + * Human-readable verdict. A scan with no critical/high signal is NOT a clearance to + * install — the heuristic only catches KNOWN attack shapes, so the honest verdict is the + * ABSENCE of a signature, never a certification of safety. + * @param {{critical?: boolean, high?: boolean}} sev + */ +export function verdict({ critical, high }) { + if (critical) return "BLOCKED — critical signature detected, do not install"; + if (high) + return "High-severity signal(s) present — treat as unsafe; do not install without review"; + return "No critical signature detected — this is NOT a safety certification. Review the source, permissions, package provenance, and network behaviour before installing."; +} + /** Scan a path (SKILL.md/.mcp.json) or raw text. Real scanner if available, else heuristic. */ export function scan(target) { const isPath = typeof target === "string" && existsSync(target); @@ -87,9 +101,12 @@ export function scan(target) { return { ok: !critical, critical, + high: false, + safe: false, // never certify safety from an absence of findings scanner: "snyk-agent-scan", findings: [], raw: out, + verdict: verdict({ critical, high: false }), }; } catch (err) { if (process.env.FORGE_DEBUG === "1") @@ -101,5 +118,17 @@ export function scan(target) { const findings = heuristicScan(content); const critical = findings.some((f) => f.sev === "critical"); - return { ok: !critical, critical, scanner: "heuristic", findings }; + const high = findings.some((f) => f.sev === "high"); + // `ok` gates install/exit-code on the blocking (critical) tier only, unchanged. `safe` + // is the stricter, honest reading: no critical AND no high — but even `safe` is only the + // absence of a known signature, never a certification (see `verdict`). + return { + ok: !critical, + critical, + high, + safe: !critical && !high, + scanner: "heuristic", + findings, + verdict: verdict({ critical, high }), + }; } diff --git a/test/skillgate.test.js b/test/skillgate.test.js index df04a57..15b48df 100644 --- a/test/skillgate.test.js +++ b/test/skillgate.test.js @@ -39,3 +39,25 @@ test("scan passes a clean SKILL.md", () => { ); assert.equal(scan(p).ok, true); }); + +test("scan verdict never certifies safety — a clean pass reads as 'not a certification'", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); + const p = join(dir, "SKILL.md"); + writeFileSync(p, "---\nname: nice\n---\n# nice\nReads files and summarizes.\n"); + const r = scan(p); + assert.equal(r.safe, true); + assert.match(r.verdict, /NOT a safety certification/); + assert.doesNotMatch(r.verdict, /\bok to install\b|safe to install/i); +}); + +test("scan: a HIGH-severity finding is not critical but is NOT reported as safe", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); + const p = join(dir, "SKILL.md"); + writeFileSync(p, "---\nname: risky\n---\n# risky\nrm -rf ~/data\n"); + const r = scan(p); + assert.equal(r.critical, false); + assert.equal(r.high, true); + assert.equal(r.ok, true); // exit-code semantics unchanged: only critical blocks + assert.equal(r.safe, false); // …but the summary must not read as safe + assert.match(r.verdict, /do not install without review/i); +}); From 5e332192bda535c2649e56be008be3cbd6e921c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:54:17 +0000 Subject: [PATCH 03/10] fix(core): inventory-aware atlas freshness, evidence-aware verify verdicts, resolvable ledger refs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- src/atlas.js | 20 +++++- src/ledger.js | 61 +++++++++++++++--- src/ledger_store.js | 76 +++++++++++++++++++--- src/substrate.js | 76 ++++++++++++++++++---- src/verify.js | 94 ++++++++++++++++++++++----- test/atlas.test.js | 10 +++ test/ledger.test.js | 129 ++++++++++++++++++++++++++++++++------ test/ledger_store.test.js | 72 +++++++++++++++++++-- test/verify.test.js | 56 +++++++++++++++-- 9 files changed, 519 insertions(+), 75 deletions(-) diff --git a/src/atlas.js b/src/atlas.js index 6cf4a30..a4b6498 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -528,17 +528,31 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { return atlas; } -/** True if any tracked file's current content hash differs from the atlas (or a file vanished). */ +/** + * True if the atlas no longer reflects the repo: a tracked file changed or vanished, + * OR the current eligible-file inventory differs from the indexed one (a brand-new or + * removed eligible file). Inventory drift is invisible to a fileHashes-only scan — the + * new file isn't in the map — so it's re-walked here with build()'s OWN walk/eligibility + * (never a second extension list). Skipped when the graph was capped (files were dropped, + * so a size diff is expected, not staleness). + */ export function isStale(root, atlas) { if (!atlas?.fileHashes) return true; - for (const [rel, h] of Object.entries(atlas.fileHashes)) { + const indexed = new Set(Object.keys(atlas.fileHashes)); + for (const rel of indexed) { let text; try { text = readFileSync(join(root, rel), "utf8"); } catch { return true; // a tracked file was deleted } - if (hash(text) !== h) return true; + if (hash(text) !== atlas.fileHashes[rel]) return true; // a tracked file changed + } + if (!atlas.capped) { + const current = []; + walk(root, current, 20000); + if (current.length !== indexed.size) return true; // a file was added or removed + for (const p of current) if (!indexed.has(relative(root, p))) return true; } return false; } diff --git a/src/ledger.js b/src/ledger.js index da92e95..ef48eee 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -140,21 +140,65 @@ export function mintClaim({ kind, body, scope = {}, provenance = {}, t = 0 }) { }; } +// Typed evidence refs are `:`. Only these types are recognized; anything +// else (or a ref with no `type:` prefix) is treated as an untyped/legacy ref and accepted +// unchanged for back-compat. `git:` is the one type forge can cheaply AND soundly resolve — +// the object must exist in THIS repo — so it is ALWAYS resolved when a resolver is supplied; +// the rest are format-checked (a non-empty value) since resolving them is not cheap/sound. +export const REF_TYPES = new Set(["git", "file", "test", "ci", "human"]); + +/** Parse a typed ref into {type, value}, or null for an untyped/legacy ref. */ +export function parseRef(ref) { + const m = /^([a-z]+):(.*)$/.exec(String(ref ?? "")); + if (!m || !REF_TYPES.has(m[1])) return null; + return { type: m[1], value: m[2] }; +} + +/** + * Validate an evidence ref. Untyped/legacy → accepted. Typed-but-empty → rejected. + * `git:` → resolved through the injected `resolveGit(sha)` predicate (execFileSync lives in + * the impure store, keeping this module pure); a git ref that does not resolve is rejected. + * @param {string} ref + * @param {{resolveGit?: (sha:string)=>boolean}} [opts] + * @returns {{ok: boolean, reason?: string}} + */ +export function validateRef(ref, { resolveGit } = {}) { + const parsed = parseRef(ref); + if (!parsed) return { ok: true }; // untyped/legacy — kept for back-compat + if (!parsed.value) return { ok: false, reason: `evidence ref "${ref}" is typed but empty` }; + if (parsed.type === "git" && typeof resolveGit === "function" && !resolveGit(parsed.value)) + return { + ok: false, + reason: `evidence ref "${ref}" is unresolvable (no such git object)`, + }; + return { ok: true }; +} + /** * Build an evidence outcome. Evidence without a verifiable ref (commit SHA, test-run - * id, episode id, CI URL) is rejected — "the model said so" is not evidence. The - * oracle's table weight is recorded for audit, but val() re-reads the table. - * @param {{oracle:string, result:"confirm"|"contradict", ref:string, author?:string, t?:number}} f + * id, episode id, CI URL) is rejected — "the model said so" is not evidence. A typed ref + * (`git:`/`file:`/`test:`/`ci:`/`human:`) is validated; a `git:` ref is resolved when a + * `resolveGit` predicate is supplied. The oracle's table weight is recorded for audit, + * but val() re-reads the table. + * @param {{oracle:string, result:"confirm"|"contradict", ref:string, author?:string, t?:number, resolveGit?:(sha:string)=>boolean}} f * @returns {{ok:true, outcome:any}|{ok:false, reason:string}} */ -export function outcomeRecord({ oracle, result, ref, author = "", t = 0 }) { +export function outcomeRecord({ oracle, result, ref, author = "", t = 0, resolveGit }) { const o = ORACLES[oracle]; if (!o) return { ok: false, reason: `unknown oracle: ${oracle}` }; if (result !== "confirm" && result !== "contradict") - return { ok: false, reason: `result must be confirm|contradict, got: ${result}` }; + return { + ok: false, + reason: `result must be confirm|contradict, got: ${result}`, + }; if (!ref || typeof ref !== "string") return { ok: false, reason: "evidence requires a verifiable ref" }; - return { ok: true, outcome: sealRecord({ author, oracle, ref, result, t, w: o.w }) }; + const v = validateRef(ref, { resolveGit }); + if (!v.ok) return { ok: false, reason: v.reason ?? "invalid evidence ref" }; + return { + ok: true, + outcome: sealRecord({ author, oracle, ref, result, t, w: o.w }), + }; } /** An evidence record val() will count: known oracle, valid result, a ref, a hash. */ @@ -375,7 +419,10 @@ export function retrieve( const boundSim = sim ? (_qs, c) => sim(q, c) : undefined; return claims .filter((c) => !c.tombstone && !isDormant(c, nowDay)) - .map((c) => ({ claim: c, score: score(qs, c, { nowDay, weights, sim: boundSim }) })) + .map((c) => ({ + claim: c, + score: score(qs, c, { nowDay, weights, sim: boundSim }), + })) .sort((a, b) => b.score - a.score || (a.claim.id < b.claim.id ? -1 : 1)) .slice(0, budget); } diff --git a/src/ledger_store.js b/src/ledger_store.js index 96160b3..5d4335a 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -4,6 +4,7 @@ // claim — evidence, provenance, tombstones — that git union-merges without conflicts // (`forge init` emits the .gitattributes rule). Everything author- or time-varying is // a log line; nothing on disk is ever edited in place. +import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, @@ -13,7 +14,7 @@ import { renameSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { authorTrust, canonicalize, @@ -28,6 +29,7 @@ import { sealRecord, sortRecords, val, + validateRef, validOutcome, } from "./ledger.js"; @@ -42,6 +44,25 @@ export const GITATTRIBUTES_RULE = [ ".forge/ledger/*/*.log merge=union", ].join("\n"); +// A ledger lives at /.forge/ledger, so the repo root is two levels up — the cwd a +// `git:` evidence ref must resolve against. +const repoRootOf = (dir) => dirname(dirname(dir)); + +// `git:` ref resolver: the object must exist in THIS repo (`git cat-file -e `), a non-zero +// exit → unresolvable. Only ever invoked by validateRef for git-typed refs, so non-git refs +// (and non-git repos) never spawn git. +const gitResolver = (root) => (sha) => { + try { + execFileSync("git", ["cat-file", "-e", sha], { + cwd: root, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +}; + const LOGS = ["evidence", "provenance", "tombstones"]; const claimPath = (dir, id) => join(dir, "claims", id.slice(0, 2), `${id}.json`); const logPath = (dir, log, id) => join(dir, log, `${id}.log`); @@ -100,7 +121,12 @@ function* walkClaimFiles(dir) { const parsed = readJson(path); // Verify the address: a tampered/corrupt claim is surfaced as claim:null. const valid = parsed && claimId(parsed.kind, parsed.body, parsed.scope) === id; - yield { id, path, raw: readFileSync(path, "utf8"), claim: valid ? { ...parsed, id } : null }; + yield { + id, + path, + raw: readFileSync(path, "utf8"), + claim: valid ? { ...parsed, id } : null, + }; } } } @@ -114,10 +140,16 @@ function* walkClaimFiles(dir) { */ export function putClaim(dir, claim) { if (!claim?.id || claim.id !== claimId(claim.kind, claim.body, claim.scope)) - return { ok: false, reason: "claim id does not match canonical content hash" }; + return { + ok: false, + reason: "claim id does not match canonical content hash", + }; const text = claimBytes(claim); if (hasSecret(text)) - return { ok: false, reason: "refused: claim looks like it contains a secret/credential" }; + return { + ok: false, + reason: "refused: claim looks like it contains a secret/credential", + }; const path = claimPath(dir, claim.id); const already = existsSync(path); const healthy = already && readJson(path) !== null && readFileSync(path, "utf8") === text; @@ -129,9 +161,15 @@ export function putClaim(dir, claim) { return { ok: true, id: claim.id, existed: already && healthy }; } -/** Append one evidence outcome (deduped by its content hash — append is idempotent). */ +/** Append one evidence outcome (deduped by its content hash — append is idempotent). A typed, + * unresolvable ref (e.g. a `git:` sha absent from this repo) is REJECTED here, before it can + * reach val() and buy confidence. */ export function appendEvidence(dir, id, outcome) { if (!validOutcome(outcome)) return { ok: false, reason: "invalid outcome (use outcomeRecord)" }; + const v = validateRef(outcome.ref, { + resolveGit: gitResolver(repoRootOf(dir)), + }); + if (!v.ok) return { ok: false, reason: v.reason ?? "unresolvable evidence ref" }; return appendRecord(dir, "evidence", id, outcome); } @@ -166,10 +204,23 @@ export function ratify(dir, idPrefix, { author = "", t = 0 } = {}) { provenance: { agent: "dash", author }, t, }); - if (!minted.ok) return { ok: false, reason: "reason" in minted ? minted.reason : "mint failed" }; + if (!minted.ok) + return { + ok: false, + reason: "reason" in minted ? minted.reason : "mint failed", + }; const put = putClaim(dir, minted.claim); - if (!put.ok) return { ok: false, reason: put.reason ?? "could not persist the decision claim" }; - return { ok: true, decisionId: minted.claim.id, ratifies: target.id, existed: put.existed }; + if (!put.ok) + return { + ok: false, + reason: put.reason ?? "could not persist the decision claim", + }; + return { + ok: true, + decisionId: minted.claim.id, + ratifies: target.id, + existed: put.existed, + }; } /** Load the full ledger state {claims, evidence, provenance, tombstones}. */ @@ -289,6 +340,7 @@ export function verify(dir) { let claims = 0; let outcomes = 0; const ids = []; + const resolveGit = gitResolver(repoRootOf(dir)); for (const { id, raw, claim } of walkClaimFiles(dir)) { if (!claim) issues.push(`claim ${id}: unparseable or id mismatch`); else { @@ -320,7 +372,13 @@ export function verify(dir) { if (!validOutcome(o)) issues.push(`${where}: invalid outcome (oracle/result/ref)`); else if (o.w !== ORACLES[o.oracle].w) issues.push(`${where}: recorded weight ${o.w} != oracle table ${ORACLES[o.oracle].w}`); - else outcomes++; + else { + // Typed, unresolvable refs (e.g. a `git:` sha absent from this repo) are named + // so CI catches evidence that can never be re-derived. + const v = validateRef(o.ref, { resolveGit }); + if (!v.ok) issues.push(`${where}: ${v.reason ?? "unresolvable evidence ref"}`); + else outcomes++; + } } if (hasSecret(line)) issues.push(`${where}: secret-like content`); } diff --git a/src/substrate.js b/src/substrate.js index 7e5f7e5..4418f30 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -7,7 +7,12 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildRunner, llmEnabled } from "./adjudicate.js"; import { goalDrift } from "./anchor.js"; -import { build as buildAtlas, impact as impactGraph, load as loadAtlas } from "./atlas.js"; +import { + isStale as atlasIsStale, + build as buildAtlas, + impact as impactGraph, + load as loadAtlas, +} from "./atlas.js"; import { assemble as assembleContext } from "./context.js"; import { matchingLessons } from "./cortex.js"; import { recordGate } from "./cost_report.js"; @@ -138,7 +143,10 @@ function makeImpactVerify(root) { * @param {number} [opts.timeoutMs] */ export function predictImpact(root, target, { threshold = 0.1, llm, model, timeoutMs } = {}) { - const atlas = loadAtlas(root) || buildAtlas({ root }); + // Rebuild when the cached atlas is stale (or missing) — a stale graph misses brand-new + // files/edges and would under-report impact. The incremental build only re-parses what changed. + const cached = loadAtlas(root); + const atlas = cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); const useLLM = llmEnabled({ llm }); return impactGraph(atlas, target, { threshold, @@ -198,7 +206,11 @@ export function substrateCheck( signalFloor: spec?.llm?.signalFloor, }; const entities = referencedEntities(text); - const preflight = preflightRepo(root, text, { askThreshold, allowBuild, ...llmOpts }); + const preflight = preflightRepo(root, text, { + askThreshold, + allowBuild, + ...llmOpts, + }); // P8 gate metering: one metrics line per explicit gate decision (halt = spend avoided). // Same write contract as reuseQuery vs reusePeek below — the ambient hook path // (allowBuild:false) never appends. Best-effort: measurement must never block the gate. @@ -215,8 +227,21 @@ export function substrateCheck( // itself best-effort (try/catch inside), so measurement can never block routing. if (allowBuild) meterRoute(root, text, route); // allowBuild:false (ambient hooks) uses the atlas only if one is already cached — never - // builds or writes .forge/atlas.json from a hook. Impact is then best-effort. - const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null); + // builds or writes .forge/atlas.json from a hook. When the cached atlas is missing or STALE + // and we can't rebuild (ambient), impact is not trustworthy: flag it (atlasFresh:false) so + // callers surface "impact unavailable" instead of presenting 0 impacted files as fact. + let atlas = loadAtlas(root); + let atlasFresh = true; + if (atlas) { + if (atlasIsStale(root, atlas)) { + if (allowBuild) atlas = buildAtlas({ root }); + else atlasFresh = false; // stale cache, can't rebuild from a hook + } + } else if (allowBuild) { + atlas = buildAtlas({ root }); + } else { + atlasFresh = false; // no atlas and can't build + } const impactTargets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8); const impactRun = useLLM ? buildRunner({ model, timeoutMs }) : undefined; const impactVerify = makeImpactVerify(root); @@ -245,7 +270,11 @@ export function substrateCheck( return { tier: r.tier, artifact: r.artifact - ? { id: r.artifact.id, path: r.artifact.body.code?.path, form: r.artifact.body.form } + ? { + id: r.artifact.id, + path: r.artifact.body.code?.path, + form: r.artifact.body.form, + } : undefined, jaccard: r.jaccard, }; @@ -290,7 +319,16 @@ export function substrateCheck( missing: context.missing, questions: context.questions, }, - impact: { targets: impactTargets, reports: impacts, impactedFiles, predictedTests }, + impact: { + targets: impactTargets, + reports: impacts, + impactedFiles, + predictedTests, + // Truthful freshness: false when the atlas is missing/stale and couldn't be rebuilt. + // Consumers must not present impactedFiles as trustworthy when this is false. + atlasFresh, + ...(atlasFresh ? {} : { note: "impact unavailable: atlas missing or stale" }), + }, scope, memory: { matchingLessons: lessons.length, @@ -306,7 +344,10 @@ export function substrateCheck( minimality: (() => { const pre = minimalityWarnings(text, route, preflight); const lean = allowBuild ? leanRepo(root, text) : { warnings: [], footprint: null }; - return { warnings: [...pre, ...lean.warnings], footprint: lean.footprint }; + return { + warnings: [...pre, ...lean.warnings], + footprint: lean.footprint, + }; })(), // M4 goal-anchoring: re-read the stated goal against files already changed this session. // Quiet pre-action (clean tree → no drift); speaks mid-session when work wandered off-goal. @@ -427,10 +468,14 @@ export function renderSubstrate(result) { ); for (const q of result.context.questions ?? []) lines.push(` ? ${q}`); } - lines.push("", ` impact: ${result.impact.impactedFiles.length} file(s) predicted`); - for (const file of result.impact.impactedFiles.slice(0, 10)) lines.push(` - ${file}`); - if (result.impact.impactedFiles.length > 10) - lines.push(` … ${result.impact.impactedFiles.length - 10} more`); + if (result.impact.atlasFresh === false) { + lines.push("", " impact: unavailable — atlas missing or stale (predictions not trustworthy)"); + } else { + lines.push("", ` impact: ${result.impact.impactedFiles.length} file(s) predicted`); + for (const file of result.impact.impactedFiles.slice(0, 10)) lines.push(` - ${file}`); + if (result.impact.impactedFiles.length > 10) + lines.push(` … ${result.impact.impactedFiles.length - 10} more`); + } const tests = result.impact.predictedTests || []; if (tests.length) { lines.push("", ` likely-affected tests (${tests.length}) — run these first:`); @@ -460,6 +505,7 @@ export function substrateContext(result) { const worthSaying = result.assumption.shouldAsk || result.impact.impactedFiles.length > 0 || + result.impact.atlasFresh === false || result.minimality.warnings.length > 0 || result.goalAnchor?.drift || ["opus", "fable"].includes(result.route.key); @@ -474,7 +520,11 @@ export function substrateContext(result) { lines.push( `- Suggested model: ${result.route.model.name} (${result.route.tier}); escalate only on a verifier failure.`, ); - if (result.impact.impactedFiles.length) { + if (result.impact.atlasFresh === false) { + lines.push( + "- Impact unavailable: atlas missing or stale — predicted blast radius is not trustworthy (rebuild the atlas to get it).", + ); + } else if (result.impact.impactedFiles.length) { const files = result.impact.impactedFiles; lines.push( `- Predicted blast radius (${files.length}): ${files.slice(0, 8).join(", ")}${files.length > 8 ? " …" : ""}. Review these before editing.`, diff --git a/src/verify.js b/src/verify.js index de7c0d0..27a3313 100644 --- a/src/verify.js +++ b/src/verify.js @@ -4,9 +4,10 @@ // (a cheap, zero-LLM hallucination signal). It emits a provenance stamp so a // reviewer reads WHAT was checked, not the authoring transcript. import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { build as buildAtlas, has, isStale, load as loadAtlas } from "./atlas.js"; +import { detectStack } from "./stack.js"; // Shared call-site extractor — one source of truth with atlas.js (they used to duplicate this). export { extractCalledSymbols } from "./extract.js"; @@ -28,35 +29,70 @@ function git(args, cwd) { } } -// Run the project's own tests (JS or Python). The gate trusts these, not a benchmark. Bounded by -// a timeout (FORGE_VERIFY_TIMEOUT_MS, default 10 min) so a hanging test can't hang the gate — a -// timeout is reported as an honest "did not complete", never as a pass. +// Run the project's OWN tests, driven off the stack detector (never a benchmark). The verdict +// is an honest four-state `status`: +// PASS — a real verifier ran and passed +// FAIL — a real verifier ran and failed +// NOT_CONFIGURED — no test runner exists for this repo (nothing ran → NEVER ok) +// INCOMPLETE — a runner was expected but couldn't complete (timeout, or no concrete +// executor for the detected command) +// `ran`/`passed` are kept for back-compat (consensus.js reads them). Bounded by a timeout +// (FORGE_VERIFY_TIMEOUT_MS, default 10 min) so a hanging test can't hang the gate. +/** + * @typedef {object} VerifyTests + * @property {boolean} ran + * @property {boolean} [passed] + * @property {"PASS"|"FAIL"|"INCOMPLETE"|"NOT_CONFIGURED"} status + * @property {string} [runner] + * @property {boolean} [timedOut] + * @property {string[]} [detected] + * @property {string} [output] + */ +/** + * @param {string} cwd + * @returns {VerifyTests} + */ function runTests(cwd) { const timeout = Number(process.env.FORGE_VERIFY_TIMEOUT_MS) || 600000; const run = (cmd, args) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: "pipe", timeout }); + // Detect the repo's real test commands (no test script → none → NOT_CONFIGURED, not a + // forced npm-test failure). + let detected = []; + try { + detected = detectStack(cwd).testCommands; + } catch {} + if (!detected.length) return { ran: false, status: "NOT_CONFIGURED" }; + // Concrete runners forge can actually execute. npm test is only real if a package.json exists. + const npm = + detected.some((c) => /(^|\s)(npm|pnpm|yarn|bun)\s+test\b/.test(c)) && + existsSync(join(cwd, "package.json")); + const pytest = detected.some((c) => /\bpytest\b/.test(c)); try { - if (existsSync(join(cwd, "package.json"))) { + if (npm) { run("npm", ["test"]); - return { ran: true, passed: true, runner: "npm test" }; + return { ran: true, passed: true, status: "PASS", runner: "npm test" }; } - if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "pytest.ini"))) { + if (pytest) { run("pytest", ["-q"]); - return { ran: true, passed: true, runner: "pytest" }; + return { ran: true, passed: true, status: "PASS", runner: "pytest" }; } - return { ran: false }; + // A runner was detected but forge has no concrete executor for it — expected, didn't complete. + return { ran: false, status: "INCOMPLETE", detected }; } catch (e) { if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") { return { ran: true, passed: false, timedOut: true, + status: "INCOMPLETE", output: `test run exceeded ${timeout}ms`, }; } return { ran: true, passed: false, + status: "FAIL", output: String(e.stdout || e.message || "").slice(-600), }; } @@ -84,16 +120,41 @@ export function checkpointCadence({ pErr, tokensPerStep, costPerToken = 1, check return Math.min(50, Math.max(1, n)); // zero risk → Infinity → the 50-step ceiling } +/** + * Independent verification pass over the working change. + * @param {{targetRoot?: string, base?: string}} [opts] + * @returns {{ok: boolean, provenance: object, unknown: string[], tests: VerifyTests, + * changedFiles: string[], added: string}} + * `ok` is `tests.status === "PASS"` — TRUE only when a real verifier ran and passed, NEVER + * when nothing ran. `changedFiles` includes untracked files; `added` includes their contents. + */ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) { const diff = git(["diff", "--unified=0", base], targetRoot) || git(["diff", "--unified=0", "--cached"], targetRoot); - const added = diff + const diffAdded = diff .split("\n") .filter((l) => l.startsWith("+") && !l.startsWith("+++")) .map((l) => l.slice(1)) .join("\n"); - const changedFiles = git(["diff", "--name-only", base], targetRoot).split("\n").filter(Boolean); + // Untracked (new, not-yet-added) files are part of the change too — a brand-new source file + // and its call sites would be invisible to `git diff`. Fold their paths into changedFiles and + // their contents into `added` so provenance and the hallucination check both see them. + const untracked = git(["ls-files", "--others", "--exclude-standard"], targetRoot) + .split("\n") + .filter(Boolean); + const changedFiles = [ + ...new Set([ + ...git(["diff", "--name-only", base], targetRoot).split("\n").filter(Boolean), + ...untracked, + ]), + ]; + let added = diffAdded; + for (const f of untracked) { + try { + added += `\n${readFileSync(join(targetRoot, f), "utf8")}`; + } catch {} + } // Verify runs AFTER edits — a cached, stale atlas would miss newly-added-but-undefined symbols // (false negatives) or flag just-defined ones (false positives). Rebuild when stale; the @@ -109,6 +170,7 @@ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) { const provenance = { base, changedFiles, + untracked, tests, symbolsChecked: symbols.length, unknownSymbols: unknown, @@ -116,9 +178,11 @@ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) { mkdirSync(join(targetRoot, ".forge"), { recursive: true }); writeFileSync(join(targetRoot, ".forge", "provenance.json"), JSON.stringify(provenance, null, 2)); - // Hard gate = the project's own tests. Unknown symbols are advisory (heuristic). - const ok = tests.ran ? tests.passed === true : true; - // `added` (the raw added diff lines) rides along for the deep lenses (consensus.js: - // secrets + reviewer read the same bytes this pass already parsed — one diff, one truth). + // Hard gate = the project's own tests, keyed off the honest four-state verdict. `ok` is TRUE + // only when a real verifier PASSED — never when nothing ran (NOT_CONFIGURED/INCOMPLETE). + // Unknown symbols stay advisory (heuristic). + const ok = tests.status === "PASS"; + // `added` (added diff lines + untracked file bodies) rides along for the deep lenses + // (consensus.js: secrets + reviewer read the same bytes this pass already parsed). return { ok, provenance, unknown, tests, changedFiles, added }; } diff --git a/test/atlas.test.js b/test/atlas.test.js index ffe9170..5abc8f3 100644 --- a/test/atlas.test.js +++ b/test/atlas.test.js @@ -81,6 +81,16 @@ test("isStale detects an edit and a deletion", () => { assert.equal(isStale(root, atlas), true, "content change is detected"); }); +test("isStale detects a brand-new eligible file (inventory drift, not just edits)", () => { + const root = fixture(); + const atlas = build({ root }); + assert.equal(isStale(root, atlas), false, "fresh right after build"); + // A new eligible file isn't in fileHashes, so a hash-only scan can't see it — the + // inventory walk must flag it as stale. + writeFileSync(join(root, "c.js"), "export function brandNew(){ return 1 }\n"); + assert.equal(isStale(root, atlas), true, "new eligible file makes the atlas stale"); +}); + test("impact (llm on): a proposed edge survives only if real + grep-verified", () => { const root = fixture(); const atlas = build({ root }); diff --git a/test/ledger.test.js b/test/ledger.test.js index 5bf858d..6a2f214 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -60,14 +60,23 @@ test("claimId: provenance and evidence never affect the address (teammates conve }); test("mintClaim: normalizes non-JSON values so different Dates can't collide on one id", () => { - const d1 = mintClaim({ kind: "fact", body: { name: "d", text: new Date(0) } }); - const d2 = mintClaim({ kind: "fact", body: { name: "d", text: new Date(86400000) } }); + const d1 = mintClaim({ + kind: "fact", + body: { name: "d", text: new Date(0) }, + }); + const d2 = mintClaim({ + kind: "fact", + body: { name: "d", text: new Date(86400000) }, + }); assert.ok(d1.ok && d2.ok); assert.notEqual(d1.claim.id, d2.claim.id, "Dates serialize to ISO strings, not {}"); }); test("mintClaim: refuses secrets, unknown kinds, and non-object bodies", () => { - const s = mintClaim({ kind: "fact", body: { name: "k", text: fakeAnthropic() } }); + const s = mintClaim({ + kind: "fact", + body: { name: "k", text: fakeAnthropic() }, + }); assert.equal(s.ok, false); assert.match(s.reason, /secret/); assert.equal(mintClaim({ kind: "nope", body: {} }).ok, false); @@ -78,16 +87,50 @@ test("outcomeRecord: requires a known oracle, a valid result, and a verifiable r assert.equal(outcomeRecord({ oracle: "vibes", result: "confirm", ref: "r" }).ok, false); assert.equal(outcomeRecord({ oracle: "test.run", result: "maybe", ref: "r" }).ok, false); assert.equal(outcomeRecord({ oracle: "test.run", result: "confirm", ref: "" }).ok, false); - const ok = outcomeRecord({ oracle: "test.run", result: "confirm", ref: "run:1", t: 3 }); + const ok = outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: "run:1", + t: 3, + }); assert.ok(ok.ok); assert.match(ok.outcome.h, /^[0-9a-f]{64}$/); assert.equal(ok.outcome.w, 0.8); }); +test("outcomeRecord: typed git ref is resolved; unresolvable is rejected, untyped is accepted", () => { + const resolveGit = (sha) => sha === "cafebabe"; // pretend only this object exists + // a git: ref whose object resolves is accepted + const good = outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: "git:cafebabe", + resolveGit, + }); + assert.ok(good.ok, "resolvable git ref accepted"); + // a git: ref that does not resolve is rejected with a reason + const bad = outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: "git:deadbeef", + resolveGit, + }); + assert.equal(bad.ok, false, "unresolvable git ref rejected"); + assert.match(bad.reason, /unresolvable/); + // a typed-but-empty ref is rejected on format + assert.equal(outcomeRecord({ oracle: "test.run", result: "confirm", ref: "git:" }).ok, false); + // an untyped/legacy ref is accepted unchanged (back-compat) + assert.ok(outcomeRecord({ oracle: "test.run", result: "confirm", ref: "run:1" }).ok); +}); + // --- confidence: the decayed Beta posterior ----------------------------------------- const mkClaim = (evidence = []) => { - const m = mintClaim({ kind: "fact", body: { name: "f", text: "body" }, t: 0 }); + const m = mintClaim({ + kind: "fact", + body: { name: "f", text: "body" }, + t: 0, + }); return { ...m.claim, evidence }; }; const ev = (result, t, oracle = "test.run") => @@ -104,7 +147,13 @@ test("val: monotone in confirmations (more independent evidence is never worse)" for (let n = 1; n <= 5; n++) { const outs = Array.from( { length: n }, - (_, i) => outcomeRecord({ oracle: "ci.run", result: "confirm", ref: `r:${i}`, t: 0 }).outcome, + (_, i) => + outcomeRecord({ + oracle: "ci.run", + result: "confirm", + ref: `r:${i}`, + t: 0, + }).outcome, ); const v = val(mkClaim(outs), 0); assert.ok(v > prev, `val(${n} confirms)=${v} must exceed ${prev}`); @@ -200,7 +249,10 @@ test("claimText: every retrievable kind exposes its human text (not canonical JS }, }).claim; assert.equal(claimText(lesson), "w c k s"); - const fact = mintClaim({ kind: "fact", body: { name: "n", text: "t" } }).claim; + const fact = mintClaim({ + kind: "fact", + body: { name: "n", text: "t" }, + }).claim; assert.equal(claimText(fact), "n t"); const diag = mintClaim({ kind: "diagnosis", @@ -217,10 +269,16 @@ test("clusters: near-duplicates group, distinct claims stay apart", () => { kind: "fact", body: { name: "note", text: `${long} is impossible` }, }).claim; - const c2 = mintClaim({ kind: "fact", body: { name: "note", text: `${long} is unlikely` } }).claim; + const c2 = mintClaim({ + kind: "fact", + body: { name: "note", text: `${long} is unlikely` }, + }).claim; const c3 = mintClaim({ kind: "fact", - body: { name: "note", text: "the deploy pipeline needs the staging flag set first" }, + body: { + name: "note", + text: "the deploy pipeline needs the staging flag set first", + }, }).claim; const groups = clusters([c1, c2, c3], { tau: 0.5 }); assert.equal(groups.length, 1); @@ -234,7 +292,10 @@ test("score: outcome-confirmed claims outrank merely-similar unconfirmed ones", const confirmed = { ...mintClaim({ kind: "fact", - body: { name: "a", text: "check callers before renaming a shared symbol" }, + body: { + name: "a", + text: "check callers before renaming a shared symbol", + }, t: 0, }).claim, evidence: [ev("confirm", 0), ev("confirm", 0, "human.accept")], @@ -249,14 +310,20 @@ test("score: outcome-confirmed claims outrank merely-similar unconfirmed ones", test("retrieve: excludes tombstoned and dormant claims, caps at budget", () => { const alive = mkClaim([ev("confirm", 0)]); - const dead = { ...mkClaim(), tombstone: { reason: "retracted", t: 0, author: "" } }; + const dead = { + ...mkClaim(), + tombstone: { reason: "retracted", t: 0, author: "" }, + }; const dormant = mkClaim([ ev("contradict", 0, "human.revert"), ev("contradict", 0, "human.accept"), ev("contradict", 0, "test.run"), ev("contradict", 0, "ci.run"), ]); - const out = retrieve("body", [alive, dead, dormant], { nowDay: 0, budget: 10 }); + const out = retrieve("body", [alive, dead, dormant], { + nowDay: 0, + budget: 10, + }); assert.deepEqual( out.map((r) => r.claim.id), [alive.id], @@ -276,8 +343,16 @@ const state = (claims, evidence = {}, tombstones = {}, provenance = {}) => ({ }); test("mergeStates: commutative, associative, idempotent — replicas converge in any order", () => { - const c1 = mintClaim({ kind: "fact", body: { name: "1", text: "one" }, t: 1 }).claim; - const c2 = mintClaim({ kind: "fact", body: { name: "2", text: "two" }, t: 2 }).claim; + const c1 = mintClaim({ + kind: "fact", + body: { name: "1", text: "one" }, + t: 1, + }).claim; + const c2 = mintClaim({ + kind: "fact", + body: { name: "2", text: "two" }, + t: 2, + }).claim; const c3 = mintClaim({ kind: "lesson", body: { whatWentWrong: "w", correctedBehavior: "c", trigger: {} }, @@ -307,7 +382,11 @@ test("mergeStates: commutative, associative, idempotent — replicas converge in }); test("mergeStates: concurrent retractions both survive; the view picks one deterministically", () => { - const c = mintClaim({ kind: "fact", body: { name: "x", text: "y" }, t: 0 }).claim; + const c = mintClaim({ + kind: "fact", + body: { name: "x", text: "y" }, + t: 0, + }).claim; const sA = state([c], {}, { [c.id]: [tomb("wrong", 3, "alice")] }); const sB = state([c], {}, { [c.id]: [tomb("stale", 2, "bob")] }); const ab = mergeStates(sA, sB); @@ -322,7 +401,11 @@ test("mergeStates: concurrent retractions both survive; the view picks one deter }); test("mergeStates: evidence unions dedupe by hash; val is identical after any merge order", () => { - const c = mintClaim({ kind: "fact", body: { name: "x", text: "y" }, t: 0 }).claim; + const c = mintClaim({ + kind: "fact", + body: { name: "x", text: "y" }, + t: 0, + }).claim; const e1 = ev("confirm", 1); const e2 = ev("contradict", 2); const sA = state([c], { [c.id]: [e1] }); @@ -355,7 +438,10 @@ test("authorTrust: bootstrap 1.0, degrades with contradicted claims, floors at 0 ...mint("b", "alice"), evidence: [out("confirm", "r1", "ci"), out("confirm", "r2", "ci")], }; - const selfServing = { ...mint("c", "bob"), evidence: [out("confirm", "r3", "bob")] }; + const selfServing = { + ...mint("c", "bob"), + evidence: [out("confirm", "r3", "bob")], + }; const wrongOften = { ...mint("d", "carol"), evidence: Array.from({ length: 20 }, (_, i) => out("contradict", `r${i}`, "ci")), @@ -370,8 +456,13 @@ test("authorTrust: bootstrap 1.0, degrades with contradicted claims, floors at 0 test("val with trust: a distrusted author's evidence moves confidence less", () => { const claim = mkClaim([ - outcomeRecord({ oracle: "test.run", result: "confirm", ref: "r", author: "carol", t: 0 }) - .outcome, + outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: "r", + author: "carol", + t: 0, + }).outcome, ]); const flat = val(claim, 0); const weighted = val(claim, 0, { trust: { carol: 0.5 } }); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index 76f6de2..3d4e11b 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,6 +25,7 @@ import { ratify, readEvidence, reindex, + repoLedger, stats, tombstone, verify, @@ -31,7 +33,12 @@ import { const tmp = () => mkdtempSync(join(tmpdir(), "forge-ledger-")); const fact = (name, text, t = 0) => - mintClaim({ kind: "fact", body: { name, text }, provenance: { author: "tester" }, t }).claim; + mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author: "tester" }, + t, + }).claim; const ev = (result, ref, t = 0) => outcomeRecord({ oracle: "test.run", result, ref, t }).outcome; test("putClaim/loadClaims: roundtrip; rewrite of the same claim is a no-op", () => { @@ -104,7 +111,12 @@ test("appendEvidence: appends, dedupes by hash, requires the claim to exist and assert.equal(readEvidence(dir, c.id).length, 1); assert.equal(appendEvidence(dir, "f".repeat(64), o).ok, false, "evidence for a ghost claim"); assert.equal( - appendEvidence(dir, c.id, { oracle: "made.up", result: "confirm", ref: "x", h: "y" }).ok, + appendEvidence(dir, c.id, { + oracle: "made.up", + result: "confirm", + ref: "x", + h: "y", + }).ok, false, "unknown oracle rejected at the store boundary too", ); @@ -146,7 +158,14 @@ test("verify: catches forged evidence — wrong content hash, unknown oracle, in '{"author":"","h":"deadbeef","oracle":"test.run","ref":"x","result":"confirm","t":0,"w":0.8}', // correctly sealed record naming an oracle that doesn't exist JSON.stringify( - sealRecord({ author: "", oracle: "made.up", ref: "x", result: "confirm", t: 0, w: 1 }), + sealRecord({ + author: "", + oracle: "made.up", + ref: "x", + result: "confirm", + t: 0, + w: 1, + }), ), ].join("\n"); writeFileSync(logPath, `${readFileSync(logPath, "utf8")}${forged}\n`); @@ -306,8 +325,13 @@ test("blame: full accountability view — mints, evidence, retractions, per-auth appendEvidence( dir, c.id, - outcomeRecord({ oracle: "ci.run", result: "confirm", ref: "ci:42", author: "bob", t: 2 }) - .outcome, + outcomeRecord({ + oracle: "ci.run", + result: "confirm", + ref: "ci:42", + author: "bob", + t: 2, + }).outcome, ); tombstone(dir, c.id, { reason: "superseded", t: 3, author: "carol" }); const b = blame(dir, c.id.slice(0, 8), 3); @@ -319,3 +343,41 @@ test("blame: full accountability view — mints, evidence, retractions, per-auth assert.ok(b.trust.alice >= 0.5 && b.trust.alice <= 1); assert.equal(blame(dir, "zz", 3), null); }); + +// --------------------------------------------------------------------------- +// P0-10 — resolvable typed evidence refs: a git: ref must resolve in THIS repo. +// --------------------------------------------------------------------------- + +test("appendEvidence: a git: ref is rejected unless the object exists in the repo", () => { + const root = mkdtempSync(join(tmpdir(), "forge-ledger-git-")); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + writeFileSync(join(root, "f.txt"), "hello\n"); + g("add", "-A"); + g("commit", "-m", "init"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + + const dir = repoLedger(root); // /.forge/ledger — repoRootOf() resolves back to root + const c = fact("build", "the git ref is real"); + putClaim(dir, c); + + // real HEAD sha resolves via `git cat-file -e` → accepted + const good = outcomeRecord({ oracle: "ci.run", result: "confirm", ref: `git:${head}` }).outcome; + assert.equal(appendEvidence(dir, c.id, good).ok, true, "resolvable HEAD sha accepted"); + + // a bogus sha does not resolve → rejected before it can affect confidence + const bogus = outcomeRecord({ + oracle: "ci.run", + result: "confirm", + ref: `git:${"0".repeat(40)}`, + }).outcome; + const r = appendEvidence(dir, c.id, bogus); + assert.equal(r.ok, false, "unresolvable git sha rejected"); + assert.match(r.reason, /unresolvable/); + + // verify() also names the unresolvable ref if one slipped in + const store = verify(dir); + assert.equal(store.outcomes, 1, "only the resolvable outcome counts"); +}); diff --git a/test/verify.test.js b/test/verify.test.js index 38a2e2b..5346880 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -1,6 +1,19 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; -import { extractCalledSymbols, findUnknownSymbols } from "../src/verify.js"; +import { extractCalledSymbols, findUnknownSymbols, verify } from "../src/verify.js"; + +const gitRepo = () => { + const root = mkdtempSync(join(tmpdir(), "forge-verify-")); + const g = (...args) => execFileSync("git", args, { cwd: root, stdio: "ignore" }); + g("init"); + g("config", "user.email", "t@t.t"); + g("config", "user.name", "t"); + return root; +}; test("extractCalledSymbols finds call sites, skips methods and builtins", () => { const src = [ @@ -50,15 +63,28 @@ test("checkpointCadence computes n* = ceil(checkCost / (pErr·tokensPerStep·cos assert.equal(checkpointCadence({ pErr: 0.05, tokensPerStep: 200, checkCost: 105 }), 11); // costPerToken scales the at-risk side assert.equal( - checkpointCadence({ pErr: 0.05, tokensPerStep: 200, costPerToken: 2, checkCost: 100 }), + checkpointCadence({ + pErr: 0.05, + tokensPerStep: 200, + costPerToken: 2, + checkCost: 100, + }), 5, ); }); test("checkpointCadence: riskier (cheaper) tiers checkpoint more often", async () => { const { checkpointCadence } = await import("../src/verify.js"); - const haiku = checkpointCadence({ pErr: 0.2, tokensPerStep: 500, checkCost: 400 }); - const opus = checkpointCadence({ pErr: 0.01, tokensPerStep: 500, checkCost: 400 }); + const haiku = checkpointCadence({ + pErr: 0.2, + tokensPerStep: 500, + checkCost: 400, + }); + const opus = checkpointCadence({ + pErr: 0.01, + tokensPerStep: 500, + checkCost: 400, + }); assert.ok(haiku < opus, `higher hazard → smaller n* (${haiku} < ${opus})`); }); @@ -76,3 +102,25 @@ test("checkpointCadence fails safe on degenerate inputs (check every step)", asy assert.equal(checkpointCadence({ pErr: Number.NaN, tokensPerStep: 100, checkCost: 100 }), 1); assert.equal(checkpointCadence({ pErr: 0, tokensPerStep: 100, checkCost: 0 }), 1); }); + +// --------------------------------------------------------------------------- +// P0-09 — evidence-aware verdict: NOT_CONFIGURED (never ok) and untracked provenance. +// --------------------------------------------------------------------------- + +test("verify: a repo with no test runner is NOT_CONFIGURED and NOT ok (nothing ran)", () => { + const root = gitRepo(); + writeFileSync(join(root, "a.js"), "export function f(){ return 1 }\n"); + const r = verify({ targetRoot: root }); + assert.equal(r.tests.status, "NOT_CONFIGURED", "no runner detected"); + assert.equal(r.tests.ran, false); + assert.equal(r.ok, false, "nothing ran must never be ok:true"); +}); + +test("verify: an untracked source file appears in provenance (changedFiles + untracked)", () => { + const root = gitRepo(); + // untracked (never `git add`ed) — invisible to `git diff`, but part of the change. + writeFileSync(join(root, "brand_new.js"), "export function shipped(){ return 2 }\n"); + const r = verify({ targetRoot: root }); + assert.ok(r.changedFiles.includes("brand_new.js"), "untracked file in changedFiles"); + assert.ok(r.provenance.untracked.includes("brand_new.js"), "untracked file in provenance stamp"); +}); From 1f72d74cebe0001ee8857d391ae536f9a070d082 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:54:19 +0000 Subject: [PATCH 04/10] fix(security): close shell secret-read bypass, jq-free redaction, tighter perms - P0-04: drop broad default Bash read allows so secret-file reads prompt; move dependency installs to ask. protect-paths now blocks a reader command aimed at a protected path (anchored to a command boundary to avoid prose false positives). - P0-05: secret redaction reimplemented in Node (secret-redact.mjs), no jq dependency; emits a visible DEGRADED warning instead of a silent no-op. forge doctor now treats a missing node runtime as a hard failure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- CHANGELOG.md | 18 ++++++++++ global/guards/protect-paths.sh | 11 +++++++ global/guards/secret-redact.mjs | 45 +++++++++++++++++++++++++ global/guards/secret-redact.sh | 54 ++++++++++-------------------- global/settings.template.json | 13 +++----- src/doctor.js | 18 +++++++--- test/guards.test.js | 58 ++++++++++++++++++++++++++++++++- 7 files changed, 167 insertions(+), 50 deletions(-) create mode 100755 global/guards/secret-redact.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d4575..25990e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed (audit remediation) + +- **Onboarding & state safety.** `forge init` no longer silently replaces a + present-but-unparseable `~/.claude/settings.json` (it refuses and preserves the + original), backs the file up before changing it, and writes atomically + (temp + rename). Hook/statusline commands now resolve to the installed package + (`~/.forge/…` → `/global/…`) so npm-global installs don't reference an + unmaterialized `~/.forge`. Personal recall moved to the XDG state dir instead of + the source tree; `install.sh` separates read-only assets from mutable state, + migrates any `global/recall/facts`, and no longer routes path ops through `eval`. +- **Security defaults.** Removed broad default Bash read allows + (`cat`/`head`/`tail`/`rg`/`git show`/`git log`) so secret-file reads prompt + instead of auto-approving, and moved dependency installs (`npm ci`/`npm install`/…) + to `ask`. `protect-paths` now blocks reading a protected path via Bash + (e.g. `cat .env`, `git show HEAD:.env`). Secret redaction is reimplemented in Node + (`secret-redact.mjs`) with no `jq` dependency and prints a visible DEGRADED warning + rather than silently no-op'ing; `forge doctor` treats missing `node` as a failure. + ## [0.20.0] - 2026-07-17 ### Added diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index b7366ff..d15afbf 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -39,6 +39,17 @@ if [ -n "${cmd:-}" ]; then *"git push --force"*|*"git push -f"*) deny "force-push blocked. Ask the user first." ;; *"DROP TABLE"*|*"DROP DATABASE"*|*"TRUNCATE "*) deny "destructive SQL detected. Confirm with the user." ;; esac + # Close the Bash secret-READ bypass (P0-04): the Read tool denies .env/keys, but a shell + # `cat .env` / `git show HEAD:.env` sidesteps that. Match a reader command anchored to a + # real command boundary (start, or after ; | &) so prose inside a quoted arg (a commit + # message mentioning ".env") isn't a false positive, AND require a protected path token. + # Best-effort defence in depth — a content scan like `rg TOKEN .` with no named path can't + # be caught here; that's what secret-redact.sh is for. + reader='(^|[;&|])[[:space:]]*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)[[:space:]]|git[[:space:]]+(show|log)[[:space:]])' + secret='(\.env([./A-Za-z0-9_-]*)?|id_rsa|id_ed25519|\.pem|\.key|/secrets/|/\.ssh/)' + if printf '%s' "$cmd" | grep -qE "$reader" && printf '%s' "$cmd" | grep -qE "$secret"; then + deny "reading a protected secret path via Bash is blocked. Read it yourself if intended." + fi # Pipe-to-shell (e.g. curl … | sh). Boundary-aware so legit `… | shellcheck` is not caught. if [[ "$cmd" =~ \|[[:space:]]*(sh|bash|zsh)([[:space:]]|$) ]]; then deny "piping content to a shell is blocked." diff --git a/global/guards/secret-redact.mjs b/global/guards/secret-redact.mjs new file mode 100755 index 0000000..9634fe2 --- /dev/null +++ b/global/guards/secret-redact.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// PostToolUse redactor — the whole thing in Node so it no longer depends on jq (P0-05). +// Reads the hook JSON on stdin, redacts the tool output via the ONE source of truth +// (src/secrets.js), and emits an `updatedToolOutput` rewrite only when something changed. +// The shell launcher (secret-redact.sh) calls this and warns loudly if node is missing — +// redaction never silently no-ops. +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +async function main() { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) raw += chunk; + if (!raw.trim()) return; + + let data; + try { + data = JSON.parse(raw); + } catch { + return; // not JSON we understand — leave output untouched + } + + const val = data.tool_response ?? data.tool_output; + if (val == null) return; + const out = typeof val === "string" ? val : JSON.stringify(val); + if (!out) return; + + const here = dirname(fileURLToPath(import.meta.url)); + const { redactSecrets } = await import( + join(here, "..", "..", "src", "secrets.js") + ); + const red = redactSecrets(out); + if (red !== out) { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PostToolUse", + updatedToolOutput: red, + }, + }), + ); + } +} + +main().catch(() => process.exit(0)); diff --git a/global/guards/secret-redact.sh b/global/guards/secret-redact.sh index 4f9c8c4..b7dbc31 100755 --- a/global/guards/secret-redact.sh +++ b/global/guards/secret-redact.sh @@ -1,46 +1,26 @@ #!/usr/bin/env bash -# PostToolUse guard — redact secrets from a tool's output BEFORE it enters context, -# using the `updatedToolOutput` hook primitive. Detection/redaction logic lives in -# src/secrets.js (format grammars + entropy scoring — ONE source of truth), imported -# via node so this script can never disagree with the JS refusal sites. Degrades to a -# narrower sed pass over known credential formats only if node or the module is -# unreachable. Defensive + advisory: only emits a rewrite when something changed. +# PostToolUse guard — redact secrets from a tool's output BEFORE it enters context, using +# the `updatedToolOutput` hook primitive. Thin launcher: all parse/redact/emit logic lives +# in secret-redact.mjs (Node) against the ONE source of truth (src/secrets.js), so the guard +# no longer depends on jq. A cheap shell prefilter skips the node spawn for the common case +# (most tool output has nothing secret-shaped). If a candidate IS present but the redactor +# cannot run, it prints a VISIBLE degraded-security warning instead of silently passing the +# secret through (P0-05). Advisory: only emits a rewrite when something changed, never blocks. set -uo pipefail -command -v jq >/dev/null 2>&1 || exit 0 +DIR="$(cd "$(dirname "$0")" && pwd -P)" +MJS="$DIR/secret-redact.mjs" INPUT="$(cat)" -out="$(printf '%s' "$INPUT" | jq -r '.tool_response // .tool_output // empty' 2>/dev/null)" -[ -n "$out" ] || exit 0 - -# Fast prefilter: PostToolUse fires after EVERY tool call, and most outputs contain -# nothing remotely secret-shaped — skip the node spawn entirely unless a candidate -# (known credential prefix, PEM header, key-ish assignment, or a 20+ char token run) -# is present. The node pass then decides precisely. -printf '%s' "$out" | grep -qE -- '-----BEGIN |ghp_|github_pat_|sk-|xox[baprs]-|AIza|ya29\.|eyJ|AKIA|(api[_-]?key|secret|passwd|password|token)[A-Za-z0-9_-]*["'"'"']?[[:space:]]*[:=]|[A-Za-z0-9+=_-]{20,}' || exit 0 -# ~/.forge is a symlink to /global, so pwd -P lands inside the real tree in -# both install modes (install.sh symlink and CLAUDE_PLUGIN_ROOT plugin checkout). -DIR="$(cd "$(dirname "$0")" && pwd -P)" -SECRETS_JS="$DIR/../../src/secrets.js" +# Fast prefilter over the raw hook JSON — skip node entirely unless a credential-shaped +# candidate (known prefix, PEM header, key-ish assignment, or a 20+ char token run) exists. +printf '%s' "$INPUT" | grep -qE -- '-----BEGIN |ghp_|github_pat_|sk-|xox[baprs]-|AIza|ya29\.|eyJ|AKIA|(api[_-]?key|secret|passwd|password|token)[A-Za-z0-9_-]*["'"'"']?[[:space:]]*[:=]|[A-Za-z0-9+=_-]{20,}' || exit 0 -red="" -if command -v node >/dev/null 2>&1 && [ -f "$SECRETS_JS" ]; then - # setEncoding: string-concatenating raw Buffers corrupts multibyte UTF-8 split - # across chunk boundaries, and the mangled text would be emitted as a rewrite. - red="$(printf '%s' "$out" | node -e ' - process.stdin.setEncoding("utf8"); - let raw = ""; - process.stdin.on("data", (d) => { raw += d; }); - process.stdin.on("end", async () => { - const { redactSecrets } = await import(process.argv[1]); - process.stdout.write(redactSecrets(raw)); - });' "$SECRETS_JS" 2>/dev/null)" || red="" -fi -if [ -z "$red" ]; then - red="$(printf '%s' "$out" | sed -E 's/(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{20,}|ya29\.[A-Za-z0-9._-]+|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/[REDACTED]/g')" +if command -v node >/dev/null 2>&1 && [ -f "$MJS" ]; then + printf '%s' "$INPUT" | node "$MJS" + exit 0 fi -if [ "$red" != "$out" ]; then - jq -n --arg o "$red" '{hookSpecificOutput:{hookEventName:"PostToolUse",updatedToolOutput:$o}}' -fi +# A secret-shaped candidate is present but we cannot redact — do NOT silently drop it. +echo "forge secret-redact: DEGRADED — node unavailable or redactor missing; tool output was NOT scanned for secrets. Install Node 20+ to restore redaction." >&2 exit 0 diff --git a/global/settings.template.json b/global/settings.template.json index 5b2f542..2fae3d7 100644 --- a/global/settings.template.json +++ b/global/settings.template.json @@ -17,18 +17,12 @@ "Bash(git status:*)", "Bash(git diff)", "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git show:*)", "Bash(git branch:*)", "Bash(git add:*)", "Bash(git stash:*)", "Bash(ls:*)", - "Bash(cat:*)", - "Bash(rg:*)", "Bash(fd:*)", "Bash(tree:*)", - "Bash(head:*)", - "Bash(tail:*)", "Bash(wc:*)", "Bash(npm run lint)", "Bash(npm run lint:*)", @@ -36,7 +30,6 @@ "Bash(npm run test:*)", "Bash(npm run build)", "Bash(npm run typecheck)", - "Bash(npm ci)", "Bash(pnpm lint)", "Bash(pnpm test:*)", "Bash(pnpm build)", @@ -55,7 +48,11 @@ "Bash(git push:*)", "Bash(git commit:*)", "Bash(gh pr create:*)", - "Bash(gh pr merge:*)" + "Bash(gh pr merge:*)", + "Bash(npm ci)", + "Bash(npm install:*)", + "Bash(pnpm install:*)", + "Bash(yarn install:*)" ], "deny": [ "Read(./.env)", diff --git a/src/doctor.js b/src/doctor.js index 663ff38..c98f66a 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -23,13 +23,23 @@ import { hasBin } from "./util.js"; const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); -// External tools the guards/commands depend on. jq is the important one — several guards -// (secret-redact, protect-paths) degrade to a naive parse or a no-op without it. +// External tools the guards/commands depend on. secret-redact now runs in Node (no jq), +// so node is the security-critical dependency; jq only helps protect-paths parse more +// precisely (it has a grep fallback either way). function checkTooling(out) { + // node powers secret redaction — its absence means tool output is NOT scanned for secrets. + out.push( + hasBin("node") + ? ok("node", "found — secret-redact scans tool output") + : fail( + "node", + "not found — secret-redact CANNOT run; tool output is NOT scanned for secrets", + ), + ); out.push( hasBin("jq") - ? ok("jq", "found — guards parse hook JSON safely") - : warn("jq", "not found — secret-redact/protect-paths degrade without it; install jq"), + ? ok("jq", "found — protect-paths parses hook JSON precisely") + : warn("jq", "not found — protect-paths falls back to grep parsing (still enforced)"), ); out.push( hasBin("git") ? ok("git", "found") : warn("git", "not found — churn/impact/anchor need it"), diff --git a/test/guards.test.js b/test/guards.test.js index 0c92028..2a89125 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -5,7 +5,12 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -const guards = join(dirname(fileURLToPath(import.meta.url)), "..", "global", "guards"); +const guards = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "global", + "guards", +); function runGuard(script, input, opts = {}) { // spawnSync captures BOTH stdout and stderr regardless of exit code; guards @@ -43,6 +48,57 @@ test("protect-paths blocks destructive rm (exit 2)", () => { assert.equal(r.code, 2); }); +test("protect-paths blocks a Bash secret read (cat .env) (exit 2)", () => { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command: "cat .env" }, + }); + assert.equal(r.code, 2); + assert.match(r.err, /protected secret path/); +}); + +test("protect-paths blocks reading a secret from git history (exit 2)", () => { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command: "git show HEAD~1:.env" }, + }); + assert.equal(r.code, 2); +}); + +test("protect-paths allows a normal Bash read (exit 0)", () => { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command: "cat src/app.js" }, + }); + assert.equal(r.code, 0); +}); + +test("protect-paths does not false-positive on prose mentioning secrets in a quoted arg", () => { + // A commit message that merely names cat/.env/git show must not be blocked — the reader + // is anchored to a command boundary, so text inside a quoted arg is safe. + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { + command: 'git commit -m "block cat .env and git show HEAD:.env reads"', + }, + }); + assert.equal(r.code, 0); +}); + +test("secret-redact redacts a token without jq (Node path)", () => { + const r = runGuard("secret-redact.sh", { + tool_name: "Bash", + tool_response: "token=ghp_0123456789abcdef0123456789abcdef0123", + }); + assert.equal(r.code, 0, "never blocks"); + assert.doesNotMatch( + r.out, + /ghp_0123456789abcdef/, + "raw token must not survive", + ); + assert.match(r.out, /updatedToolOutput/, "emits a redaction rewrite"); +}); + test("cost-budget never blocks and warns on a broad command", () => { const r = runGuard("cost-budget.sh", { session_id: "t-broad", From 77d715baaf54c2087c8146122cc563119f2917de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:58:43 +0000 Subject: [PATCH 05/10] fix(sync): validate managed-file body, not just the embedded marker P0-08: writeIfChanged and doctor drift detection compare the full expected body against the canonical source, so a hand-edited managed file with an intact forge:sync marker is detected and restored instead of reported in-sync. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- CHANGELOG.md | 4 ++++ src/doctor.js | 13 +++++++++---- src/emit/_shared.js | 6 ++++-- test/sync.test.js | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25990e4..32ebb90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). (e.g. `cat .env`, `git show HEAD:.env`). Secret redaction is reimplemented in Node (`secret-redact.mjs`) with no `jq` dependency and prints a visible DEGRADED warning rather than silently no-op'ing; `forge doctor` treats missing `node` as a failure. +- **Managed-file integrity.** `writeIfChanged` and `forge doctor` now compare the full + file body against the canonical source instead of trusting the embedded `forge:sync` + marker, so a hand-edited managed file (`AGENTS.md`, etc.) with an intact marker is + detected and restored on `forge sync` (P0-08). ## [0.20.0] - 2026-07-17 diff --git a/src/doctor.js b/src/doctor.js index c98f66a..6f890bc 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -7,7 +7,7 @@ import { isStale, load as loadAtlas } from "./atlas.js"; import { BRAND } from "./brand.js"; import { summary as cortexSummary } from "./cortex.js"; import { docsCheck } from "./docs_check.js"; -import { extractHash, hashContent } from "./emit/_shared.js"; +import { hashContent, mdHeader } from "./emit/_shared.js"; import { gatewayBase, gatewayModelMap } from "./gateway_model_map.js"; import { verify as ledgerVerify, repoLedger } from "./ledger_store.js"; import { PRICING_VERIFIED } from "./model_tiers.js"; @@ -237,10 +237,15 @@ function checkDrift(out, targetRoot) { out.push(warn("AGENTS.md", "not emitted here — run `forge sync`")); return; } - const current = hashContent(canonical(targetRoot)); - const onDisk = extractHash(readFileSync(agents, "utf8")); + // Compare the actual file to the full expected content, not just the embedded marker — + // a hand-edited body with an intact marker would otherwise report "in sync" (P0-08). + const body = canonical(targetRoot); + const expected = `${mdHeader(hashContent(body))}\n${body}\n`; + const actual = readFileSync(agents, "utf8"); out.push( - current === onDisk ? ok("AGENTS.md", "in sync") : warn("AGENTS.md", "stale — run `forge sync`"), + actual === expected + ? ok("AGENTS.md", "in sync") + : warn("AGENTS.md", "stale or hand-edited — run `forge sync`"), ); } diff --git a/src/emit/_shared.js b/src/emit/_shared.js index 9bcf3eb..659845b 100644 --- a/src/emit/_shared.js +++ b/src/emit/_shared.js @@ -26,10 +26,12 @@ export function extractHash(text) { return m ? m[1] : null; } -// Write only when the content's marker differs from what's on disk. +// Write only when the file BODY differs from what's on disk. Comparing the full expected +// content (not just the embedded marker hash) means a hand-edited body with an intact marker +// is detected and restored — the marker alone can't be trusted as proof of sync (P0-08). export function writeIfChanged(absPath, content) { const existing = readIfExists(absPath); - if (existing !== null && extractHash(existing) === extractHash(content)) return "unchanged"; + if (existing === content) return "unchanged"; mkdirSync(dirname(absPath), { recursive: true }); writeFileSync(absPath, content); return "written"; diff --git a/test/sync.test.js b/test/sync.test.js index 40b4db5..92ee886 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -36,6 +36,24 @@ test("re-running is idempotent (nothing rewritten)", () => { assert.equal(written.length, 0, `second sync wrote ${written.map((r) => r.target)}`); }); +test("a hand-edited managed body (marker intact) is detected and restored", () => { + const root = fixture(); + sync({ targetRoot: root }); + const agents = join(root, "AGENTS.md"); + const original = readFileSync(agents, "utf8"); + // Tamper with the body but keep the forge:sync marker line untouched. + const tampered = original.replace(/## Workflow/, "## Workflow\n- SNEAKY injected rule"); + assert.notEqual(tampered, original, "tampering changed the body"); + writeFileSync(agents, tampered); + const second = sync({ targetRoot: root }); + const wrote = second.report.filter((r) => r.action === "written").map((r) => r.target); + assert.ok( + wrote.some((t) => t.includes("AGENTS.md")), + "sync must rewrite a body-edited AGENTS.md, not trust the intact marker", + ); + assert.equal(readFileSync(agents, "utf8"), original, "canonical body restored"); +}); + test("adopts an existing unmanaged CLAUDE.md: prepends @AGENTS.md, preserves content, idempotent", () => { const root = fixture(); writeFileSync(join(root, "CLAUDE.md"), "# my own claude file\nkeep me\n"); From 53de26537b0d86674b8885c2fe78cdfef4896d32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:05:17 +0000 Subject: [PATCH 06/10] style(biome): normalize formatting under root config Two files were reformatted by the format-on-edit hook under a transient worktree biome config (different line width); re-apply the canonical root formatting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- src/docs_check.js | 82 +++++++++++---------------------------------- test/guards.test.js | 13 ++----- 2 files changed, 22 insertions(+), 73 deletions(-) diff --git a/src/docs_check.js b/src/docs_check.js index 8e1cb4e..61a33e9 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -13,12 +13,7 @@ import { TOOLS } from "./mcp_tools.js"; import { MODELS } from "./model_tiers.js"; /** The user-facing prose docs every claim is reconciled against. */ -const DOC_FILES = [ - "README.md", - "docs/GUIDE.md", - "ARCHITECTURE.md", - "ROADMAP.md", -]; +const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md"]; // Env vars read in src that are NOT user-facing contract: child-process plumbing and // values injected by host tools rather than set by users. @@ -32,8 +27,7 @@ const INTERNAL_ENV = new Set([ // Prefixes that mark an env var as OURS to document. A doc may freely mention other // tools' vars (GITHUB_TOKEN, PATH) — those aren't claims about forge's own surface. -const ENV_PREFIX_RE = - /\b((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX)_[A-Z0-9_]+)\b/g; +const ENV_PREFIX_RE = /\b((?:FORGE|ANTHROPIC|LITELLM|OPENROUTER|ENABLE_CORTEX)_[A-Z0-9_]+)\b/g; function readDoc(root, rel) { const p = join(root, rel); @@ -56,12 +50,8 @@ export function envVarsRead(root = BRAND.root) { const vars = new Set(); for (const file of srcFiles(root)) { const text = readFileSync(file, "utf8"); - for (const m of text.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) - vars.add(m[1]); - for (const m of text.matchAll( - /process\.env\[["']([A-Z_][A-Z0-9_]*)["']\]/g, - )) - vars.add(m[1]); + for (const m of text.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) vars.add(m[1]); + for (const m of text.matchAll(/process\.env\[["']([A-Z_][A-Z0-9_]*)["']\]/g)) vars.add(m[1]); } const guards = join(root, "global", "guards"); if (existsSync(guards)) { @@ -96,9 +86,7 @@ function checkCommands(docs, issues) { } } for (const [file, text] of Object.entries(docs)) { - for (const m of text.matchAll( - new RegExp(`\`${BRAND.cli} ([a-z][a-z-]*)\\b`, "g"), - )) { + for (const m of text.matchAll(new RegExp(`\`${BRAND.cli} ([a-z][a-z-]*)\\b`, "g"))) { const name = m[1]; if (!(name in COMMANDS) && !HIDDEN_COMMANDS.includes(name)) { issues.push({ @@ -128,11 +116,7 @@ function checkEnvVars(root, docs, issues) { const documented = new Set(); for (const [file, text] of Object.entries(docs)) { for (const m of text.matchAll(ENV_PREFIX_RE)) { - if ( - !documented.has(`${file}:${m[1]}`) && - !read.has(m[1]) && - !INTERNAL_ENV.has(m[1]) - ) { + if (!documented.has(`${file}:${m[1]}`) && !read.has(m[1]) && !INTERNAL_ENV.has(m[1])) { documented.add(`${file}:${m[1]}`); issues.push({ check: "env-vars", @@ -190,12 +174,7 @@ function markdownFiles(root) { if (!existsSync(root)) return []; return readdirSync(root, { recursive: true }) .map(String) - .filter( - (f) => - f.endsWith(".md") && - !f.includes("node_modules") && - !f.startsWith(".git/"), - ); + .filter((f) => f.endsWith(".md") && !f.includes("node_modules") && !f.startsWith(".git/")); } // The branded Mermaid theme every diagram shares (see README's `%%{init …}%%`). Without it @@ -219,10 +198,7 @@ function checkDiagrams(root, issues) { for (const m of text.matchAll(MERMAID_BLOCK_RE)) { // An intentional example block (e.g. docs showing what a BAD diagram looks like) opts // out with an HTML comment `` on the line before the fence. - if ( - /docs-check-ignore/.test(text.slice(Math.max(0, m.index - 80), m.index)) - ) - continue; + if (/docs-check-ignore/.test(text.slice(Math.max(0, m.index - 80), m.index))) continue; const block = m[1]; if (!block.includes("%%{init")) { issues.push({ @@ -266,13 +242,9 @@ function checkChangelog(root, issues) { const text = readDoc(root, "CHANGELOG.md"); if (!text) return; const sections = [ - ...text.matchAll( - /^## \[([^\]]+)\][^\n]*\n([\s\S]*?)(?=^## \[|\n*$(?![\s\S]))/gm, - ), + ...text.matchAll(/^## \[([^\]]+)\][^\n]*\n([\s\S]*?)(?=^## \[|\n*$(?![\s\S]))/gm), ]; - const version = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), - ).version; + const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; const released = sections.filter((s) => s[1].toLowerCase() !== "unreleased"); if (released.length && released[0][1] !== version) { issues.push({ @@ -292,18 +264,13 @@ function checkChangelog(root, issues) { } const unreleased = sections.find((s) => s[1].toLowerCase() === "unreleased"); if (unreleased && !unreleased[2].trim()) { - const srcT = Number( - git(root, ["log", "-1", "--format=%ct", "--", "src"]) || 0, - ); - const clT = Number( - git(root, ["log", "-1", "--format=%ct", "--", "CHANGELOG.md"]) || 0, - ); + const srcT = Number(git(root, ["log", "-1", "--format=%ct", "--", "src"]) || 0); + const clT = Number(git(root, ["log", "-1", "--format=%ct", "--", "CHANGELOG.md"]) || 0); if (srcT && clT && srcT > clT) { issues.push({ check: "changelog", severity: "error", - detail: - "src changed since CHANGELOG.md was last touched, but [Unreleased] is empty", + detail: "src changed since CHANGELOG.md was last touched, but [Unreleased] is empty", }); } } @@ -339,8 +306,7 @@ function measuredTimings(root) { const set = new Set(); for (const line of readDoc(root, "reports/benchmarks.md").split("\n")) { if (!line.startsWith("|")) continue; // table rows only — not the prose above it - for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) - set.add(`${m[1]} ${m[2]}`); + for (const m of line.matchAll(/(\d+(?:\.\d+)?)\s*(ms|µs|s)\b/g)) set.add(`${m[1]} ${m[2]}`); } return set; } @@ -393,10 +359,8 @@ function headingSlug(text) { // (``, `name=…`, or a `{#custom-id}` suffix). function anchorsFor(text) { const set = new Set(); - for (const m of text.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) - set.add(headingSlug(m[1])); - for (const m of text.matchAll(/\b(?:id|name)=["']([\w-]+)["']/g)) - set.add(m[1].toLowerCase()); + for (const m of text.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) set.add(headingSlug(m[1])); + for (const m of text.matchAll(/\b(?:id|name)=["']([\w-]+)["']/g)) set.add(m[1].toLowerCase()); for (const m of text.matchAll(/\{#([\w-]+)\}/g)) set.add(m[1].toLowerCase()); return set; } @@ -443,8 +407,7 @@ function checkLinks(root, issues) { let targetRel; if (!path) targetRel = rel; // same-file anchor - else if (path.endsWith(".md")) - targetRel = normalize(join(dirname(rel), path)); + else if (path.endsWith(".md")) targetRel = normalize(join(dirname(rel), path)); else continue; // .html/.pdf/code target — can't resolve headings, skip const anchors = anchorsOf(targetRel); if (anchors == null) continue; // unreadable/missing target — file existence is another matter @@ -476,9 +439,7 @@ function checkRoadmap(root, issues) { try { // Parse leading integers per component so a prerelease tag ("1.2.3-beta.1") still yields // [1,2,3], not NaN; guard the read so a missing/corrupt manifest can't abort the whole check. - const raw = - JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || - ""; + const raw = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || ""; const pv = raw.match(/(\d+)\.(\d+)(?:\.(\d+))?/); if (!pv) return; pkg = [Number(pv[1]), Number(pv[2]), Number(pv[3] || 0)]; @@ -487,8 +448,7 @@ function checkRoadmap(root, issues) { } const behind = road[0] < pkg[0] || - (road[0] === pkg[0] && - (road[1] < pkg[1] || (road[1] === pkg[1] && road[2] < pkg[2]))); + (road[0] === pkg[0] && (road[1] < pkg[1] || (road[1] === pkg[1] && road[2] < pkg[2]))); if (behind) { issues.push({ check: "roadmap", @@ -522,9 +482,7 @@ function checkCrosswalk(root, issues) { }); return; } - const known = new Set( - srcFiles(root).map((f) => String(f).split(/[\\/]/).pop()), - ); + const known = new Set(srcFiles(root).map((f) => String(f).split(/[\\/]/).pop())); for (const dir of [join("global", "guards"), "hooks"]) { const d = join(root, dir); if (!existsSync(d)) continue; diff --git a/test/guards.test.js b/test/guards.test.js index 2a89125..0f977c2 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -5,12 +5,7 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -const guards = join( - dirname(fileURLToPath(import.meta.url)), - "..", - "global", - "guards", -); +const guards = join(dirname(fileURLToPath(import.meta.url)), "..", "global", "guards"); function runGuard(script, input, opts = {}) { // spawnSync captures BOTH stdout and stderr regardless of exit code; guards @@ -91,11 +86,7 @@ test("secret-redact redacts a token without jq (Node path)", () => { tool_response: "token=ghp_0123456789abcdef0123456789abcdef0123", }); assert.equal(r.code, 0, "never blocks"); - assert.doesNotMatch( - r.out, - /ghp_0123456789abcdef/, - "raw token must not survive", - ); + assert.doesNotMatch(r.out, /ghp_0123456789abcdef/, "raw token must not survive"); assert.match(r.out, /updatedToolOutput/, "emits a redaction rewrite"); }); From cdd1fd584eb20c348767d6c5afc0fe3b4196b489 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:13:42 +0000 Subject: [PATCH 07/10] feat(mcp): context7 opt-in via forge integrations; update-aware emitters; wire honest labels - P0-06: remove context7 from the default MCP set; add `forge integrations [add ]` that shows package/network/files and writes only with --yes. JSON emitters now refresh a drifted forge-owned server instead of leaving a stale entry (user servers untouched). - Wire the CLI to skillgate's honest verdict (S-01) and relabel the deep-verify output (defectRiskScore / remainingUncheckedWeight); align the ledger command description. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- CHANGELOG.md | 17 +++++++++ README.md | 87 +++++++++++++++++++++++---------------------- docs/GUIDE.md | 1 + source/mcp.json | 4 --- src/cli.js | 45 +++++++++++++++++++++-- src/commands.js | 62 ++++++++++++++++++++++++-------- src/emit/mcp.js | 13 ++++--- src/integrations.js | 40 +++++++++++++++++++++ test/mcp.test.js | 61 ++++++++++++++++++++++++++----- 9 files changed, 252 insertions(+), 78 deletions(-) create mode 100644 src/integrations.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ebb90..8a3e457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). file body against the canonical source instead of trusting the embedded `forge:sync` marker, so a hand-edited managed file (`AGENTS.md`, etc.) with an intact marker is detected and restored on `forge sync` (P0-08). +- **Third-party MCP is opt-in.** `context7` is no longer installed by default; `forge +init` wires only forge's own server. New `forge integrations` command adds an optional + server (e.g. `context7`) after showing its package, network behaviour, and the files it + touches, writing only with `--yes` (P0-06). MCP emitters now refresh a drifted + forge-owned entry instead of leaving a stale one (user servers untouched). +- **Core correctness.** Atlas staleness is inventory-aware (a new/removed file now + invalidates the graph), and `forge impact`/`substrate` report "impact unavailable" on a + stale/missing atlas instead of presenting 0 impacted files as trustworthy (P0-07). + `forge verify` returns `PASS | FAIL | INCOMPLETE | NOT_CONFIGURED` (never "pass" when no + verifier ran), drives real test commands off the stack detector, and includes untracked + files in provenance (P0-09). Ledger evidence refs are validated — a typed `git:` + ref must resolve before it can affect confidence (P0-10). +- **Honest claims & wording.** Dropped scanner "ok to install" certification language + (S-01); renamed `P(defect)`→`defectRiskScore` and `residual`→`remainingUncheckedWeight` + in output; reframed the preflight score as a heuristic; qualified "proof-carrying + memory" as evidence-referenced and "zero-config" as guided/low-configuration across the + docs; added a beta status block and per-benchmark sample sizes. ## [0.20.0] - 2026-07-17 diff --git a/README.md b/README.md index fce932c..a4c0f14 100644 --- a/README.md +++ b/README.md @@ -188,49 +188,50 @@ Output is plain text when piped; on a TTY it adds brand-palette color and confid meters. `NO_COLOR` turns color off, `FORCE_COLOR=1` forces it on (e.g. in CI, `0` forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. -| Group | Command | Does | -| -------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **Config layer** | `forge init` | emit every tool's native config from one source | -| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | -| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | -| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | -| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | -| | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | -| | `forge catalog` | Start-Here index of every tool / crew / guard | -| | `forge brand` | print the brand token map | -| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | -| | `forge recall` | cross-session personal memory — list / add / consolidate | -| | `forge remember` | durable, repo-committable fact | -| | `forge brain` | portable project-memory index | -| | `forge cortex` | self-correcting lessons — `status` / `why` | -| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | -| | `forge reuse` | proof-carrying code cache — query / mint / stats | -| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | -| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | -| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | -| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | -| | `forge preflight` | assumption / info-gap check | -| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | -| | `forge impact` | predict blast radius for a symbol or file | -| | `forge scope` | cluster + surface coupled files | -| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | -| | `forge context` | budgeted context assembly + completeness gate | -| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | -| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | -| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | -| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | -| | `forge lean` | scope-minimality footprint (advisory) | -| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | -| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | -| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | -| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | -| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | -| | `forge spec` | spec-as-contract drift — init / lock / check | -| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | -| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | -| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | -| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | +| Group | Command | Does | +| -------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Config layer** | `forge init` | emit every tool's native config from one source | +| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | +| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | +| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | +| | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | +| | `forge config` | provider setup — show / switch / add providers, set the default model | +| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — shows package/network, writes only with `--yes` | +| | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | +| | `forge catalog` | Start-Here index of every tool / crew / guard | +| | `forge brand` | print the brand token map | +| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | +| | `forge recall` | cross-session personal memory — list / add / consolidate | +| | `forge remember` | durable, repo-committable fact | +| | `forge brain` | portable project-memory index | +| | `forge cortex` | self-correcting lessons — `status` / `why` | +| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | +| | `forge reuse` | proof-carrying code cache — query / mint / stats | +| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | +| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | +| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | +| | `forge preflight` | assumption / info-gap check | +| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | +| | `forge impact` | predict blast radius for a symbol or file | +| | `forge scope` | cluster + surface coupled files | +| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | +| | `forge context` | budgeted context assembly + completeness gate | +| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | +| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | +| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | +| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | +| | `forge lean` | scope-minimality footprint (advisory) | +| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | +| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | +| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | +| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | +| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | +| | `forge spec` | spec-as-contract drift — init / lock / check | +| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | +| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | +| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | +| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | **→ Every command with a worked example and real output: [`docs/GUIDE.md`](docs/GUIDE.md).** diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 925e061..27e7682 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -970,6 +970,7 @@ Plain `forge cost` remains the per-day spend view via `ccusage`. | `forge docs check` | Docs↔code drift: commands, env vars, MCP tools, CHANGELOG reconciled against the code (CI-gated on the forge repo itself). | | `forge docs sync` | Diff-driven stale-docs sweep: UPDATED / STALE (file:line hits) / VERIFIED-UNAFFECTED per artifact (see the full section above). | | `forge catalog` | Start-Here index of every tool / crew / guard. | +| `forge integrations` | Opt-in third-party MCP servers (e.g. `context7`, no longer installed by default). `integrations add ` prints the package, its network behaviour, and the files it touches, then writes only with `--yes`. | | `forge brain` / `forge remember` | Portable project memory inlined into `AGENTS.md`. | | `forge cost` | Real per-day spend (via `ccusage`) + the cost ceiling; `--stages` for the measured report. | | `forge scan ` | Vet a skill/MCP (SKILL.md/.mcp.json) for injection/RCE/exfil before install. | diff --git a/source/mcp.json b/source/mcp.json index 0f69bb4..f4aeaaf 100644 --- a/source/mcp.json +++ b/source/mcp.json @@ -1,8 +1,4 @@ { - "context7": { - "command": "npx", - "args": ["-y", "@upstash/context7-mcp@3.2.2"] - }, "forge-cortex": { "command": "forge", "args": ["cortex-mcp"] diff --git a/src/cli.js b/src/cli.js index 1cfa4a4..f69b109 100755 --- a/src/cli.js +++ b/src/cli.js @@ -314,6 +314,43 @@ async function run(argv) { } return; } + if (cmd === "integrations") { + const { listIntegrations, planIntegration, addIntegration } = await import("./integrations.js"); + const sub = argv[1]; + if (sub === "add") { + const name = argv[2]; + const plan = planIntegration(name); + if (!plan.ok) { + console.error(plan.reason); + process.exitCode = 1; + return; + } + if (!argv.includes("--yes")) { + heading(`${BRAND.brand} integrations — add ${name}\n`); + console.log(` This adds a THIRD-PARTY MCP server to every detected tool's config:`); + console.log(` package: ${plan.pkg}`); + console.log(` network: ${plan.network}`); + console.log(` purpose: ${plan.why}`); + console.log(` writes: .mcp.json, .cursor/mcp.json, .gemini/…, .codex/…, .continue/…`); + console.log( + `\n Not installed. Re-run with --yes to apply: ${BRAND.cli} integrations add ${name} --yes`, + ); + return; + } + const res = addIntegration(name, { targetRoot: process.cwd() }); + const wrote = res.rows.filter((x) => x.action === "written").map((x) => x.target); + heading(`${BRAND.brand} integrations — add ${name}\n`); + console.log(` added ${name} → ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); + return; + } + // Default: list what's available. + heading(`${BRAND.brand} integrations — opt-in third-party MCP servers\n`); + for (const it of listIntegrations()) { + console.log(` ${it.name.padEnd(12)} ${it.why} (${it.pkg})`); + } + console.log(`\n Add one with: ${BRAND.cli} integrations add `); + return; + } if (cmd === "recall") { const r = await import("./recall.js"); const store = r.defaultStore(); @@ -832,7 +869,7 @@ async function run(argv) { console.log(" no obvious red flags"); } console.log( - `\n ${r.critical ? "BLOCKED — critical finding, do not install" : "ok to install"}`, + `\n ${r.verdict || (r.critical ? "BLOCKED — critical finding" : "no critical signature detected — not a safety certification")}`, ); if (r.critical) process.exitCode = 1; return; @@ -871,11 +908,13 @@ async function run(argv) { for (const f of r.findings) console.log(` ! ${f}`); } console.log( - `\n P(defect): ${bar(r.p)} ${r.p.toFixed(2)}${ + `\n defectRiskScore: ${bar(r.p)} ${r.p.toFixed(2)} (heuristic, not a calibrated probability)${ r.families.length ? ` (families: ${r.families.join(", ")})` : "" }`, ); - console.log(` residual: ${r.residual.toFixed(3)} — Theorem-D silent-miss bound`); + console.log( + ` remainingUncheckedWeight: ${r.residual.toFixed(3)} — Theorem-D silent-miss bound (heuristic)`, + ); console.log( `\n ${ r.ok ? paint("PASS", "ok") : paint("BLOCKED — cross-family consensus says defect", "err") diff --git a/src/commands.js b/src/commands.js index 2e9e0f6..e87514c 100644 --- a/src/commands.js +++ b/src/commands.js @@ -10,7 +10,8 @@ export const COMMANDS = { "self-update — `--check` reports if a newer version is available, bare applies it, `--to ` pins/downgrades", taste: "enable one UI-taste tool for this repo (no arg = list)", atlas: "build / query the code-graph (where-is-Y, has-symbol)", - stack: "detect this repo's real stack (languages, frameworks, test commands) from its manifests", + stack: + "detect this repo's real stack (languages, frameworks, test commands) from its manifests", radar: "dependency-currency rings — staleness/major-lag/advisories from live registry evidence, cached 24h", recall: "manage cross-session memory (list / add / consolidate)", @@ -20,7 +21,8 @@ export const COMMANDS = { "independent verification gate — tests + hallucinated-symbol + provenance (--deep: multi-lens consensus)", precommit: "commit-level gate — staged code w/o docs + secret scan (FORGE_COMMIT_GATE=block|warn|0)", - harden: "wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings", + harden: + "wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings", remember: "add a durable fact to this repo's portable memory (forge brain)", brain: "show / rebuild the portable project memory index", cost: "real per-day spend via ccusage + measured stage factors (--stages)", @@ -28,29 +30,41 @@ export const COMMANDS = { cortex: "self-correcting project memory — status / why ", deja: "anti-repetition — have you done this task before? ranks prior solved/verified sessions", ledger: - "proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import", - reuse: "proof-carrying code cache — query / mint --file / stats", - context: "budgeted context assembly + completeness gate — what an edit NEEDS known", - preflight: "assumption check — what a task names that the repo doesn't define", + "evidence-referenced memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import", + reuse: + "proof-carrying code cache — query / mint --file / stats", + context: + "budgeted context assembly + completeness gate — what an edit NEEDS known", + preflight: + "assumption check — what a task names that the repo doesn't define", config: "provider setup — show / switch / add providers, set default model", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", - substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", - scope: "decompose files into independent clusters (+ coupled files you didn't name)", - anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", - handoff: "bounded session snapshot — rewrite .forge/state.md, re-injected each session start", - decide: "append-only decision log — D-#### ADR-lite entries in .forge/decisions.md", + substrate: + "one pre-action gate: assumptions, route, impact, scope, memory, verify", + scope: + "decompose files into independent clusters (+ coupled files you didn't name)", + anchor: + "goal-drift check — are your actual (git) changes still on the stated goal?", + handoff: + "bounded session snapshot — rewrite .forge/state.md, re-injected each session start", + decide: + "append-only decision log — D-#### ADR-lite entries in .forge/decisions.md", know: "route any fact to its storage home (decision / ledger / recall / …) — total, never dropped", diagnose: "doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation", - imagine: "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", + imagine: + "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", uicheck: "deterministic UI checks — contrast · fingerprint · design · visual ", dash: "live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius", - report: "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", + report: + "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", brand: "print the active brand token map", docs: "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep)", + integrations: + "opt-in third-party MCP servers (e.g. context7) — shows package/network, writes only with --yes", }; export const GROUPS = { @@ -79,8 +93,26 @@ export const GROUPS = { "imagine", "lean", ], - Quality: ["verify", "precommit", "radar", "scan", "spec", "taste", "uicheck", "harden"], - Config: ["config", "cost", "dash", "report", "brand", "atlas", "stack"], + Quality: [ + "verify", + "precommit", + "radar", + "scan", + "spec", + "taste", + "uicheck", + "harden", + ], + Config: [ + "config", + "cost", + "dash", + "report", + "brand", + "atlas", + "stack", + "integrations", + ], }; /** Commands that exist but are deliberately not advertised in --help or docs tables. */ diff --git a/src/emit/mcp.js b/src/emit/mcp.js index 29cf945..8e56249 100644 --- a/src/emit/mcp.js +++ b/src/emit/mcp.js @@ -24,17 +24,20 @@ function mergeJson(path, key, servers) { } } const bucket = obj[key] || (obj[key] = {}); - let added = 0; + // Managed-entry ownership (P0-06): every server WE emit is forge-managed, so add it when + // missing AND refresh it when a prior emission has drifted (stale command/args). Servers + // the user added under other names are never touched — we only iterate our own set. + let changed = 0; for (const [name, def] of Object.entries(servers)) { - if (!bucket[name]) { + if (!bucket[name] || JSON.stringify(bucket[name]) !== JSON.stringify(def)) { bucket[name] = def; - added += 1; + changed += 1; } } - if (!added) return { action: "unchanged", note: "present" }; + if (!changed) return { action: "unchanged", note: "present" }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`); - return { action: "written", note: `+${added} server(s)` }; + return { action: "written", note: `${changed} server(s) written/updated` }; } function emitCodexToml(path, servers) { diff --git a/src/integrations.js b/src/integrations.js new file mode 100644 index 0000000..ba3b7ec --- /dev/null +++ b/src/integrations.js @@ -0,0 +1,40 @@ +// forge integrations — opt-in third-party MCP servers. These are NOT installed by default +// (P0-06): `forge init` only wires forge's own server. Each entry here is added explicitly +// via `forge integrations add `, which first shows the package, its network behaviour, +// and the files it will touch, then requires confirmation (--yes) before writing anything. +import { emitMcp } from "./emit/mcp.js"; + +/** The catalog of known optional integrations. Keep each entry honest about what running it + * actually does (network, third-party code execution). */ +export const INTEGRATIONS = { + context7: { + server: { command: "npx", args: ["-y", "@upstash/context7-mcp@3.2.2"] }, + pkg: "@upstash/context7-mcp@3.2.2", + network: "yes — `npx -y` downloads and executes the package on every session start", + why: "live library-documentation lookup", + }, +}; + +export function listIntegrations() { + return Object.entries(INTEGRATIONS).map(([name, m]) => ({ + name, + pkg: m.pkg, + network: m.network, + why: m.why, + })); +} + +/** Describe what `add ` would do, without writing anything. */ +export function planIntegration(name) { + const m = INTEGRATIONS[name]; + if (!m) return { ok: false, reason: `unknown integration: ${name}` }; + return { ok: true, name, pkg: m.pkg, network: m.network, why: m.why }; +} + +/** Write the integration's server into each tool's MCP config (via the same emitter as sync). */ +export function addIntegration(name, { targetRoot = process.cwd() } = {}) { + const m = INTEGRATIONS[name]; + if (!m) return { ok: false, reason: `unknown integration: ${name}` }; + const rows = emitMcp({ targetRoot, servers: { [name]: m.server } }); + return { ok: true, name, rows }; +} diff --git a/test/mcp.test.js b/test/mcp.test.js index c220441..82dd2f0 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -7,19 +7,25 @@ import { sync } from "../src/sync.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-mcp-")); -test("sync emits MCP config into each tool's real format", () => { +test("sync emits MCP config into each tool's real format (forge server only, no third-party)", () => { const root = fixture(); sync({ targetRoot: root }); - assert.match(readFileSync(join(root, ".mcp.json"), "utf8"), /"mcpServers"[\s\S]*context7/); - assert.match(readFileSync(join(root, ".cursor", "mcp.json"), "utf8"), /context7/); + const mcp = readFileSync(join(root, ".mcp.json"), "utf8"); + assert.match(mcp, /"mcpServers"[\s\S]*forge-cortex/); + // P0-06: the third-party context7 server is NOT installed by default. + assert.doesNotMatch(mcp, /context7/, "context7 must not be a default MCP server"); + assert.match(readFileSync(join(root, ".cursor", "mcp.json"), "utf8"), /forge-cortex/); assert.match( readFileSync(join(root, ".zed", "settings.json"), "utf8"), - /"context_servers"[\s\S]*context7/, + /"context_servers"[\s\S]*forge-cortex/, + ); + assert.match( + readFileSync(join(root, ".vscode", "mcp.json"), "utf8"), + /"servers"[\s\S]*forge-cortex/, ); - assert.match(readFileSync(join(root, ".vscode", "mcp.json"), "utf8"), /"servers"[\s\S]*context7/); assert.match( readFileSync(join(root, ".codex", "config.toml"), "utf8"), - /\[mcp_servers\.context7\]/, + /\[mcp_servers\.forge-cortex\]/, ); assert.match( readFileSync(join(root, ".continue", "mcpServers", "forge-mcp.yaml"), "utf8"), @@ -27,6 +33,40 @@ test("sync emits MCP config into each tool's real format", () => { ); }); +test("integrations add context7 writes it (opt-in) after not being present by default", async () => { + const root = fixture(); + sync({ targetRoot: root }); + assert.doesNotMatch(readFileSync(join(root, ".mcp.json"), "utf8"), /context7/); + const m = await import("../src/integrations.js"); + assert.equal(m.planIntegration("nope").ok, false, "unknown integration rejected"); + const res = m.addIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + const after = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.ok(after.mcpServers.context7, "context7 added on opt-in"); + assert.ok(after.mcpServers["forge-cortex"], "existing forge server preserved"); +}); + +test("managed-entry update: a drifted forge server is refreshed, user servers untouched", () => { + const root = fixture(); + writeFileSync( + join(root, ".mcp.json"), + JSON.stringify({ + mcpServers: { + mine: { command: "x" }, + "forge-cortex": { command: "forge", args: ["OLD-STALE-ARG"] }, + }, + }), + ); + sync({ targetRoot: root }); + const j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.deepEqual( + j.mcpServers["forge-cortex"].args, + ["cortex-mcp"], + "stale forge entry refreshed", + ); + assert.ok(j.mcpServers.mine, "user server preserved"); +}); + test("sync emits Continue rules (Continue does not read AGENTS.md)", () => { const root = fixture(); sync({ targetRoot: root }); @@ -53,12 +93,17 @@ test("MCP merge preserves a user's own server", () => { sync({ targetRoot: root }); const j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); assert.ok(j.mcpServers.mine, "user server preserved"); - assert.ok(j.mcpServers.context7, "forge server added"); + assert.ok(j.mcpServers["forge-cortex"], "forge server added"); }); test("cortex MCP exposes substrate tools", async () => { const { handle } = await import("../src/cortex_mcp.js"); - const listed = await handle({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); + const listed = await handle({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }); const names = listed.result.tools.map((tool) => tool.name); assert.ok(names.includes("substrate_check")); assert.ok(names.includes("predict_impact")); From 5031e11429e4456ae2d115156a31565aafec5f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:22:06 +0000 Subject: [PATCH 08/10] feat(pricing+release): effective-date model pricing; require full quality gate before publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0-12: model prices support scheduled {effectiveFrom,effectiveUntil} windows resolved by priceOf(model,date). Sonnet 5 carries its $2/$10 intro rate (through 2026-08-31) and the $3/$15 successor; `forge route` shows the current effective price; docs check accepts any scheduled price. - P0-11: new reusable quality-gate workflow (tests, biome, typecheck, shellcheck, zero-dep assertion, version-drift, docs check, npm pack) is required by CI, bump, and release — a tag/publish can no longer proceed on `npm test` alone. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- .github/workflows/bump.yml | 9 ++- .github/workflows/ci.yml | 63 +++------------------ .github/workflows/release.yml | 6 +- .github/workflows/reusable-quality-gate.yml | 36 ++++++++++++ CHANGELOG.md | 8 +++ src/cli.js | 7 ++- src/commands.js | 58 +++++-------------- src/docs_check.js | 8 ++- src/model_tiers.js | 34 +++++++++++ src/model_tiers.json | 11 +++- test/model_tiers.test.js | 23 ++++++++ 11 files changed, 156 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/reusable-quality-gate.yml create mode 100644 test/model_tiers.test.js diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 3d7b54d..ed06730 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -39,7 +39,15 @@ concurrency: cancel-in-progress: false jobs: + # Never tag a tree that fails the FULL quality gate (not just tests) — P0-11. + gate: + if: >- + github.actor != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore(release):') + uses: ./.github/workflows/reusable-quality-gate.yml + bump: + needs: gate runs-on: ubuntu-latest # Never react to our own release commit (belt-and-suspenders behind GitHub's recursion guard). if: >- @@ -54,7 +62,6 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm test # never tag a broken tree - name: Bump version fields + rotate CHANGELOG id: bump # On push, always "auto"; on manual dispatch, honor the chosen input. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065e216..7caf586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ -# CI gate for every push and PR: test matrix (Node 20/22 — matches the ">=20" engines -# field; 18 is EOL), Biome lint+format, ShellCheck, the zero-runtime-dependency assertion, -# the version-drift guard, and dependency review. All must pass to merge. +# CI gate for every push and PR: the Node 20/22 test matrix (matches the ">=20" engines +# field; 18 is EOL) plus the shared quality gate (Biome, typecheck, ShellCheck, zero-dep +# assertion, version-drift, docs-drift, pack). The quality gate is the SAME reusable workflow +# the version bump and release require, so none of the three can drift from the others. name: CI on: @@ -28,59 +29,9 @@ jobs: - run: npm ci - run: npm test - lint: - name: Lint & format (Biome) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run check - - run: npm run typecheck - - run: npm audit --audit-level=critical - - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - # Fail only on errors; the guards trip style/warning false-positives (e.g. SC2034 on - # vars used cross-file via the sourced _guardlib.sh). - - run: shellcheck --severity=error $(git ls-files '*.sh') - - no-runtime-deps: - name: Assert zero runtime dependencies - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - run: node -e "process.exit(Object.keys(require('./package.json').dependencies||{}).length)" - - run: npm pack --dry-run - - version-drift: - name: Version fields agree - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - # package.json, package-lock.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, - # and CITATION.cff must all carry the same version (scripts/bump.mjs keeps them in sync). - - run: node scripts/bump.mjs check - - docs-drift: - name: Docs match code - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 # the CHANGELOG check compares src vs CHANGELOG.md commit times - - uses: actions/setup-node@v6 - with: - node-version: 22 - # Commands, env vars, MCP tools, and CHANGELOG reconciled against the code — - # a feature can't merge with its docs missing (src/docs_check.js). - - run: node src/cli.js docs check + quality-gate: + name: Quality gate + uses: ./.github/workflows/reusable-quality-gate.yml dependency-review: name: Dependency review diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab8853a..70bfe68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,12 @@ permissions: contents: read jobs: + # Publish only after the FULL quality gate passes — not just `npm test` (P0-11). + gate: + uses: ./.github/workflows/reusable-quality-gate.yml + release: + needs: gate runs-on: ubuntu-latest permissions: contents: write # create the GitHub Release @@ -40,7 +45,6 @@ jobs: registry-url: "https://registry.npmjs.org" cache: npm - run: npm ci - - run: npm test - name: Assert tag matches package.json version env: TAG: ${{ github.ref_name }} diff --git a/.github/workflows/reusable-quality-gate.yml b/.github/workflows/reusable-quality-gate.yml new file mode 100644 index 0000000..b1a0db6 --- /dev/null +++ b/.github/workflows/reusable-quality-gate.yml @@ -0,0 +1,36 @@ +# The single quality gate required before merge, tag, AND npm publish (P0-11). CI, the +# version bump, and the release all call this reusable workflow, so a release can no longer +# ship on `npm test` alone while lint/typecheck/shellcheck/docs/pack quietly fail — every +# path runs the exact same checks from one source of truth. +name: Quality gate + +on: + workflow_call: + +permissions: + contents: read + +jobs: + gate: + name: Full quality gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the docs CHANGELOG check compares src vs CHANGELOG.md commit times + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run check + - run: npm run typecheck + - run: npm audit --audit-level=critical + - name: ShellCheck + run: shellcheck --severity=error $(git ls-files '*.sh') + - name: Assert zero runtime dependencies + run: node -e "process.exit(Object.keys(require('./package.json').dependencies||{}).length)" + - run: node scripts/bump.mjs check + - run: node src/cli.js docs check + - run: npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3e457..7944735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ init` wires only forge's own server. New `forge integrations` command adds an op verifier ran), drives real test commands off the stack detector, and includes untracked files in provenance (P0-09). Ledger evidence refs are validated — a typed `git:` ref must resolve before it can affect confidence (P0-10). +- **Effective-date pricing.** Model prices support scheduled `{effectiveFrom, +effectiveUntil}` windows resolved by `priceOf(model, date)`; Sonnet 5 now carries its + $2/$10 introductory rate (through 2026-08-31) and the $3/$15 successor, and `forge route` + shows the current effective price (P0-12). +- **Release integrity.** A reusable `quality-gate` workflow (tests, Biome, typecheck, + ShellCheck, zero-dep assertion, version-drift, docs check, `npm pack`) is now required by + CI, the version bump, and the release — a tag/publish can no longer proceed on `npm test` + alone while other gates fail (P0-11). - **Honest claims & wording.** Dropped scanner "ok to install" certification language (S-01); renamed `P(defect)`→`defectRiskScore` and `residual`→`remainingUncheckedWeight` in output; reframed the preflight score as a heuristic; qualified "proof-carrying diff --git a/src/cli.js b/src/cli.js index f69b109..cb38e4b 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1468,8 +1468,13 @@ async function run(argv) { console.log(JSON.stringify(rec, null, 2)); } else { heading(`${BRAND.brand} route — cheapest capable model\n`); + const { priceOf } = await import("./model_tiers.js"); + const price = priceOf(rec.key) || { + inCost: rec.model.inCost, + outCost: rec.model.outCost, + }; console.log( - ` → ${paint(rec.model.name, "accent")} (${rec.tier}, $${rec.model.inCost}/$${rec.model.outCost} per M tok)`, + ` → ${paint(rec.model.name, "accent")} (${rec.tier}, $${price.inCost}/$${price.outCost} per M tok, current effective)`, ); console.log(` ${rec.model.use}`); console.log( diff --git a/src/commands.js b/src/commands.js index e87514c..7f733a6 100644 --- a/src/commands.js +++ b/src/commands.js @@ -10,8 +10,7 @@ export const COMMANDS = { "self-update — `--check` reports if a newer version is available, bare applies it, `--to ` pins/downgrades", taste: "enable one UI-taste tool for this repo (no arg = list)", atlas: "build / query the code-graph (where-is-Y, has-symbol)", - stack: - "detect this repo's real stack (languages, frameworks, test commands) from its manifests", + stack: "detect this repo's real stack (languages, frameworks, test commands) from its manifests", radar: "dependency-currency rings — staleness/major-lag/advisories from live registry evidence, cached 24h", recall: "manage cross-session memory (list / add / consolidate)", @@ -21,8 +20,7 @@ export const COMMANDS = { "independent verification gate — tests + hallucinated-symbol + provenance (--deep: multi-lens consensus)", precommit: "commit-level gate — staged code w/o docs + secret scan (FORGE_COMMIT_GATE=block|warn|0)", - harden: - "wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings", + harden: "wire security controls — pre-commit gate (gitleaks + commit gate) + sandbox settings", remember: "add a durable fact to this repo's portable memory (forge brain)", brain: "show / rebuild the portable project memory index", cost: "real per-day spend via ccusage + measured stage factors (--stages)", @@ -31,36 +29,26 @@ export const COMMANDS = { deja: "anti-repetition — have you done this task before? ranks prior solved/verified sessions", ledger: "evidence-referenced memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import", - reuse: - "proof-carrying code cache — query / mint --file / stats", - context: - "budgeted context assembly + completeness gate — what an edit NEEDS known", - preflight: - "assumption check — what a task names that the repo doesn't define", + reuse: "proof-carrying code cache — query / mint --file / stats", + context: "budgeted context assembly + completeness gate — what an edit NEEDS known", + preflight: "assumption check — what a task names that the repo doesn't define", config: "provider setup — show / switch / add providers, set default model", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", - substrate: - "one pre-action gate: assumptions, route, impact, scope, memory, verify", - scope: - "decompose files into independent clusters (+ coupled files you didn't name)", - anchor: - "goal-drift check — are your actual (git) changes still on the stated goal?", - handoff: - "bounded session snapshot — rewrite .forge/state.md, re-injected each session start", - decide: - "append-only decision log — D-#### ADR-lite entries in .forge/decisions.md", + substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", + scope: "decompose files into independent clusters (+ coupled files you didn't name)", + anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", + handoff: "bounded session snapshot — rewrite .forge/state.md, re-injected each session start", + decide: "append-only decision log — D-#### ADR-lite entries in .forge/decisions.md", know: "route any fact to its storage home (decision / ledger / recall / …) — total, never dropped", diagnose: "doom-loop check — record a failure; 3× the same signature mints a diagnosis + escalation", - imagine: - "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", + imagine: "consequence simulation — predicted breaks + the minimal dry-run test suite for a task", lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", uicheck: "deterministic UI checks — contrast · fingerprint · design · visual ", dash: "live dashboard: ledger, metrics trends, radar, memory browser, timeline, blast radius", - report: - "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", + report: "emit a static, self-contained HTML snapshot of .forge/ — opens offline, no server", brand: "print the active brand token map", docs: "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep)", integrations: @@ -93,26 +81,8 @@ export const GROUPS = { "imagine", "lean", ], - Quality: [ - "verify", - "precommit", - "radar", - "scan", - "spec", - "taste", - "uicheck", - "harden", - ], - Config: [ - "config", - "cost", - "dash", - "report", - "brand", - "atlas", - "stack", - "integrations", - ], + Quality: ["verify", "precommit", "radar", "scan", "spec", "taste", "uicheck", "harden"], + Config: ["config", "cost", "dash", "report", "brand", "atlas", "stack", "integrations"], }; /** Commands that exist but are deliberately not advertised in --help or docs tables. */ diff --git a/src/docs_check.js b/src/docs_check.js index 61a33e9..6d3c8c8 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -10,7 +10,7 @@ import { dirname, join, normalize } from "node:path"; import { BRAND } from "./brand.js"; import { COMMANDS, HIDDEN_COMMANDS } from "./commands.js"; import { TOOLS } from "./mcp_tools.js"; -import { MODELS } from "./model_tiers.js"; +import { allPricePairs } from "./model_tiers.js"; /** The user-facing prose docs every claim is reconciled against. */ const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md"]; @@ -284,13 +284,15 @@ function checkChangelog(root, issues) { * (the actual failure mode: a figure that went stale to a value the table no longer has). */ function checkModelTiers(docs, issues) { - const models = Object.values(MODELS); + // Accept any price across flat + scheduled windows so a documented intro/standard rate + // (effective-date pricing, P0-12) isn't mis-flagged as stale. + const pairs = allPricePairs(); const PRICE_RE = /\$(\d+)\/\$(\d+)\s*per\s*M\s*tok/gi; for (const [file, text] of Object.entries(docs)) { for (const m of text.matchAll(PRICE_RE)) { const inC = Number(m[1]); const outC = Number(m[2]); - if (!models.some((mo) => mo.inCost === inC && mo.outCost === outC)) { + if (!pairs.some((p) => p.inCost === inC && p.outCost === outC)) { issues.push({ check: "model-tiers", severity: "error", diff --git a/src/model_tiers.js b/src/model_tiers.js index 2899496..0186788 100644 --- a/src/model_tiers.js +++ b/src/model_tiers.js @@ -15,3 +15,37 @@ export const MODELS = data.models; /** Cheap → expensive. */ export const TIER_ORDER = data.tierOrder; + +/** Today as an ISO date (YYYY-MM-DD). Isolated so callers can inject a date in tests. */ +const today = () => new Date().toISOString().slice(0, 10); + +/** + * Resolve a model's price for a given date. A model may carry a `prices` schedule of + * `{effectiveFrom, effectiveUntil?, inCost, outCost}` windows (e.g. an introductory rate); + * the active window for `date` wins, otherwise we fall back to the flat inCost/outCost + * (steady-state). This is why a single `pricingVerified` date is no longer enough (P0-12). + * @param {string} key model key (haiku/sonnet/opus/fable) + * @param {string} [date] ISO date; defaults to today + * @returns {{inCost:number, outCost:number}|null} + */ +export function priceOf(key, date = today()) { + const m = MODELS[key]; + if (!m) return null; + for (const w of m.prices || []) { + if (date >= w.effectiveFrom && (!w.effectiveUntil || date <= w.effectiveUntil)) { + return { inCost: w.inCost, outCost: w.outCost }; + } + } + return { inCost: m.inCost, outCost: m.outCost }; +} + +/** Every distinct price pair across flat + scheduled windows — used by the docs check so a + * documented introductory/standard price isn't flagged as stale. */ +export function allPricePairs() { + const pairs = []; + for (const m of Object.values(MODELS)) { + pairs.push({ inCost: m.inCost, outCost: m.outCost }); + for (const w of m.prices || []) pairs.push({ inCost: w.inCost, outCost: w.outCost }); + } + return pairs; +} diff --git a/src/model_tiers.json b/src/model_tiers.json index 9ff0e69..43fd1b7 100644 --- a/src/model_tiers.json +++ b/src/model_tiers.json @@ -1,6 +1,6 @@ { "pricingCurrency": "USD", - "pricingVerified": "2026-07-05", + "pricingVerified": "2026-07-17", "models": { "haiku": { "id": "claude-haiku-4-5-20251001", @@ -16,6 +16,15 @@ "tier": "medium", "inCost": 3, "outCost": 15, + "prices": [ + { + "effectiveFrom": "2026-06-30", + "effectiveUntil": "2026-08-31", + "inCost": 2, + "outCost": 10 + }, + { "effectiveFrom": "2026-09-01", "inCost": 3, "outCost": 15 } + ], "use": "refactoring, feature work, tests, code review (the default)" }, "opus": { diff --git a/test/model_tiers.test.js b/test/model_tiers.test.js new file mode 100644 index 0000000..5a39ea7 --- /dev/null +++ b/test/model_tiers.test.js @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { allPricePairs, priceOf } from "../src/model_tiers.js"; + +test("priceOf resolves the active pricing window by date (P0-12)", () => { + // Sonnet 5 introductory pricing runs through 2026-08-31, then the standard rate. + assert.deepEqual(priceOf("sonnet", "2026-07-17"), { inCost: 2, outCost: 10 }, "intro window"); + assert.deepEqual(priceOf("sonnet", "2026-08-31"), { inCost: 2, outCost: 10 }, "intro boundary"); + assert.deepEqual(priceOf("sonnet", "2026-09-01"), { inCost: 3, outCost: 15 }, "standard window"); +}); + +test("priceOf falls back to flat cost for a model with no schedule", () => { + assert.deepEqual(priceOf("haiku", "2026-07-17"), { inCost: 1, outCost: 5 }); + assert.equal(priceOf("nope"), null); +}); + +test("allPricePairs includes both scheduled and flat prices", () => { + const pairs = allPricePairs(); + const has = (i, o) => pairs.some((p) => p.inCost === i && p.outCost === o); + assert.ok(has(2, 10), "intro sonnet price present"); + assert.ok(has(3, 15), "standard sonnet price present"); + assert.ok(has(1, 5), "haiku flat price present"); +}); From ce255ffbf9465e554ab3600e4ba99d574b195b3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 17:10:05 +0000 Subject: [PATCH 09/10] feat(product): policy profiles, repo config, obligation matrix, subsystem health, labs grouping - P1-02/P1-03: `forge init --profile` + `.forge/forge.config.json` with explicit profile/disableSections/rules override semantics; `minimal` profile ships only the five core-safety rules. - P1-04: replace the "make chain-of-thought visible" UI rule with safe-rationale guidance. - P1-05: completion gate spells out change-type obligations (code -> docs + test; config -> config docs; test-only owes nothing) instead of accepting any doc/state touch. - P1-06: `forge doctor` reports subsystem health as ACTIVE|DEGRADED|UNAVAILABLE|FAILED. - P1-01: group experimental commands under "Labs (experimental)" in help. - Also fix a protect-paths false positive (.keys()/.environment no longer read as secrets). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- CHANGELOG.md | 20 ++++++++++++++++ global/guards/protect-paths.sh | 4 +++- source/rules.json | 2 +- src/cli.js | 28 +++++++++++++++++++--- src/commands.js | 39 ++++++++++++++++-------------- src/doctor.js | 36 +++++++++++++++++++++++++++- src/gate.js | 34 +++++++++++++++++++++++--- src/init.js | 42 +++++++++++++++++++++++++++++--- src/sync.js | 44 ++++++++++++++++++++++++++++++++++ test/commands.test.js | 20 ++++++++++++++++ test/doctor.test.js | 12 ++++++++++ test/gate.test.js | 17 +++++++++++-- test/guards.test.js | 14 +++++++++++ test/sync.test.js | 27 +++++++++++++++++++++ 14 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 test/commands.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7944735..f9c26f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,26 @@ effectiveUntil}` windows resolved by `priceOf(model, date)`; Sonnet 5 now carrie memory" as evidence-referenced and "zero-config" as guided/low-configuration across the docs; added a beta status block and per-benchmark sample sizes. +### Added / changed (product) + +- **Policy profiles & repo config.** `forge init --profile minimal|standard|web-app| +backend-service|library|regulated` writes `.forge/forge.config.json`; the `minimal` + profile emits only the five core-safety rules instead of the full engineering pack + (P1-02). `forge.config.json` gives explicit override semantics — `profile`, + `disableSections` (drop by id/title), and `rules` (append) with deterministic order + (P1-03). +- **Change-obligation guidance.** The completion gate now spells out change-type + obligations (code → docs + a test, config → config docs; test-only owes nothing) so it + points at the right artifact instead of accepting any doc/state touch as done (P1-05). +- **Subsystem health.** `forge doctor` reports each key subsystem (secret-redaction, + guards, atlas, managed-config, pricing) in a standard `ACTIVE|DEGRADED|UNAVAILABLE| +FAILED` vocabulary so a degraded control stays visible (P1-06). +- **Help grouping.** Experimental commands are grouped under "Labs (experimental)" in + `forge --help`, separating the core reliability loop from experiments (P1-01). +- **UI rule.** Replaced the "make chain-of-thought visible" AI-UX rule with safe-rationale + guidance (assumptions, tool actions, sources, verification evidence — no hidden reasoning + traces) (P1-04). + ## [0.20.0] - 2026-07-17 ### Added diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index d15afbf..fc4f359 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -46,7 +46,9 @@ if [ -n "${cmd:-}" ]; then # Best-effort defence in depth — a content scan like `rg TOKEN .` with no named path can't # be caught here; that's what secret-redact.sh is for. reader='(^|[;&|])[[:space:]]*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)[[:space:]]|git[[:space:]]+(show|log)[[:space:]])' - secret='(\.env([./A-Za-z0-9_-]*)?|id_rsa|id_ed25519|\.pem|\.key|/secrets/|/\.ssh/)' + # \b anchors the extensions so `.key` matches a real key file but NOT `Object.keys`, + # and `.env` matches `.env`/`.env.prod` but NOT `.environment`. + secret='(\.env(\.[A-Za-z0-9_-]+)?\b|id_rsa\b|id_ed25519\b|\.pem\b|\.key\b|/secrets/|/\.ssh/)' if printf '%s' "$cmd" | grep -qE "$reader" && printf '%s' "$cmd" | grep -qE "$secret"; then deny "reading a protected secret path via Bash is blocked. Read it yourself if intended." fi diff --git a/source/rules.json b/source/rules.json index 2667e17..ce302ba 100644 --- a/source/rules.json +++ b/source/rules.json @@ -106,7 +106,7 @@ "Commit to an aesthetic before coding; avoid AI-slop defaults (Inter/Roboto/Arial/system fonts, purple-gradient-on-white, generic card grids). Pick 2–3 typefaces and a 4–6 color palette with semantic roles.", "Meet WCAG: body text ≥4.5:1 contrast (large/UI ≥3:1), visible :focus-visible on every interactive element, tap targets ≥24×24px, alt text + form labels, and wrap animations ≥200ms in prefers-reduced-motion.", "Empty states are never dead ends: show a plain-language message + a clear next step. Error messages name the field, explain the problem, and suggest a fix — never 'Something went wrong'.", - "For AI-driven UIs: show model confidence/uncertainty, make chain-of-thought and tool actions visible, give live preview/feedback over blind prompting, and provide fallback paths when the model fails.", + "For AI-driven UIs: show concise rationale, assumptions, tool actions, sources, verification evidence, and uncertainty — without exposing private hidden reasoning traces. Give live preview/feedback over blind prompting, and provide fallback paths when the model fails.", "Pick the interaction pattern deliberately — a co-editing canvas or inline controls beat a wall-of-text chatbot when the user is steering structured output.", "When AI audits a UI, only ASSERT the deterministic (contrast math, missing focus ring/alt/label) — keep hierarchy/taste/pattern-fit advisory so the audit doesn't hallucinate false positives." ] diff --git a/src/cli.js b/src/cli.js index cb38e4b..267052b 100755 --- a/src/cli.js +++ b/src/cli.js @@ -50,16 +50,33 @@ async function run(argv) { if (cmd === "init") { const { init } = await import("./init.js"); const noSettings = argv.includes("--no-settings"); - const { report, bytes, settings, detected } = init({ + const profileIdx = argv.indexOf("--profile"); + const profile = profileIdx >= 0 ? argv[profileIdx + 1] : undefined; + const { + report, + bytes, + settings, + detected, + profile: profileResult, + } = init({ targetRoot: process.cwd(), noSettings, + profile, }); + if (profileResult?.error) { + console.error(` ${profileResult.error}`); + process.exitCode = 1; + return; + } const wrote = report.filter((r) => r.action === "written").map((r) => r.target); heading(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); console.log( ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, ); + if (profileResult?.profile) { + console.log(` profile: ${profileResult.profile} → .forge/forge.config.json`); + } if ((settings?.action === "merged" || settings?.action === "created") && "added" in settings) { const verb = settings.action === "created" ? "created" : "merged"; const what = settings.added.length ? settings.added.join(", ") : "defaults"; @@ -260,9 +277,9 @@ async function run(argv) { } if (cmd === "doctor") { const { doctor } = await import("./doctor.js"); - const { results, failed } = doctor({ targetRoot: process.cwd() }); + const { results, failed, health } = doctor({ targetRoot: process.cwd() }); if (argv.includes("--json")) { - console.log(JSON.stringify({ results, failed }, null, 2)); + console.log(JSON.stringify({ results, failed, health }, null, 2)); if (failed) process.exitCode = 1; return; } @@ -273,6 +290,11 @@ async function run(argv) { }; heading(`${BRAND.brand} doctor\n`); for (const r of results) console.log(` ${icon[r.status]} ${r.label.padEnd(16)} ${r.note}`); + // Subsystem health in the standard vocabulary (P1-06) — a degraded control stays visible. + const healthLine = Object.entries(health) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + console.log(`\n health: ${healthLine}`); console.log( `\n${failed === 0 ? paint("all clear", "ok") : paint(`${failed} problem(s)`, "err")}`, ); diff --git a/src/commands.js b/src/commands.js index 7f733a6..6109459 100644 --- a/src/commands.js +++ b/src/commands.js @@ -55,34 +55,37 @@ export const COMMANDS = { "opt-in third-party MCP servers (e.g. context7) — shows package/network, writes only with --yes", }; +// Groups order the --help surface from the stable reliability loop down to experiments. +// "Labs" is the audit's P1-01 signal: those commands are experimental, not part of the +// core loop — grouped here so new users see the load-bearing beams first (nothing is +// removed; the full surface still ships). export const GROUPS = { - Core: ["init", "sync", "doctor", "catalog", "docs", "update"], - Memory: [ - "cortex", - "deja", - "recall", - "remember", - "brain", - "ledger", - "reuse", - "handoff", - "decide", - "know", - ], + Core: ["init", "sync", "doctor", "catalog", "docs", "update", "config"], Substrate: [ "substrate", "preflight", - "route", "impact", "scope", "context", - "anchor", - "diagnose", + "route", + "verify", + "precommit", + ], + Memory: ["cortex", "recall", "remember", "brain", "ledger", "handoff", "decide", "know"], + Quality: ["scan", "spec", "harden", "radar"], + Config: ["brand", "atlas", "stack", "integrations", "cost"], + "Labs (experimental)": [ + "taste", + "uicheck", "imagine", "lean", + "anchor", + "diagnose", + "dash", + "report", + "deja", + "reuse", ], - Quality: ["verify", "precommit", "radar", "scan", "spec", "taste", "uicheck", "harden"], - Config: ["config", "cost", "dash", "report", "brand", "atlas", "stack", "integrations"], }; /** Commands that exist but are deliberately not advertised in --help or docs tables. */ diff --git a/src/doctor.js b/src/doctor.js index 6f890bc..1075ae5 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -390,6 +390,36 @@ function checkUpdate(out) { } catch {} } +// The important subsystems and the check label each derives its health from. +const HEALTH_SUBSYSTEMS = { + "secret-redaction": "node", // secret-redact runs the JS redactor; no node → FAILED + guards: "guards exec", + atlas: "atlas", + "managed-config": "AGENTS.md", + pricing: "model pricing", +}; + +/** + * Standard subsystem-health vocabulary (P1-06): ACTIVE | DEGRADED | UNAVAILABLE | FAILED, + * derived from the SAME checks the report uses (no parallel source), so a degraded security + * or verification control is never invisible behind a green overall status. + * @param {Array<{status:string,label:string}>} results + */ +export function subsystemHealth(results) { + const state = (label) => { + const r = results.find((x) => x.label === label); + if (!r) return "UNAVAILABLE"; + if (r.status === "fail") return "FAILED"; + if (r.status === "warn") return "DEGRADED"; + return "ACTIVE"; + }; + const health = {}; + for (const [subsystem, label] of Object.entries(HEALTH_SUBSYSTEMS)) { + health[subsystem] = state(label); + } + return health; +} + export function doctor({ targetRoot = process.cwd() } = {}) { const results = []; checkNode(results); @@ -409,5 +439,9 @@ export function doctor({ targetRoot = process.cwd() } = {}) { checkCortex(results, targetRoot); checkLedger(results, targetRoot); checkUpdate(results); - return { results, failed: results.filter((r) => r.status === "fail").length }; + return { + results, + failed: results.filter((r) => r.status === "fail").length, + health: subsystemHealth(results), + }; } diff --git a/src/gate.js b/src/gate.js index 1b10f98..b120fe6 100644 --- a/src/gate.js +++ b/src/gate.js @@ -146,10 +146,25 @@ export function gateDecision({ return { allow: true, row: "no-code-class", classes }; } +/** The change-type obligation matrix (P1-05): what evidence each kind of change owes, so + * the gate points at the RIGHT artifact instead of treating any doc/state touch as done. + * Derived from the classes already computed — a pure function so it's easy to test. + * @param {{code?: string[], config?: string[], test?: string[]}} classes */ +export function obligationsFor(classes = {}) { + const out = []; + if (classes.code?.length) + out.push( + "Code changed → update the docs it affects AND add/adjust a test that exercises the new behaviour (a handoff note alone is not the obligation).", + ); + if (classes.config?.length) + out.push("Config changed → update the config/deployment docs that describe it."); + return out; +} + /** The block reason IS the repair procedure — its consumer is the agent itself, and a * checklist converts a failure into a same-turn fix. Stale-doc candidates come from the * CACHED atlas only (a hook never builds). */ -export function repairReason(root, { codeFiles = [], driftAlarm = false } = {}) { +export function repairReason(root, { codeFiles = [], driftAlarm = false, classes = {} } = {}) { let likelyDocs = []; try { const atlas = loadAtlas(root); @@ -163,9 +178,13 @@ export function repairReason(root, { codeFiles = [], driftAlarm = false } = {}) } catch {} const shown = codeFiles.slice(0, 10).join(", "); const more = codeFiles.length > 10 ? ` (+${codeFiles.length - 10} more)` : ""; + const obligations = obligationsFor(classes); const lines = [ "END-TO-END COMPLETENESS: code changed this session but no doc or state artifact moved with it.", `Changed code: ${shown}${more}`, + ...(obligations.length + ? ["Obligations for this change:", ...obligations.map((o) => `- ${o}`)] + : []), "Do what applies before finishing:", `1. \`${BRAND.cli} docs sync\` — sweep the diff for stale doc mentions${ likelyDocs.length ? ` (graph suggests: ${likelyDocs.join(", ")})` : "" @@ -242,8 +261,17 @@ export function stopGate(root, sid, hook = {}) { .filter(Number.isFinite); if (scores.length >= 3) driftAlarm = cusum(scores).alarm; } catch {} - const reason = repairReason(root, { codeFiles: decision.classes.code, driftAlarm }); - return { allow: false, row: decision.row, reason, classes: decision.classes }; + const reason = repairReason(root, { + codeFiles: decision.classes.code, + driftAlarm, + classes: decision.classes, + }); + return { + allow: false, + row: decision.row, + reason, + classes: decision.classes, + }; } catch { return { allow: true, row: "internal-error" }; } diff --git a/src/init.js b/src/init.js index 6527e17..b58ee21 100644 --- a/src/init.js +++ b/src/init.js @@ -211,13 +211,49 @@ export function mergeSettings({ settingsPath, noSettings } = {}) { }; } -/** Scaffold this repo's cross-tool config (emit every tool) in one step. */ -export function init({ targetRoot = process.cwd(), noSettings = false } = {}) { +/** Valid policy profiles (P1-02). `standard` is the full engineering pack (default). */ +export const PROFILES = [ + "minimal", + "standard", + "web-app", + "backend-service", + "library", + "regulated", +]; + +/** Persist a chosen profile to `.forge/forge.config.json` so `sync` applies it. Merges into + * any existing config rather than clobbering it. Returns the resolved profile or null. */ +function writeProfile(targetRoot, profile) { + if (!profile) return null; + if (!PROFILES.includes(profile)) return { error: `unknown profile: ${profile}` }; + const dir = join(targetRoot, ".forge"); + const path = join(dir, "forge.config.json"); + /** @type {Record} */ + let cfg = {}; + if (existsSync(path)) { + try { + cfg = JSON.parse(readFileSync(path, "utf8")) || {}; + } catch { + cfg = {}; + } + } + cfg.profile = profile; + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`); + return { profile }; +} + +/** + * Scaffold this repo's cross-tool config (emit every tool) in one step. + * @param {{targetRoot?: string, noSettings?: boolean, profile?: string}} [opts] + */ +export function init({ targetRoot = process.cwd(), noSettings = false, profile } = {}) { + const profileResult = writeProfile(targetRoot, profile); const r = sync({ targetRoot }); ensureLedgerGitattributes(targetRoot); const settings = mergeSettings({ noSettings }); const detected = autoDetectProvider(); - return { ...r, settings, detected }; + return { ...r, settings, detected, profile: profileResult }; } function skillDescription(dir) { diff --git a/src/sync.js b/src/sync.js index a2dae0e..7042402 100644 --- a/src/sync.js +++ b/src/sync.js @@ -34,13 +34,57 @@ export function assemble(rules) { return `${out.join("\n").trimEnd()}\n`; } +// The `minimal` profile: only the five non-negotiable safety rules, for repos that don't +// want forge's full engineering-philosophy pack imposed on an existing architecture (P1-02). +const MINIMAL_SECTION = { + id: "core-safety", + title: "Core safety", + rules: [ + "Never expose or write secrets, tokens, or keys into code, commits, or output.", + "Inspect the surrounding code before editing; match the existing conventions.", + "Verify before claiming completion — run tests/build/lint and show the command + output.", + "Respect the repository's existing architecture and conventions over any default.", + "Ask before destructive or irreversible actions (rm -rf, history rewrite, prod changes).", + ], +}; + +/** Read the optional per-repo config (`.forge/forge.config.json`). Invalid JSON is ignored + * (fail-open) so a typo can't break `forge sync`. */ +export function loadConfig(targetRoot) { + const p = join(targetRoot, ".forge/forge.config.json"); + if (!existsSync(p)) return {}; + try { + const cfg = JSON.parse(readFileSync(p, "utf8")); + return cfg && typeof cfg === "object" ? cfg : {}; + } catch { + return {}; + } +} + +/** + * Resolve the rule set for a repo with explicit, deterministic override semantics (P1-03): + * 1. profile — `minimal` replaces the pack with the core-safety section; otherwise the + * full source pack. + * 2. disableSections — drop sections by id or title. + * 3. appends — legacy `.forge/rules.json` sections, then `config.rules` sections. + */ function loadRules(targetRoot) { + const cfg = loadConfig(targetRoot); const base = JSON.parse(readFileSync(join(BRAND.root, "source/rules.json"), "utf8")); + if (cfg.profile === "minimal") { + base.sections = [MINIMAL_SECTION]; + } else if (Array.isArray(cfg.disableSections) && cfg.disableSections.length) { + const drop = new Set(cfg.disableSections); + base.sections = (base.sections || []).filter((s) => !drop.has(s.id) && !drop.has(s.title)); + } const override = join(targetRoot, ".forge/rules.json"); if (existsSync(override)) { const extra = JSON.parse(readFileSync(override, "utf8")); base.sections = [...(base.sections || []), ...(extra.sections || [])]; } + if (Array.isArray(cfg.rules) && cfg.rules.length) { + base.sections = [...(base.sections || []), ...cfg.rules]; + } return base; } diff --git a/test/commands.test.js b/test/commands.test.js new file mode 100644 index 0000000..53914ff --- /dev/null +++ b/test/commands.test.js @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { COMMANDS, GROUPS, HIDDEN_COMMANDS } from "../src/commands.js"; + +test("every command appears in exactly one help group (P1-01 grouping stays complete)", () => { + const cmds = Object.keys(COMMANDS); + const grouped = Object.values(GROUPS).flat(); + const missing = cmds.filter((c) => !grouped.includes(c)); + const dupes = grouped.filter((c, i) => grouped.indexOf(c) !== i); + const phantom = grouped.filter((c) => !cmds.includes(c) && !HIDDEN_COMMANDS.includes(c)); + assert.deepEqual(missing, [], "commands missing from any group"); + assert.deepEqual(dupes, [], "commands listed in more than one group"); + assert.deepEqual(phantom, [], "group lists a non-existent command"); +}); + +test("Labs group exists and holds experimental commands (not the core loop)", () => { + const labs = GROUPS["Labs (experimental)"] || []; + assert.ok(labs.includes("taste") && labs.includes("imagine"), "labs holds experiments"); + assert.ok(!labs.includes("verify") && !labs.includes("substrate"), "core loop is not in labs"); +}); diff --git a/test/doctor.test.js b/test/doctor.test.js index 58474d1..47d40bf 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -8,6 +8,18 @@ import { doctor } from "../src/doctor.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-doctor-")); +test("doctor reports subsystem health in the standard vocabulary (P1-06)", () => { + const root = fixture(); + const { health } = doctor({ targetRoot: root }); + const allowed = new Set(["ACTIVE", "DEGRADED", "UNAVAILABLE", "FAILED"]); + for (const key of ["secret-redaction", "guards", "atlas", "managed-config", "pricing"]) { + assert.ok(key in health, `health reports ${key}`); + assert.ok(allowed.has(health[key]), `${key}=${health[key]} is a valid state`); + } + // node is present in this runtime, so redaction is ACTIVE (not silently missing). + assert.equal(health["secret-redaction"], "ACTIVE"); +}); + test("doctor warns when a repo has more than ~6 MCP servers", () => { const root = fixture(); const servers = {}; diff --git a/test/gate.test.js b/test/gate.test.js index e44c702..00b819c 100644 --- a/test/gate.test.js +++ b/test/gate.test.js @@ -1,6 +1,17 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { classifyPath, gateDecision } from "../src/gate.js"; +import { classifyPath, gateDecision, obligationsFor } from "../src/gate.js"; + +test("obligationsFor derives change-type obligations (P1-05)", () => { + const code = obligationsFor({ code: ["src/x.js"] }); + assert.ok( + code.some((o) => /test/.test(o)), + "code change obliges a test, not just a handoff note", + ); + const config = obligationsFor({ config: ["Dockerfile"] }); + assert.ok(config.some((o) => /config/i.test(o))); + assert.deepEqual(obligationsFor({ test: ["x.test.js"] }), [], "test-only owes no prose"); +}); test("classifyPath: one total function from the shared registries", () => { assert.equal(classifyPath(".forge/state.md"), "docs", "state snapshot IS the doc signal"); @@ -38,7 +49,9 @@ test("gate table: guard rows always allow", () => { test("gate table: clean and internal-only sessions owe nothing", () => { assert.equal(gateDecision({ changed: [] }).row, "no-changes"); - const internal = gateDecision({ changed: [".forge/lessons/a.md", "AGENTS.md"] }); + const internal = gateDecision({ + changed: [".forge/lessons/a.md", "AGENTS.md"], + }); assert.equal(internal.row, "no-changes", "internal artifacts never trigger the gate"); assert.equal(internal.allow, true); }); diff --git a/test/guards.test.js b/test/guards.test.js index 0f977c2..8bd9cc5 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -68,6 +68,20 @@ test("protect-paths allows a normal Bash read (exit 0)", () => { assert.equal(r.code, 0); }); +test("protect-paths does not false-positive on .keys()/.environment (extension-anchored)", () => { + for (const command of [ + 'grep -n foo src/x.js; node -e "Object.keys(r)"', + "cat src/environment.js", + "rg keyword docs/", + ]) { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command }, + }); + assert.equal(r.code, 0, `must not block: ${command}`); + } +}); + test("protect-paths does not false-positive on prose mentioning secrets in a quoted arg", () => { // A commit message that merely names cat/.env/git show must not be blocked — the reader // is anchored to a command boundary, so text inside a quoted arg is safe. diff --git a/test/sync.test.js b/test/sync.test.js index 92ee886..169afa8 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -88,3 +88,30 @@ test("per-repo .forge/rules.json extends the shared source", () => { sync({ targetRoot: root }); assert.match(readFileSync(join(root, "AGENTS.md"), "utf8"), /## ProjectX/); }); + +test("minimal profile emits only the core-safety section (P1-02)", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/forge.config.json"), JSON.stringify({ profile: "minimal" })); + sync({ targetRoot: root }); + const md = readFileSync(join(root, "AGENTS.md"), "utf8"); + assert.match(md, /## Core safety/, "core-safety present"); + assert.doesNotMatch(md, /## AI interfaces & design quality/, "full pack sections dropped"); +}); + +test("config disableSections drops a named section; config.rules appends (P1-03)", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + join(root, ".forge/forge.config.json"), + JSON.stringify({ + disableSections: ["ai-ux"], + rules: [{ title: "ProjectY", rules: ["custom rule"] }], + }), + ); + sync({ targetRoot: root }); + const md = readFileSync(join(root, "AGENTS.md"), "utf8"); + assert.doesNotMatch(md, /## AI interfaces & design quality/, "disabled section dropped"); + assert.match(md, /## ProjectY/, "config.rules appended"); + assert.match(md, /## Workflow/, "other sections preserved"); +}); From 92b7d8d6b81474f87ffada8130241b2f4d0ef1ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 17:32:39 +0000 Subject: [PATCH 10/10] chore(gitleaks): allowlist the placeholder token in the redactor test The secret-redact test uses an obviously-fake sequential-hex ghp_ token as a fixture to prove redaction works; gitleaks flagged it as a github-pat. Narrowly allowlist that exact placeholder so real-secret detection stays intact everywhere. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbJ3efKguNwyi5tMghvy8i --- .gitleaks.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 86c44e0..ca1c7e7 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -13,3 +13,8 @@ paths = [ '''^research/formal-synthesis/graded_reference_set\.json$''', '''^research/formal-synthesis/merged_references\.json$''', ] +# Obvious placeholder credentials used as test fixtures to exercise the secret +# redactor/scanner — sequential-hex tokens that are unmistakably fake, not real secrets. +regexes = [ + '''ghp_0123456789abcdef0123456789abcdef0123''', +]