infernoflow 0.44.19 → 0.44.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -267,13 +267,14 @@ That's the whole product. No vendor lock-in (it's JSONL on disk). No SaaS. One C
267
267
 
268
268
  Local-first by design:
269
269
 
270
- - 🚫 **No telemetry.** No analytics, no error reporting, no install pings.
270
+ - ✅ **Telemetry is opt-in and off by default.** After a few runs in an interactive terminal infernoflow asks once; nothing is sent unless you answer yes. It never sends code, file paths or memory content. Check or change it with `infernoflow telemetry status`.
271
271
  - 🚫 **No `postinstall` script.** `npm install -g infernoflow` runs no code — it only copies files.
272
- - 🚫 **No network calls in any default command path.** Everything runs on your machine.
273
- - 🚫 **No auto-updates, no background processes, no cloud sync.**
274
- - ✅ **Reads and writes only inside your project directory** (`.ai-memory/`, plus the three rule files at repo root).
272
+ - 🚫 **No network calls in any default command path** (with telemetry off, which is the default).
273
+ - 🚫 **No auto-updates of the package, no background processes, no cloud sync.**
274
+ - ⚠️ **Writes outside the project:** `setup` (and the automatic refresh after an upgrade) registers the MCP server in your AI tools' config files — `~/.claude.json` and the Claude Desktop config. Everything else stays in the project (`.ai-memory/`, `.cursor/`, `.claude/`, `.vscode/`, the three rule files). Moving these registrations into per-project config is in progress.
275
275
  - ✅ **Auto-injected content is wrapped in markers** (`<!-- infernoflow:start -->` / `<!-- infernoflow:end -->`) — your manual edits outside the block are never touched.
276
- - ✅ **Secret patterns rejected on capture** — entries matching `sk-`, `ghp_`, `-----BEGIN` are refused at the AMP writer.
276
+ - ⚠️ **Memory files are plain text and are committed with your repo.** Don't paste secrets into prompts or log messages — automatic secret redaction is not implemented yet.
277
+ - ✅ **No shell is used to run commands** from the MCP server or the prompt hooks (since 0.44.20), and MCP tool arguments are validated against their schema.
277
278
 
278
279
  The optional `infernoflow ai setup` command wires an AI provider (Anthropic / OpenAI / Google / Ollama) for a few enrichment commands — same trust model as using that provider directly. Off by default.
279
280
 
@@ -1,15 +1,17 @@
1
- import*as a from"node:fs";import*as u from"node:path";import*as v from"node:os";import{fileURLToPath as $}from"node:url";import{execSync as N}from"node:child_process";import{detectIdeContext as I}from"../ai/ideDetection.mjs";import{header as J,ok as h,warn as M,info as w,done as D,cyan as d,yellow as C,bold as k,green as m,gray as j}from"../ui/output.mjs";import"../cursorHooksInstall.mjs";import"../vsCodeCopilotHooksInstall.mjs";import{updateInjectionConfig as E}from"../amp/io.mjs";import{findProjectRoot as T}from"../projectRoot.mjs";import{injectionPatchFromArgs as A}from"./refresh.mjs";const L=u.dirname($(import.meta.url));function _(){return u.resolve(L,"../../templates")}function W(t){try{return N(`npx infernoflow ${t}`,{encoding:"utf8",cwd:process.cwd(),timeout:6e4,stdio:["inherit","pipe","pipe"]})}catch(i){return i.stdout||i.stderr||i.message}}function y(t){if(!a.existsSync(t))return{data:{},existed:!1,corrupt:!1,backup:null};let i;try{i=a.readFileSync(t,"utf8")}catch{return{data:{},existed:!0,corrupt:!1,backup:null}}try{const s=JSON.parse(i);return!s||typeof s!="object"||Array.isArray(s)?{data:{},existed:!0,corrupt:!1,backup:null}:{data:s,existed:!0,corrupt:!1,backup:null}}catch{let s=null;try{s=`${t}.corrupt-${Date.now()}.bak`,a.writeFileSync(s,i,"utf8")}catch{s=null}return{data:{},existed:!0,corrupt:!0,backup:s}}}function H(){const t=v.homedir();return process.platform==="win32"?u.join(t,"AppData","Roaming","Claude","claude_desktop_config.json"):process.platform==="darwin"?u.join(t,"Library","Application Support","Claude","claude_desktop_config.json"):u.join(t,".config","Claude","claude_desktop_config.json")}function U(t,i){const s=H(),n=u.dirname(s);if(!a.existsSync(n)&&!a.existsSync(s))return{updated:!1,skipped:"not-installed"};const{data:r,backup:e}=y(s);r.mcpServers||(r.mcpServers={});const l=r.mcpServers.infernoflow;if(l&&Array.isArray(l.args)&&l.args[0]===t&&l.env&&l.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:e};const c={...l&&l.env?l.env:{}};i&&(c.INFERNOFLOW_PROJECT_DIR=i),r.mcpServers.infernoflow={command:"node",args:[t],env:c};const p=JSON.stringify(r,null,2);return a.mkdirSync(n,{recursive:!0}),a.writeFileSync(s,p,"utf8"),{updated:!0,path:s,backup:e}}function V(t){const i=u.join(t,".git");if(!a.existsSync(i))return{installed:!1,skipped:"not-a-git-repo"};const s=u.join(i,"hooks"),n=u.join(s,"post-commit"),r='infernoflow log "commit: $(git log -1 --pretty=%s)" --type note --source git-hook --auto --quiet >/dev/null 2>&1 || true';try{if(a.mkdirSync(s,{recursive:!0}),a.existsSync(n)){const e=a.readFileSync(n,"utf8");if(e.includes("infernoflow"))return{installed:!1,already:!0};const l=e.replace(/\s*$/,"")+`
1
+ import*as a from"node:fs";import*as u from"node:path";import*as w from"node:os";import{fileURLToPath as N}from"node:url";import{execSync as I}from"node:child_process";import{detectIdeContext as M}from"../ai/ideDetection.mjs";import{header as J,ok as k,warn as E,info as v,done as D,cyan as p,yellow as C,bold as h,green as g,gray as j}from"../ui/output.mjs";import"../cursorHooksInstall.mjs";import{refreshSecuritySensitiveCopies as L}from"../securityRefresh.mjs";import"../vsCodeCopilotHooksInstall.mjs";import{updateInjectionConfig as T}from"../amp/io.mjs";import{findProjectRoot as A}from"../projectRoot.mjs";import{injectionPatchFromArgs as H}from"./refresh.mjs";const W=u.dirname(N(import.meta.url));function _(){return u.resolve(W,"../../templates")}function U(t){try{return I(`npx infernoflow ${t}`,{encoding:"utf8",cwd:process.cwd(),timeout:6e4,stdio:["inherit","pipe","pipe"]})}catch(i){return i.stdout||i.stderr||i.message}}function y(t){if(!a.existsSync(t))return{data:{},existed:!1,corrupt:!1,backup:null};let i;try{i=a.readFileSync(t,"utf8")}catch{return{data:{},existed:!0,corrupt:!1,backup:null}}try{const s=JSON.parse(i);return!s||typeof s!="object"||Array.isArray(s)?{data:{},existed:!0,corrupt:!1,backup:null}:{data:s,existed:!0,corrupt:!1,backup:null}}catch{let s=null;try{s=`${t}.corrupt-${Date.now()}.bak`,a.writeFileSync(s,i,"utf8")}catch{s=null}return{data:{},existed:!0,corrupt:!0,backup:s}}}function V(){const t=w.homedir();return process.platform==="win32"?u.join(t,"AppData","Roaming","Claude","claude_desktop_config.json"):process.platform==="darwin"?u.join(t,"Library","Application Support","Claude","claude_desktop_config.json"):u.join(t,".config","Claude","claude_desktop_config.json")}function K(t,i){const s=V(),n=u.dirname(s);if(!a.existsSync(n)&&!a.existsSync(s))return{updated:!1,skipped:"not-installed"};const{data:r,backup:o}=y(s);r.mcpServers||(r.mcpServers={});const l=r.mcpServers.infernoflow;if(l&&Array.isArray(l.args)&&l.args[0]===t&&l.env&&l.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:o};const c={...l&&l.env?l.env:{}};i&&(c.INFERNOFLOW_PROJECT_DIR=i),r.mcpServers.infernoflow={command:"node",args:[t],env:c};const d=JSON.stringify(r,null,2);return a.mkdirSync(n,{recursive:!0}),a.writeFileSync(s,d,"utf8"),{updated:!0,path:s,backup:o}}function q(t){const i=u.join(t,".git");if(!a.existsSync(i))return{installed:!1,skipped:"not-a-git-repo"};const s=u.join(i,"hooks"),n=u.join(s,"post-commit"),r='infernoflow log "commit: $(git log -1 --pretty=%s)" --type note --source git-hook --auto --quiet >/dev/null 2>&1 || true';try{if(a.mkdirSync(s,{recursive:!0}),a.existsSync(n)){const o=a.readFileSync(n,"utf8");if(o.includes("infernoflow"))return{installed:!1,already:!0};const l=o.replace(/\s*$/,"")+`
2
2
 
3
3
  # infernoflow auto-capture
4
4
  `+r+`
5
- `;a.writeFileSync(n,l,"utf8")}else{const e=["#!/bin/sh","# infernoflow: auto-capture the commit subject into session memory.","# Best-effort and non-blocking \u2014 never fails a commit.",r,""].join(`
6
- `);a.writeFileSync(n,e,"utf8")}try{a.chmodSync(n,493)}catch{}return{installed:!0,path:n}}catch(e){return{installed:!1,error:e.message}}}const q=`#!/usr/bin/env node
5
+ `;a.writeFileSync(n,l,"utf8")}else{const o=["#!/bin/sh","# infernoflow: auto-capture the commit subject into session memory.","# Best-effort and non-blocking \u2014 never fails a commit.",r,""].join(`
6
+ `);a.writeFileSync(n,o,"utf8")}try{a.chmodSync(n,493)}catch{}return{installed:!0,path:n}}catch(o){return{installed:!1,error:o.message}}}const B=2,b=`#!/usr/bin/env node
7
7
  // infernoflow UserPromptSubmit hook (Claude Code / Cursor).
8
+ // infernoflow-hook-version: ${B}
8
9
  // Logs a best-effort 'attempt' entry when the user's prompt shows frustration,
9
10
  // so the highest-value capture signal doesn't depend on the model remembering.
10
- // Never blocks the prompt.
11
- import { readFileSync } from "node:fs";
12
- import { spawnSync } from "node:child_process";
11
+ // Never blocks the prompt. Never uses a shell.
12
+ import { readFileSync, existsSync, realpathSync } from "node:fs";
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
+ import { dirname, join } from "node:path";
13
15
 
14
16
  let raw = "";
15
17
  try { raw = readFileSync(0, "utf8"); } catch {}
@@ -17,19 +19,43 @@ let prompt = "";
17
19
  try { const j = JSON.parse(raw); prompt = j.prompt || j.user_prompt || j.userPrompt || ""; }
18
20
  catch { prompt = raw; }
19
21
 
20
- const MARKERS = [/!!+/, /not working/i, /still (broken|failing|not)/i, /does ?n'?t work/i, /\\bbroken\\b/i, /\\bretry(ing)?\\b/i, /same error/i, /no change/i];
21
- if (prompt && MARKERS.some((re) => re.test(prompt))) {
22
- const msg = "User frustration: " + prompt.replace(/\\s+/g, " ").trim().slice(0, 120);
22
+ // Find the CLI's JavaScript entry point (never the .cmd / shell wrapper).
23
+ function findCliMjs() {
24
+ let hits = [];
23
25
  try {
24
- spawnSync("infernoflow", ["log", msg, "--type", "attempt", "--result", "failed", "--auto", "--quiet", "--source", "hook"], {
25
- stdio: "ignore", timeout: 5000, shell: process.platform === "win32",
26
- });
26
+ const finder = process.platform === "win32" ? "where" : "which";
27
+ hits = execFileSync(finder, ["infernoflow"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, timeout: 3000 })
28
+ .split(/\\r?\\n/).map((s) => s.trim()).filter(Boolean);
27
29
  } catch {}
30
+ for (const c of hits) {
31
+ try { const real = realpathSync(c); if (/\\.m?js$/i.test(real)) return real; } catch {}
32
+ const d = dirname(c);
33
+ for (const pkg of [join(d, "node_modules", "infernoflow"), join(d, "..", "lib", "node_modules", "infernoflow")]) {
34
+ for (const f of [join(pkg, "dist", "bin", "infernoflow.mjs"), join(pkg, "bin", "infernoflow.mjs")]) {
35
+ if (existsSync(f)) return f;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ const MARKERS = [/!!+/, /not working/i, /still (broken|failing|not)/i, /does ?n'?t work/i, /\\bbroken\\b/i, /\\bretry(ing)?\\b/i, /same error/i, /no change/i];
43
+ if (prompt && MARKERS.some((re) => re.test(prompt))) {
44
+ // Leading dashes are stripped so the text can never be read as a CLI flag.
45
+ const msg = "User frustration: " + prompt.replace(/\\s+/g, " ").trim().replace(/^[\\s-]+/, "").slice(0, 120);
46
+ const cli = findCliMjs();
47
+ if (cli) {
48
+ try {
49
+ spawnSync(process.execPath, [cli, "log", msg, "--type", "attempt", "--result", "failed", "--auto", "--quiet", "--source", "hook"], {
50
+ stdio: "ignore", timeout: 5000, windowsHide: true, shell: false,
51
+ });
52
+ } catch {}
53
+ }
28
54
  }
29
55
  process.exit(0);
30
- `;function K(t){const i=u.join(t,".claude","hooks"),s=u.join(i,"log-frustration.mjs");try{a.mkdirSync(i,{recursive:!0}),a.writeFileSync(s,q,"utf8");try{a.chmodSync(s,493)}catch{}}catch(c){return{installed:!1,error:c.message}}const n=u.join(t,".claude","settings.json"),{data:r}=y(n);(!r.hooks||typeof r.hooks!="object")&&(r.hooks={}),Array.isArray(r.hooks.UserPromptSubmit)||(r.hooks.UserPromptSubmit=[]);const e="node .claude/hooks/log-frustration.mjs",l=r.hooks.UserPromptSubmit.some(c=>Array.isArray(c?.hooks)&&c.hooks.some(p=>typeof p?.command=="string"&&p.command.includes("log-frustration.mjs")));l||r.hooks.UserPromptSubmit.push({hooks:[{type:"command",command:e}]});try{a.mkdirSync(u.dirname(n),{recursive:!0}),a.writeFileSync(n,JSON.stringify(r,null,2),"utf8")}catch(c){return{installed:!0,registered:!1,error:c.message}}return{installed:!0,registered:!l}}const G=["infernoflow_status","infernoflow_run","infernoflow_apply","infernoflow_check","infernoflow_context","infernoflow_implement","infernoflow_git_drift","infernoflow_scan_ui","infernoflow_review","amp_read","amp_write","amp_search","amp_handoff","amp_health"];function B(t,i){const s=u.join(v.homedir(),".claude.json"),{data:n,backup:r}=y(s);n.mcpServers||(n.mcpServers={});const e=n.mcpServers.infernoflow;if(e&&e.args&&e.args[0]===t&&e.env&&e.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:r};const l={...e&&e.env?e.env:{}};i&&(l.INFERNOFLOW_PROJECT_DIR=i),n.mcpServers.infernoflow={command:"node",args:[t],env:l};const c=JSON.stringify(n,null,2).replace(/\u0000+/g,"");return a.writeFileSync(s,c,"utf8"),{updated:!0,path:s,backup:r}}function z(t,i,s){const n=u.join(t,".vscode"),r=u.join(n,"mcp.json"),{data:e,backup:l}=y(r);e.servers||(e.servers={});const c=e.servers.infernoflow;if(c&&c.args&&c.args[0]===i&&c.env&&c.env.INFERNOFLOW_PROJECT_DIR===s)return{updated:!1,backup:l};const p={...c&&c.env?c.env:{}};return s&&(p.INFERNOFLOW_PROJECT_DIR=s),e.servers.infernoflow={type:"stdio",command:"node",args:[i],env:p},a.mkdirSync(n,{recursive:!0}),a.writeFileSync(r,JSON.stringify(e,null,2)+`
31
- `,"utf8"),{updated:!0,path:r,backup:l}}function Q(t,i){const s=u.join(t,".claude"),n=u.join(s,"settings.json"),{data:r}=y(n),e=new Set(r.allowedTools||[]);for(const c of G)e.add(`mcp__infernoflow__${c}`);const l={...r,allowedTools:[...e]};return a.mkdirSync(s,{recursive:!0}),a.writeFileSync(n,JSON.stringify(l,null,2),"utf8"),n}function X(t,{silent:i=!1}={}){const s=_(),n=i?()=>{}:o=>h(o),r=i?()=>{}:o=>M(o),e={mcpServer:!1,claudeJson:!1,claudeSettings:!1,claudeDesktop:!1,gitHooks:!1,captureHook:!1,backups:[]},l=u.join(s,"cursor","inferno-mcp-server.mjs"),c=u.join(t,".cursor","inferno-mcp-server.mjs"),p=(()=>{try{return T(t)}catch{return t}})();try{a.existsSync(c)||(a.mkdirSync(u.dirname(c),{recursive:!0}),a.copyFileSync(l,c),e.mcpServer=!0,n("Copied MCP server \u2192 "+d(".cursor/inferno-mcp-server.mjs")))}catch(o){r("MCP server copy skipped: "+o.message)}try{const o=B(c,p);o.updated&&(e.claudeJson=!0,n("Registered MCP server in "+d("~/.claude.json"))),o.backup&&(e.backups.push(o.backup),r("~/.claude.json was unreadable \u2014 backed up to "+o.backup+" before rewrite"))}catch(o){r("~/.claude.json update skipped: "+o.message)}try{z(t,c,p).updated&&(e.vscodeMcp=!0,n("Registered MCP server in "+d(".vscode/mcp.json")+j(" (Copilot Chat)")))}catch(o){r(".vscode/mcp.json update skipped: "+o.message)}try{const o=u.join(t,".cursor","mcp.json"),{data:g}=y(o);g.mcpServers||(g.mcpServers={});const f=g.mcpServers.infernoflow;(!f||!f.args||f.args[0]!==c||!f.env||f.env.INFERNOFLOW_PROJECT_DIR!==p)&&(g.mcpServers.infernoflow={command:"node",args:[c],env:{...f&&f.env?f.env:{},INFERNOFLOW_PROJECT_DIR:p}},a.mkdirSync(u.dirname(o),{recursive:!0}),a.writeFileSync(o,JSON.stringify(g,null,2)+`
32
- `,"utf8"),e.cursorMcp=!0,n("Registered MCP server in "+d(".cursor/mcp.json")))}catch(o){r(".cursor/mcp.json update skipped: "+o.message)}try{Q(t,!1),e.claudeSettings=!0,n("Pre-approved infernoflow tools in "+d(".claude/settings.json"))}catch(o){r(".claude/settings.json skipped: "+o.message)}try{const o=U(c,p);o.updated&&(e.claudeDesktop=!0,n("Registered MCP server in "+d("claude_desktop_config.json")+j(" (Claude Desktop app)"))),o.backup&&(e.backups.push(o.backup),r("Claude Desktop config was unreadable \u2014 backed up to "+o.backup))}catch(o){r("Claude Desktop config skipped: "+o.message)}try{V(t).installed&&(e.gitHooks=!0,n("Installed git post-commit hook \u2192 "+d(".git/hooks/post-commit")))}catch(o){r("git hook install skipped: "+o.message)}try{K(t).installed&&(e.captureHook=!0,n("Installed capture hook \u2192 "+d(".claude/hooks/log-frustration.mjs")))}catch(o){r("capture hook install skipped: "+o.message)}return e}async function le(t){const i=process.cwd(),s=t.includes("--force")||t.includes("-f"),n=t.includes("--yes")||t.includes("-y"),r=_();J("infernoflow setup");const{ideDetected:e}=I("auto");w(`IDE detected: ${k(e==="cursor"?"Cursor":e==="vscode"?"VS Code":e==="windsurf"?"Windsurf":"unknown")}`);const c=u.join(i,".ai-memory");a.existsSync(c)?h(".ai-memory/ already exists \u2014 skipping init"):(console.log(`
56
+ `;function G(t){const i=u.join(t,".claude","hooks"),s=u.join(i,"log-frustration.mjs");try{a.mkdirSync(i,{recursive:!0}),a.writeFileSync(s,b,"utf8");try{a.chmodSync(s,493)}catch{}}catch(c){return{installed:!1,error:c.message}}const n=u.join(t,".claude","settings.json"),{data:r}=y(n);(!r.hooks||typeof r.hooks!="object")&&(r.hooks={}),Array.isArray(r.hooks.UserPromptSubmit)||(r.hooks.UserPromptSubmit=[]);const o="node .claude/hooks/log-frustration.mjs",l=r.hooks.UserPromptSubmit.some(c=>Array.isArray(c?.hooks)&&c.hooks.some(d=>typeof d?.command=="string"&&d.command.includes("log-frustration.mjs")));l||r.hooks.UserPromptSubmit.push({hooks:[{type:"command",command:o}]});try{a.mkdirSync(u.dirname(n),{recursive:!0}),a.writeFileSync(n,JSON.stringify(r,null,2),"utf8")}catch(c){return{installed:!0,registered:!1,error:c.message}}return{installed:!0,registered:!l}}const z=["infernoflow_status","infernoflow_run","infernoflow_apply","infernoflow_check","infernoflow_context","infernoflow_implement","infernoflow_git_drift","infernoflow_scan_ui","infernoflow_review","amp_read","amp_write","amp_search","amp_handoff","amp_health"];function Q(t,i){const s=u.join(w.homedir(),".claude.json"),{data:n,backup:r}=y(s);n.mcpServers||(n.mcpServers={});const o=n.mcpServers.infernoflow;if(o&&o.args&&o.args[0]===t&&o.env&&o.env.INFERNOFLOW_PROJECT_DIR===i)return{updated:!1,backup:r};const l={...o&&o.env?o.env:{}};i&&(l.INFERNOFLOW_PROJECT_DIR=i),n.mcpServers.infernoflow={command:"node",args:[t],env:l};const c=JSON.stringify(n,null,2).replace(/\u0000+/g,"");return a.writeFileSync(s,c,"utf8"),{updated:!0,path:s,backup:r}}function X(t,i,s){const n=u.join(t,".vscode"),r=u.join(n,"mcp.json"),{data:o,backup:l}=y(r);o.servers||(o.servers={});const c=o.servers.infernoflow;if(c&&c.args&&c.args[0]===i&&c.env&&c.env.INFERNOFLOW_PROJECT_DIR===s)return{updated:!1,backup:l};const d={...c&&c.env?c.env:{}};return s&&(d.INFERNOFLOW_PROJECT_DIR=s),o.servers.infernoflow={type:"stdio",command:"node",args:[i],env:d},a.mkdirSync(n,{recursive:!0}),a.writeFileSync(r,JSON.stringify(o,null,2)+`
57
+ `,"utf8"),{updated:!0,path:r,backup:l}}function Y(t,i){const s=u.join(t,".claude"),n=u.join(s,"settings.json"),{data:r}=y(n),o=new Set(r.allowedTools||[]);for(const c of z)o.add(`mcp__infernoflow__${c}`);const l={...r,allowedTools:[...o]};return a.mkdirSync(s,{recursive:!0}),a.writeFileSync(n,JSON.stringify(l,null,2),"utf8"),n}function Z(t,{silent:i=!1}={}){const s=_(),n=i?()=>{}:e=>k(e),r=i?()=>{}:e=>E(e),o={mcpServer:!1,claudeJson:!1,claudeSettings:!1,claudeDesktop:!1,gitHooks:!1,captureHook:!1,backups:[]},l=u.join(s,"cursor","inferno-mcp-server.mjs"),c=u.join(t,".cursor","inferno-mcp-server.mjs"),d=(()=>{try{return A(t)}catch{return t}})();try{a.existsSync(c)||(a.mkdirSync(u.dirname(c),{recursive:!0}),a.copyFileSync(l,c),o.mcpServer=!0,n("Copied MCP server \u2192 "+p(".cursor/inferno-mcp-server.mjs")))}catch(e){r("MCP server copy skipped: "+e.message)}try{const e=L(t,{captureHookScript:b});e.length&&(o.securityRefreshed=e,e.some(f=>f.endsWith("inferno-mcp-server.mjs"))&&(o.mcpServer=!0),n("Security update: replaced outdated "+e.map(f=>p(f)).join(", ")))}catch{}try{const e=Q(c,d);e.updated&&(o.claudeJson=!0,n("Registered MCP server in "+p("~/.claude.json"))),e.backup&&(o.backups.push(e.backup),r("~/.claude.json was unreadable \u2014 backed up to "+e.backup+" before rewrite"))}catch(e){r("~/.claude.json update skipped: "+e.message)}try{X(t,c,d).updated&&(o.vscodeMcp=!0,n("Registered MCP server in "+p(".vscode/mcp.json")+j(" (Copilot Chat)")))}catch(e){r(".vscode/mcp.json update skipped: "+e.message)}try{const e=u.join(t,".cursor","mcp.json"),{data:f}=y(e);f.mcpServers||(f.mcpServers={});const m=f.mcpServers.infernoflow;(!m||!m.args||m.args[0]!==c||!m.env||m.env.INFERNOFLOW_PROJECT_DIR!==d)&&(f.mcpServers.infernoflow={command:"node",args:[c],env:{...m&&m.env?m.env:{},INFERNOFLOW_PROJECT_DIR:d}},a.mkdirSync(u.dirname(e),{recursive:!0}),a.writeFileSync(e,JSON.stringify(f,null,2)+`
58
+ `,"utf8"),o.cursorMcp=!0,n("Registered MCP server in "+p(".cursor/mcp.json")))}catch(e){r(".cursor/mcp.json update skipped: "+e.message)}try{Y(t,!1),o.claudeSettings=!0,n("Pre-approved infernoflow tools in "+p(".claude/settings.json"))}catch(e){r(".claude/settings.json skipped: "+e.message)}try{const e=K(c,d);e.updated&&(o.claudeDesktop=!0,n("Registered MCP server in "+p("claude_desktop_config.json")+j(" (Claude Desktop app)"))),e.backup&&(o.backups.push(e.backup),r("Claude Desktop config was unreadable \u2014 backed up to "+e.backup))}catch(e){r("Claude Desktop config skipped: "+e.message)}try{q(t).installed&&(o.gitHooks=!0,n("Installed git post-commit hook \u2192 "+p(".git/hooks/post-commit")))}catch(e){r("git hook install skipped: "+e.message)}try{G(t).installed&&(o.captureHook=!0,n("Installed capture hook \u2192 "+p(".claude/hooks/log-frustration.mjs")))}catch(e){r("capture hook install skipped: "+e.message)}return o}async function de(t){const i=process.cwd(),s=t.includes("--force")||t.includes("-f"),n=t.includes("--yes")||t.includes("-y"),r=_();J("infernoflow setup");const{ideDetected:o}=M("auto");v(`IDE detected: ${h(o==="cursor"?"Cursor":o==="vscode"?"VS Code":o==="windsurf"?"Windsurf":"unknown")}`);const c=u.join(i,".ai-memory");a.existsSync(c)?k(".ai-memory/ already exists \u2014 skipping init"):(console.log(`
33
59
  ${C(".ai-memory/")} not found \u2014 running init ...
34
- `),W(n?"init --yes":"init"));const p=A(t);if(Object.keys(p).length)try{E(i,p),h("Injection config updated \u2192 "+JSON.stringify(p))}catch{}console.log(),w("Wiring up MCP servers for Cursor / VS Code Copilot / Claude Code ...");const o=X(i,{silent:!1});console.log(),D("infernoflow ready"),console.log(`
35
- ${k("What was set up:")}`),console.log(` ${m("\u2714")} MCP server installed \u2192 ${d(".cursor/inferno-mcp-server.mjs")}`),o.cursorMcp&&console.log(` ${m("\u2714")} Cursor MCP config \u2192 ${d(".cursor/mcp.json")}`),o.vscodeMcp&&console.log(` ${m("\u2714")} VS Code Copilot MCP config \u2192 ${d(".vscode/mcp.json")}`),o.claudeJson&&console.log(` ${m("\u2714")} Claude Code MCP config \u2192 ${d("~/.claude.json")}`),o.claudeSettings&&console.log(` ${m("\u2714")} Auto-approved tools \u2192 ${d(".claude/settings.json")}`),o.claudeDesktop&&console.log(` ${m("\u2714")} Claude Desktop MCP config \u2192 ${d("claude_desktop_config.json")}`),o.gitHooks&&console.log(` ${m("\u2714")} Git post-commit hook \u2192 ${d(".git/hooks/post-commit")}`),o.captureHook&&console.log(` ${m("\u2714")} Capture hook (Claude Code/Cursor) \u2192 ${d(".claude/hooks/log-frustration.mjs")}`);try{const{detectStaleMcpRuntime:g}=await import("../mcpRuntime.mjs"),{readFileSync:f}=await import("node:fs"),{dirname:b,join:R}=await import("node:path"),{fileURLToPath:O}=await import("node:url"),P=b(O(import.meta.url)),x=R(P,"..","..","package.json"),F=JSON.parse(f(x,"utf8")).version,S=g(i,F);S&&(console.log(),console.log(` ${C("\u26A0")} ${k("Restart required:")} ${S.message}`))}catch{}console.log(),console.log(` ${k("Next step:")} Restart your AI tool. Test by asking:`),console.log(` ${d('"call the amp_write tool with a test note"')}`),console.log()}export{G as MCP_TOOLS,X as autoSetupMcp,H as claudeDesktopConfigPath,K as installClaudeCodeCaptureHook,V as installGitHooks,le as setupCommand,U as updateClaudeDesktopConfig,B as updateClaudeJson,z as updateVscodeMcpJson,Q as writeClaudeSettings};
60
+ `),U(n?"init --yes":"init"));const d=H(t);if(Object.keys(d).length)try{T(i,d),k("Injection config updated \u2192 "+JSON.stringify(d))}catch{}console.log(),v("Wiring up MCP servers for Cursor / VS Code Copilot / Claude Code ...");const e=Z(i,{silent:!1});console.log(),D("infernoflow ready"),console.log(`
61
+ ${h("What was set up:")}`),console.log(` ${g("\u2714")} MCP server installed \u2192 ${p(".cursor/inferno-mcp-server.mjs")}`),e.cursorMcp&&console.log(` ${g("\u2714")} Cursor MCP config \u2192 ${p(".cursor/mcp.json")}`),e.vscodeMcp&&console.log(` ${g("\u2714")} VS Code Copilot MCP config \u2192 ${p(".vscode/mcp.json")}`),e.claudeJson&&console.log(` ${g("\u2714")} Claude Code MCP config \u2192 ${p("~/.claude.json")}`),e.claudeSettings&&console.log(` ${g("\u2714")} Auto-approved tools \u2192 ${p(".claude/settings.json")}`),e.claudeDesktop&&console.log(` ${g("\u2714")} Claude Desktop MCP config \u2192 ${p("claude_desktop_config.json")}`),e.gitHooks&&console.log(` ${g("\u2714")} Git post-commit hook \u2192 ${p(".git/hooks/post-commit")}`),e.captureHook&&console.log(` ${g("\u2714")} Capture hook (Claude Code/Cursor) \u2192 ${p(".claude/hooks/log-frustration.mjs")}`);try{const{detectStaleMcpRuntime:f}=await import("../mcpRuntime.mjs"),{readFileSync:m}=await import("node:fs"),{dirname:R,join:x}=await import("node:path"),{fileURLToPath:O}=await import("node:url"),P=R(O(import.meta.url)),F=x(P,"..","..","package.json"),$=JSON.parse(m(F,"utf8")).version,S=f(i,$);S&&(console.log(),console.log(` ${C("\u26A0")} ${h("Restart required:")} ${S.message}`))}catch{}console.log(),console.log(` ${h("Next step:")} Restart your AI tool. Test by asking:`),console.log(` ${p('"call the amp_write tool with a test note"')}`),console.log()}export{b as CAPTURE_HOOK_SCRIPT,B as CAPTURE_HOOK_VERSION,z as MCP_TOOLS,Z as autoSetupMcp,V as claudeDesktopConfigPath,G as installClaudeCodeCaptureHook,q as installGitHooks,de as setupCommand,K as updateClaudeDesktopConfig,Q as updateClaudeJson,X as updateVscodeMcpJson,Y as writeClaudeSettings};
@@ -1 +1 @@
1
- import*as r from"node:fs";import*as o from"node:path";import{installInfernoDraftTooling as v}from"./draftToolingInstall.mjs";function h(f){const{cwd:s,templatesRoot:t,force:m,silent:e}=f,a=f.logOk||(()=>{}),l=f.logWarn||(()=>{});function p(n,c){return r.existsSync(c)&&!m?(e||l("Skipped (exists): "+o.relative(s,c)),!1):(r.mkdirSync(o.dirname(c),{recursive:!0}),r.copyFileSync(n,c),e||a("Created: "+o.relative(s,c)),!0)}v({cwd:s,templatesRoot:t,force:m,silent:e,logOk:a,logWarn:l});const u=o.join(t,"cursor","hooks.json"),j=o.join(s,".cursor","hooks.json"),k=o.join(t,"cursor","hooks","inferno-session-draft.mjs"),S=o.join(s,".cursor","hooks","inferno-session-draft.mjs");p(u,j),p(k,S);const d=o.join(t,"cursor","inferno-mcp-server.mjs"),y=o.join(s,".cursor","inferno-mcp-server.mjs");p(d,y);const i=o.join(s,".cursor","mcp.json");if(!r.existsSync(i)||m){let n={};if(r.existsSync(i))try{n=JSON.parse(r.readFileSync(i,"utf8"))}catch{}n.mcpServers||(n.mcpServers={}),n.mcpServers.infernoflow={command:"node",args:["./inferno-mcp-server.mjs"],env:{}},r.mkdirSync(o.dirname(i),{recursive:!0}),r.writeFileSync(i,JSON.stringify(n,null,2),"utf8"),e||a("Created: .cursor/mcp.json")}else e||l("Skipped (exists): .cursor/mcp.json")}export{h as installCursorHooksArtifacts};
1
+ import*as e from"node:fs";import*as r from"node:path";import{installInfernoDraftTooling as v}from"./draftToolingInstall.mjs";import{refreshSecuritySensitiveCopies as h}from"./securityRefresh.mjs";function H(m){const{cwd:s,templatesRoot:t,force:a,silent:n}=m,f=m.logOk||(()=>{}),p=m.logWarn||(()=>{});function l(o,c){return e.existsSync(c)&&!a?(n||p("Skipped (exists): "+r.relative(s,c)),!1):(e.mkdirSync(r.dirname(c),{recursive:!0}),e.copyFileSync(o,c),n||f("Created: "+r.relative(s,c)),!0)}v({cwd:s,templatesRoot:t,force:a,silent:n,logOk:f,logWarn:p});const u=r.join(t,"cursor","hooks.json"),j=r.join(s,".cursor","hooks.json"),S=r.join(t,"cursor","hooks","inferno-session-draft.mjs"),d=r.join(s,".cursor","hooks","inferno-session-draft.mjs");l(u,j),l(S,d);const k=r.join(t,"cursor","inferno-mcp-server.mjs"),y=r.join(s,".cursor","inferno-mcp-server.mjs");l(k,y);try{const o=h(s);o.length&&!n&&f("Security update: replaced "+o.join(", "))}catch{}const i=r.join(s,".cursor","mcp.json");if(!e.existsSync(i)||a){let o={};if(e.existsSync(i))try{o=JSON.parse(e.readFileSync(i,"utf8"))}catch{}o.mcpServers||(o.mcpServers={}),o.mcpServers.infernoflow={command:"node",args:["./inferno-mcp-server.mjs"],env:{}},e.mkdirSync(r.dirname(i),{recursive:!0}),e.writeFileSync(i,JSON.stringify(o,null,2),"utf8"),n||f("Created: .cursor/mcp.json")}else n||p("Skipped (exists): .cursor/mcp.json")}export{H as installCursorHooksArtifacts};
@@ -0,0 +1,2 @@
1
+ import*as c from"node:fs";import*as n from"node:path";import{fileURLToPath as d}from"node:url";const h=n.dirname(d(import.meta.url));function k(){return n.resolve(h,"../templates")}const p="infernoflow-hook-version: 2";function t(r){try{return c.readFileSync(r,"utf8")}catch{return null}}const j=r=>r==null?r:r.replace(/\r\n/g,`
2
+ `);function v(r){return typeof r=="string"&&(r.includes('serverInfo: { name: "infernoflow"')||r.includes("[infernoflow MCP]"))}function y(r,u={}){const f=[],l=k(),i=(o,e)=>{const a=n.join(r,o);try{c.mkdirSync(n.dirname(a),{recursive:!0}),c.writeFileSync(a,e,"utf8"),f.push(o.split(n.sep).join("/"))}catch{}},s=t(n.join(l,"cursor","inferno-mcp-server.mjs"));if(s)for(const o of[n.join(".cursor","inferno-mcp-server.mjs"),"inferno-mcp-server.mjs"]){const e=t(n.join(r,o));e!=null&&v(e)&&j(e)!==j(s)&&i(o,s)}const m=t(n.join(l,"cursor","hooks","inferno-session-draft.mjs"));if(m){const o=n.join(".cursor","hooks","inferno-session-draft.mjs"),e=t(n.join(r,o));e!=null&&!e.includes(p)&&i(o,m)}if(u.captureHookScript){const o=n.join(".claude","hooks","log-frustration.mjs"),e=t(n.join(r,o));e!=null&&!e.includes(p)&&i(o,u.captureHookScript)}return f}export{p as HOOK_VERSION_MARKER,y as refreshSecuritySensitiveCopies};
@@ -1,3 +1,5 @@
1
- import*as o from"node:fs";import*as a from"node:path";const p=new Set(["--help","-h","--version","-v","commands","doctor","setup","init","uninstall"]);function u(t){const n=a.join(t,".ai-memory"),e=a.join(t,"inferno");return o.existsSync(n)?a.join(n,".last-cli-version"):o.existsSync(e)?a.join(e,".last-cli-version"):null}function d(t){try{return o.readFileSync(t,"utf8").trim()}catch{return""}}async function m(t,n){if(!t||n&&p.has(n))return;const e=process.cwd(),c=u(e);if(!c)return;const s=d(c);if(s===t)return;let l=!1,f=s==="";try{const{refreshRuleFilesFromMemory:i}=await import("./ruleFiles.mjs");i(e)}catch{}try{const{applyCleanTreePolicy:i}=await import("./cleanTree.mjs");i(e)}catch{}try{const{autoSetupMcp:i}=await import("./commands/setup.mjs"),r=i(e,{silent:!0});l=!!(r&&(r.mcpServer||r.claudeJson||r.cursorMcp||r.vscodeMcp||r.claudeSettings))}catch{}try{o.writeFileSync(c,t,"utf8")}catch{}if(l||f)try{const{gray:i}=await import("./ui/output.mjs"),r=s?`upgraded ${s} \u2192 ${t}`:`initialized for ${t}`;process.stderr.write(i(` infernoflow: ${r}, wired MCP servers + rule files
2
- `))}catch{process.stderr.write(` infernoflow: refreshed for v${t}
1
+ import*as n from"node:fs";import*as a from"node:path";const u=new Set(["--help","-h","--version","-v","commands","doctor","setup","init","uninstall"]);function p(e){const o=a.join(e,".ai-memory"),t=a.join(e,"inferno");return n.existsSync(o)?a.join(o,".last-cli-version"):n.existsSync(t)?a.join(t,".last-cli-version"):null}function y(e){try{return n.readFileSync(e,"utf8").trim()}catch{return""}}async function m(e,o){if(!e||o&&u.has(o))return;const t=process.cwd(),c=p(t);if(!c)return;const s=y(c);if(s===e)return;let f=!1,l=[],d=s==="";try{const{refreshRuleFilesFromMemory:i}=await import("./ruleFiles.mjs");i(t)}catch{}try{const{applyCleanTreePolicy:i}=await import("./cleanTree.mjs");i(t)}catch{}try{const{autoSetupMcp:i}=await import("./commands/setup.mjs"),r=i(t,{silent:!0});f=!!(r&&(r.mcpServer||r.claudeJson||r.cursorMcp||r.vscodeMcp||r.claudeSettings)),l=r&&r.securityRefreshed||[]}catch{}try{n.writeFileSync(c,e,"utf8")}catch{}if(l.length&&process.stderr.write(` infernoflow: security update \u2014 replaced outdated ${l.join(", ")}.
2
+ infernoflow: restart your AI tool (Claude Code / Cursor / VS Code) so the new MCP server is loaded.
3
+ `),f||d)try{const{gray:i}=await import("./ui/output.mjs"),r=s?`upgraded ${s} \u2192 ${e}`:`initialized for ${e}`;process.stderr.write(i(` infernoflow: ${r}, wired MCP servers + rule files
4
+ `))}catch{process.stderr.write(` infernoflow: refreshed for v${e}
3
5
  `)}}export{m as runUpgradeBackfillIfNeeded};
@@ -16,7 +16,41 @@
16
16
  */
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
- import { spawnSync } from "node:child_process";
19
+ import { spawnSync, execFileSync } from "node:child_process";
20
+
21
+ // infernoflow-hook-version: 2
22
+ // SECURITY (0.44.20): the CLI is run as `node infernoflow.mjs ...` with NO
23
+ // shell. Before 0.44.20 this hook used spawnSync("infernoflow.cmd", args,
24
+ // { shell: true }) on Windows, which hands the prompt text to cmd.exe
25
+ // unquoted — a prompt containing `&` or `|` ran as a command.
26
+ function findCliMjs() {
27
+ let hits = [];
28
+ try {
29
+ const finder = process.platform === "win32" ? "where" : "which";
30
+ hits = execFileSync(finder, ["infernoflow"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, timeout: 3000 })
31
+ .split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
32
+ } catch {}
33
+ for (const c of hits) {
34
+ try { const real = fs.realpathSync(c); if (/\.m?js$/i.test(real)) return real; } catch {}
35
+ const d = path.dirname(c);
36
+ for (const pkg of [path.join(d, "node_modules", "infernoflow"), path.join(d, "..", "lib", "node_modules", "infernoflow")]) {
37
+ for (const f of [path.join(pkg, "dist", "bin", "infernoflow.mjs"), path.join(pkg, "bin", "infernoflow.mjs")]) {
38
+ if (fs.existsSync(f)) return f;
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Run the infernoflow CLI without a shell. Returns the spawnSync result or null. */
46
+ function runCli(args, opts) {
47
+ const cli = findCliMjs();
48
+ if (!cli) return null;
49
+ return spawnSync(process.execPath, [cli, ...args], { ...opts, windowsHide: true, shell: false });
50
+ }
51
+
52
+ /** Text that becomes a CLI argument must never look like a flag. */
53
+ const asCliText = (v) => String(v ?? "").replace(/^[\s-]+/, "");
20
54
 
21
55
  /** Keep in sync with templates/scripts/inferno-promote-draft.mjs */
22
56
  const DRAFT_HEADER = `# CONTEXT draft (gitignored)
@@ -201,11 +235,10 @@ function handleBookmarkTrigger(prompt) {
201
235
  // The `bookmark` command (no --marker) auto-captures the session transcript
202
236
  // as the resume point. Needs infernoflow >= 0.44.10 on PATH; if the global
203
237
  // CLI is older / missing, this no-ops and the AI's amp_bookmark path covers it.
204
- const bin = process.platform === "win32" ? "infernoflow.cmd" : "infernoflow";
205
- const r = spawnSync(bin, ["bookmark", label], {
206
- cwd: projectRoot(), encoding: "utf8", timeout: 12000, shell: process.platform === "win32",
238
+ const r = runCli(["bookmark", asCliText(label)], {
239
+ cwd: projectRoot(), encoding: "utf8", timeout: 12000,
207
240
  });
208
- wrote = r.status === 0;
241
+ wrote = !!r && r.status === 0;
209
242
  } catch { /* CLI unavailable — skip */ }
210
243
 
211
244
  if (wrote) {
@@ -240,11 +273,10 @@ function handleUserPrompt(text) {
240
273
  // direct sessions.jsonl append so capture still works without a global CLI.
241
274
  let wrote = false;
242
275
  try {
243
- const bin = process.platform === "win32" ? "infernoflow.cmd" : "infernoflow";
244
- const r = spawnSync(bin, ["log", msg, "--type", "attempt", "--source", "cursor-trigger", "--tags", "auto-trigger"], {
245
- cwd: projectRoot(), encoding: "utf8", timeout: 8000, shell: process.platform === "win32",
276
+ const r = runCli(["log", asCliText(msg), "--type", "attempt", "--source", "cursor-trigger", "--tags", "auto-trigger"], {
277
+ cwd: projectRoot(), encoding: "utf8", timeout: 8000,
246
278
  });
247
- wrote = r.status === 0;
279
+ wrote = !!r && r.status === 0;
248
280
  } catch { /* fall through to direct write */ }
249
281
  if (!wrote) {
250
282
  try {
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
@@ -37,6 +37,19 @@ function walkUpForInfernoflow(startFile) {
37
37
  return null;
38
38
  }
39
39
 
40
+ /**
41
+ * Locate the global `infernoflow` launcher(s) on PATH WITHOUT a shell.
42
+ * `where` (Windows) / `which` (POSIX) are real executables, so execFileSync
43
+ * with an argument array is enough. Returns [] when nothing is found.
44
+ */
45
+ function lookupOnPath(name) {
46
+ try {
47
+ const finder = process.platform === "win32" ? "where" : "which";
48
+ const out = execFileSync(finder, [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false });
49
+ return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
50
+ } catch { return []; }
51
+ }
52
+
40
53
  function findInfernoflowRoot() {
41
54
  // 1. Walk up from this template's own location.
42
55
  // Works when the template runs from inside infernoflow-pkg/ or from a
@@ -56,9 +69,7 @@ function findInfernoflowRoot() {
56
69
  // and the user's project doesn't depend on infernoflow locally. Without
57
70
  // this branch the MCP server boots with v0.0.0-unknown.
58
71
  try {
59
- const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
60
- const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
61
- for (const candidate of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
72
+ for (const candidate of lookupOnPath("infernoflow")) {
62
73
  if (!fs.existsSync(candidate)) continue;
63
74
  const binDir = path.dirname(candidate);
64
75
  // Windows layout: <npm-prefix>/infernoflow.cmd + <npm-prefix>/node_modules/infernoflow/
@@ -89,20 +100,40 @@ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code
89
100
  // Resolve a deterministic location once at startup, in priority order:
90
101
  // 1. infernoflow installed in the project's node_modules (npm i / npm link)
91
102
  // 2. `where`/`which` the global binary
92
- // Returns null if nothing is found; runCmd surfaces a clear error in that case.
103
+ // Returns null if nothing is found; runCli surfaces a clear error in that case.
104
+ // SECURITY: this must always resolve to the CLI's JavaScript entry point
105
+ // (infernoflow.mjs), which we run as `node <file> ...args` with NO shell.
106
+ // We never execute the npm `.cmd` / shell-script wrapper: running those needs
107
+ // a shell, and a shell turns tool arguments into commands.
93
108
  function resolveInfernoflowBin() {
94
- if (INFERNOFLOW_ROOT) {
109
+ const fromRoot = (root) => {
95
110
  for (const c of [
96
- path.join(INFERNOFLOW_ROOT, "dist", "bin", "infernoflow.mjs"),
97
- path.join(INFERNOFLOW_ROOT, "bin", "infernoflow.mjs"),
111
+ path.join(root, "dist", "bin", "infernoflow.mjs"),
112
+ path.join(root, "bin", "infernoflow.mjs"),
98
113
  ]) if (fs.existsSync(c)) return c;
114
+ return null;
115
+ };
116
+ if (INFERNOFLOW_ROOT) {
117
+ const hit = fromRoot(INFERNOFLOW_ROOT);
118
+ if (hit) return hit;
119
+ }
120
+ for (const candidate of lookupOnPath("infernoflow")) {
121
+ if (!fs.existsSync(candidate)) continue;
122
+ // POSIX: the bin entry is usually a symlink straight to infernoflow.mjs.
123
+ try {
124
+ const real = fs.realpathSync(candidate);
125
+ if (/\.m?js$/i.test(real)) return real;
126
+ } catch {}
127
+ // Windows / wrapper layouts: find the package next to the wrapper.
128
+ const binDir = path.dirname(candidate);
129
+ for (const layout of [
130
+ path.join(binDir, "node_modules", "infernoflow"),
131
+ path.join(binDir, "..", "lib", "node_modules", "infernoflow"),
132
+ ]) {
133
+ const hit = fromRoot(layout);
134
+ if (hit) return hit;
135
+ }
99
136
  }
100
- try {
101
- const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
102
- const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
103
- const first = out.split(/\r?\n/)[0];
104
- if (first && fs.existsSync(first)) return first;
105
- } catch {}
106
137
  return null;
107
138
  }
108
139
 
@@ -110,7 +141,7 @@ const INFERNOFLOW_BIN = resolveInfernoflowBin();
110
141
 
111
142
  // In-process AMP I/O loader. When available, amp_write / amp_read bypass the
112
143
  // CLI entirely — no subprocess, no version skew, no flag-mapping field loss.
113
- // Falls back to shell-out via runCmd() if the AMP layer can't be loaded.
144
+ // Falls back to the CLI via runCli() if the AMP layer can't be loaded.
114
145
  let ampIo = null;
115
146
  let refreshRuleFiles = null;
116
147
  let harvestSnapshot = null;
@@ -211,8 +242,16 @@ try {
211
242
  * Run the infernoflow CLI. Returns either the stdout string OR a structured
212
243
  * error object so call sites can decide whether to surface it via JSON-RPC
213
244
  * sendError() instead of returning gibberish text to the agent.
245
+ *
246
+ * SECURITY: `args` is an ARRAY of separate arguments, passed straight to
247
+ * `node infernoflow.mjs` with execFileSync and no shell. Tool input must never
248
+ * be concatenated into a command string — that allowed command injection
249
+ * through tool arguments (fixed in 0.44.20).
214
250
  */
215
- function runCmd(args, env = {}) {
251
+ function runCli(args, env = {}) {
252
+ if (!Array.isArray(args) || !args.every(a => typeof a === "string")) {
253
+ return { __error: true, message: "internal: runCli expects an array of strings", stderr: "", stdout: "", status: 1 };
254
+ }
216
255
  if (!INFERNOFLOW_BIN) {
217
256
  return {
218
257
  __error: true,
@@ -223,15 +262,13 @@ function runCmd(args, env = {}) {
223
262
  };
224
263
  }
225
264
  try {
226
- const isMjs = INFERNOFLOW_BIN.toLowerCase().endsWith(".mjs");
227
- const cmd = isMjs
228
- ? `"${process.execPath}" "${INFERNOFLOW_BIN}" ${args}`
229
- : `"${INFERNOFLOW_BIN}" ${args}`;
230
- return execSync(cmd, {
265
+ return execFileSync(process.execPath, [INFERNOFLOW_BIN, ...args], {
231
266
  encoding: "utf8",
232
267
  cwd: PROJECT_DIR,
233
268
  timeout: 30000,
234
269
  env: { ...process.env, ...env },
270
+ windowsHide: true,
271
+ shell: false,
235
272
  });
236
273
  } catch (err) {
237
274
  return {
@@ -244,7 +281,7 @@ function runCmd(args, env = {}) {
244
281
  }
245
282
  }
246
283
 
247
- /** True if a runCmd() result is actually a structured error. */
284
+ /** True if a runCli() result is actually a structured error. */
248
285
  function isCmdError(result) {
249
286
  return typeof result === "object" && result !== null && result.__error === true;
250
287
  }
@@ -259,36 +296,97 @@ function isCmdError(result) {
259
296
  // helpers that pair cleanly with the kept CLI surface.
260
297
  const TOOLS = [
261
298
  // ── AMP-spec memory tools (the product) ──────────────────────────────────
262
- { name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string" }, limit: { type: "number" } } } },
263
- { name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string" }, file: { type: "string" }, line: { type: "number" }, tags: { type: "array", items: { type: "string" } }, detail: { type: "string", description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
264
- { name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
265
- { name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string" }, note: { type: "string", description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
299
+ { name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string", maxLength: 1000 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 200 } } } },
300
+ { name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string", maxLength: 2000 }, file: { type: "string", maxLength: 1000 }, line: { type: "integer", minimum: 1, maximum: 10000000 }, tags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 100 } }, detail: { type: "string", maxLength: 200000, description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
301
+ { name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 500 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
302
+ { name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string", maxLength: 200 }, note: { type: "string", maxLength: 200000, description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
266
303
  { name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
267
304
  { name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
268
305
 
269
306
  // ── Read-only contract helpers ───────────────────────────────────────────
270
307
  { name: "infernoflow_status", description: "Show project memory + contract health at a glance.", inputSchema: { type: "object", properties: {} } },
271
308
  { name: "infernoflow_check", description: "Validate the capability contract (read-only).", inputSchema: { type: "object", properties: {} } },
272
- { name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
273
- { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "number", description: "How many commits back to check (default: 1)" } } } },
309
+ { name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string", maxLength: 1000 }, working: { type: "string", maxLength: 1000 } } } },
310
+ { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "integer", minimum: 1, maximum: 100, description: "How many commits back to check (default: 1, max: 100)" } } } },
274
311
  ];
275
312
 
313
+ // ── Input validation ─────────────────────────────────────────────────────────
314
+ // SECURITY: tool arguments come from the model, and the model can be steered by
315
+ // anything it reads (a README, an issue, a memory entry pulled from git). The
316
+ // inputSchema above is advisory to the client only — nothing enforces it — so
317
+ // we enforce it here, before any argument reaches the CLI, git or the disk.
318
+ // Unknown properties are dropped (not rejected) so a chatty client still works.
319
+ const TOOL_SCHEMAS = new Map(TOOLS.map(t => [t.name, t.inputSchema]));
320
+
321
+ function checkValue(key, v, spec) {
322
+ const t = spec.type;
323
+ if (t === "string") {
324
+ if (typeof v !== "string") return `'${key}' must be a string`;
325
+ if (spec.maxLength && v.length > spec.maxLength) return `'${key}' is too long (max ${spec.maxLength} characters)`;
326
+ if (v.includes("\u0000")) return `'${key}' must not contain NUL characters`;
327
+ } else if (t === "integer" || t === "number") {
328
+ if (typeof v !== "number" || !Number.isFinite(v)) return `'${key}' must be a number`;
329
+ if (t === "integer" && !Number.isInteger(v)) return `'${key}' must be a whole number`;
330
+ if (spec.minimum !== undefined && v < spec.minimum) return `'${key}' must be >= ${spec.minimum}`;
331
+ if (spec.maximum !== undefined && v > spec.maximum) return `'${key}' must be <= ${spec.maximum}`;
332
+ } else if (t === "array") {
333
+ if (!Array.isArray(v)) return `'${key}' must be an array`;
334
+ if (spec.maxItems && v.length > spec.maxItems) return `'${key}' has too many items (max ${spec.maxItems})`;
335
+ for (const item of v) { const e = checkValue(`${key}[]`, item, spec.items || {}); if (e) return e; }
336
+ }
337
+ if (spec.enum && !spec.enum.includes(v)) return `'${key}' must be one of: ${spec.enum.join(", ")}`;
338
+ return null;
339
+ }
340
+
341
+ /** Returns { ok: true, value } with only known, valid properties, or { ok: false, error }. */
342
+ function validateToolInput(name, input) {
343
+ const schema = TOOL_SCHEMAS.get(name);
344
+ if (!schema) return { ok: false, error: `Unknown tool: ${name}` };
345
+ if (input === undefined || input === null) input = {};
346
+ if (typeof input !== "object" || Array.isArray(input)) return { ok: false, error: "arguments must be an object" };
347
+ const props = schema.properties || {};
348
+ const value = {};
349
+ for (const [key, spec] of Object.entries(props)) {
350
+ const v = input[key];
351
+ if (v === undefined || v === null) continue;
352
+ const err = checkValue(key, v, spec);
353
+ if (err) return { ok: false, error: `Invalid arguments for ${name}: ${err}` };
354
+ value[key] = v;
355
+ }
356
+ for (const req of schema.required || []) {
357
+ if (value[req] === undefined || value[req] === "") return { ok: false, error: `Invalid arguments for ${name}: '${req}' is required` };
358
+ }
359
+ return { ok: true, value };
360
+ }
361
+
362
+ /**
363
+ * Free text that is handed to the CLI as an argument. The CLI's own parser
364
+ * treats any argument equal to a flag name (e.g. "--watch", "--auto-push") as
365
+ * that flag, even when it arrives as a separate argv entry. Strip leading
366
+ * dashes so tool text can never be read as a CLI option.
367
+ */
368
+ function asCliText(v) {
369
+ return String(v ?? "").replace(/^[\s-]+/, "");
370
+ }
371
+
276
372
  // ── git drift detection (inline — no external imports in this template file) ─
277
373
  function detectGitDrift(sinceCommits) {
278
374
  const cwd = PROJECT_DIR;
279
375
  const infernoDir = path.join(cwd, "inferno");
280
376
 
281
- const runGit = (cmd) => {
282
- try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 10_000 }); }
377
+ // SECURITY: git is run with an argument array and no shell.
378
+ const runGit = (args) => {
379
+ try { return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false }); }
283
380
  catch { return ""; }
284
381
  };
382
+ const n = Number.isInteger(sinceCommits) && sinceCommits >= 1 && sinceCommits <= 100 ? sinceCommits : 1;
285
383
 
286
384
  const changedSet = new Set();
287
385
  const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
288
386
 
289
- addLines(runGit("git diff --name-only HEAD"));
290
- addLines(runGit(`git diff --name-only HEAD~${sinceCommits} HEAD`));
291
- addLines(runGit("git ls-files --others --exclude-standard"));
387
+ addLines(runGit(["diff", "--name-only", "HEAD"]));
388
+ addLines(runGit(["diff", "--name-only", `HEAD~${n}`, "HEAD"]));
389
+ addLines(runGit(["ls-files", "--others", "--exclude-standard"]));
292
390
 
293
391
  const changedFiles = Array.from(changedSet).sort();
294
392
  if (!changedFiles.length) return "No changed files detected since last commit.";
@@ -388,29 +486,35 @@ function detectGitDrift(sinceCommits) {
388
486
  return lines.join("\n");
389
487
  }
390
488
 
391
- function handleTool(id, name, input) {
489
+ function handleTool(id, name, rawInput) {
392
490
  try {
491
+ const checked = validateToolInput(name, rawInput);
492
+ if (!checked.ok) {
493
+ const code = checked.error.startsWith("Unknown tool") ? -32601 : -32602;
494
+ return sendError(id, code, checked.error);
495
+ }
496
+ const input = checked.value;
393
497
  let text = "";
394
498
  // ── Read-only contract helpers ─────────────────────────────────────────
395
499
  if (name === "infernoflow_check") {
396
- text = runCmd("check");
500
+ text = runCli(["check"]);
397
501
  } else if (name === "infernoflow_status") {
398
- text = runCmd("status");
502
+ text = runCli(["status"]);
399
503
  } else if (name === "infernoflow_context") {
400
- const parts = [];
401
- if (input.intent) parts.push(`--intent "${input.intent}"`);
402
- if (input.working) parts.push(`--working "${input.working}"`);
403
- text = runCmd("context " + parts.join(" "));
504
+ const args = ["context"];
505
+ if (input.intent) args.push("--intent", asCliText(input.intent));
506
+ if (input.working) args.push("--working", asCliText(input.working));
507
+ text = runCli(args);
404
508
  } else if (name === "infernoflow_git_drift") {
405
- text = detectGitDrift(input.sinceCommits || 1);
509
+ text = detectGitDrift(input.sinceCommits ?? 1);
406
510
 
407
511
  // ── AMP-spec memory tools ──────────────────────────────────────────────
408
512
  } else if (name === "amp_read") {
409
- const args = [];
410
- if (input.query) args.push(JSON.stringify(input.query));
513
+ const args = ["ask"];
514
+ if (input.query) args.push(asCliText(input.query));
411
515
  if (input.type) args.push("--type", input.type);
412
516
  if (input.limit) args.push("--limit", String(input.limit));
413
- text = runCmd("ask " + args.join(" "));
517
+ text = runCli(args);
414
518
  } else if (name === "amp_write") {
415
519
  // Prefer in-process write: no subprocess, no `npx` version skew, and
416
520
  // file/line/tags reach disk unchanged. The CLI fallback below is only
@@ -445,15 +549,13 @@ function handleTool(id, name, input) {
445
549
  return sendError(id, -32000, `amp_write failed (in-process): ${err.message}`);
446
550
  }
447
551
  } else {
448
- // Fallback: shell-out. Pass --file/--line/--tags through the CLI so
449
- // they're not silently dropped like in the original implementation.
450
- const t = (input.type || "note").replace(/[^a-z]/g, "");
451
- const m = JSON.stringify(input.msg || "");
452
- const extras = [];
453
- if (input.file) extras.push("--file", JSON.stringify(input.file));
454
- if (input.line) extras.push("--line", String(input.line));
455
- if (input.tags && input.tags.length) extras.push("--tags", JSON.stringify(input.tags.join(",")));
456
- text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
552
+ // Fallback: run the CLI (argument array, no shell). Pass
553
+ // --file/--line/--tags through so they're not silently dropped.
554
+ const args = ["log", asCliText(input.msg), "--type", input.type || "note"];
555
+ if (input.file) args.push("--file", asCliText(input.file));
556
+ if (input.line) args.push("--line", String(input.line));
557
+ if (input.tags && input.tags.length) args.push("--tags", input.tags.map(asCliText).join(","));
558
+ text = runCli(args);
457
559
  }
458
560
  } else if (name === "amp_bookmark") {
459
561
  // A bookmark is a `note` entry tagged "bookmark"; the optional `note`
@@ -485,13 +587,13 @@ function handleTool(id, name, input) {
485
587
  return sendError(id, -32000, `amp_bookmark failed (in-process): ${err.message}`);
486
588
  }
487
589
  } else {
488
- const l = JSON.stringify(input.label || "");
489
- const extras = input.note ? `--note ${JSON.stringify(input.note)}` : "";
490
- text = runCmd(`bookmark ${l} ${extras}`);
590
+ const args = ["bookmark", asCliText(input.label)];
591
+ if (input.note) args.push("--note", asCliText(input.note));
592
+ text = runCli(args);
491
593
  }
492
594
  } else if (name === "amp_handoff") {
493
595
  // switch writes a file; we read it back to return the content
494
- const switchResult = runCmd("switch");
596
+ const switchResult = runCli(["switch"]);
495
597
  if (isCmdError(switchResult)) {
496
598
  return sendError(id, -32000, `infernoflow switch failed: ${switchResult.message}\n${switchResult.stderr || switchResult.stdout || ""}`.trim());
497
599
  }
@@ -508,20 +610,20 @@ function handleTool(id, name, input) {
508
610
  text = "(handoff generated; could not read back: " + err.message + ")";
509
611
  }
510
612
  } else if (name === "amp_search") {
511
- const args = [JSON.stringify(input.query || "")];
613
+ const args = ["ask", asCliText(input.query)];
512
614
  if (input.type) args.push("--type", input.type);
513
- text = runCmd("ask " + args.join(" "));
615
+ text = runCli(args);
514
616
  } else if (name === "amp_health") {
515
- const recap = runCmd("recap --json");
617
+ const recap = runCli(["recap", "--json"]);
516
618
  if (isCmdError(recap)) {
517
- text = runCmd("status");
619
+ text = runCli(["status"]);
518
620
  } else {
519
- text = recap.trim() || runCmd("status");
621
+ text = recap.trim() || runCli(["status"]);
520
622
  }
521
623
 
522
624
  } else { return sendError(id, -32601, `Unknown tool: ${name}`); }
523
625
 
524
- // Central error check — if any runCmd() call produced a structured error,
626
+ // Central error check — if any runCli() call produced a structured error,
525
627
  // surface it as a real JSON-RPC error so the calling AI sees a proper
526
628
  // failure instead of garbled stderr text mixed into a "successful" reply.
527
629
  if (isCmdError(text)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.44.19",
3
+ "version": "0.44.20",
4
4
  "description": "Persistent memory for AI coding sessions — captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
5
5
  "type": "module",
6
6
  "bin": {