diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..5447586 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "sp-devcontrol", + "version": "2.1.0", + "description": "Gobernanza local para sesiones de código asistidas por IA: gates de autorización, aprobación de cambios, políticas de riesgo, compliance y rollback.", + "mcpServers": { + "devcontrol": { + "type": "sse", + "url": "http://localhost:7893/mcp" + } + }, + "skills": "./.claude/skills" +} diff --git a/.claude/skills/govern-changes/SKILL.md b/.claude/skills/govern-changes/SKILL.md new file mode 100644 index 0000000..b0bccc3 --- /dev/null +++ b/.claude/skills/govern-changes/SKILL.md @@ -0,0 +1,35 @@ +--- +name: govern-changes +description: Gobernar cambios de código con DevControl: sesiones, aprobaciones, políticas de riesgo y compliance. Usar cuando el usuario pida revisar, aprobar o rechazar cambios, verificar políticas, o generar reportes de cumplimiento en un proyecto con DevControl inicializado. +--- + +# Gobernar cambios con DevControl + +Usar las herramientas del MCP `devcontrol` para mantener un ciclo de desarrollo trazable y aprobado. + +## Flujo estándar + +1. Verificar el estado del proyecto con `devcontrol_status`. +2. Si no hay sesión activa, iniciar una con `devcontrol_session_start` indicando el objetivo. +3. Antes de cada cambio significativo, evaluar el riesgo con `devcontrol_policy_check` (path o command). +4. Registrar cada cambio pendiente en la sesión activa. +5. Aprobar o rechazar cambios con `devcontrol_approve_change` / `devcontrol_reject_change` según la revisión. +6. Generar reporte de cumplimiento con `devcontrol_compliance_report` al finalizar. + +## Reglas + +- Siempre pasar `projectRoot` explícitamente para evitar ambigüedad entre proyectos. +- Una sesión activa a la vez por proyecto. +- No aprobar cambios en paths protegidos sin justificación explícita. +- El reporte de compliance incluye sesiones, cambios, controles y riesgos acumulados. + +## Referencia de herramientas MCP + +| Tool | Descripción | +|------|-------------| +| `devcontrol_status` | Estado: preflight, sesión activa, cambios pendientes | +| `devcontrol_session_start` | Iniciar nueva sesión de gobernanza | +| `devcontrol_approve_change` | Aprobar un cambio pendiente por ID | +| `devcontrol_reject_change` | Rechazar un cambio pendiente por ID | +| `devcontrol_policy_check` | Evaluar riesgo de path o command contra políticas | +| `devcontrol_compliance_report` | Generar reporte completo de cumplimiento | diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md new file mode 100644 index 0000000..23cb375 --- /dev/null +++ b/.codex/AGENTS.md @@ -0,0 +1,15 @@ +# Agentes Codex — DevControl + +## Reglas de gobernanza + +1. Antes de cualquier cambio de código, verificar el estado con `devcontrol_status`. +2. Evaluar el riesgo de cada operación con `devcontrol_policy_check` antes de ejecutarla. +3. Mantener una sesión de gobernanza activa durante todo el ciclo de trabajo. +4. Aprobar cambios solo después de revisión manual explícita. +5. No modificar paths protegidos sin aprobación previa. + +## Flujo de trabajo + +``` +devcontrol_status → devcontrol_session_start → [trabajo] → devcontrol_policy_check → devcontrol_approve_change → devcontrol_compliance_report +``` diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..48165ed --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,6 @@ +[mcp_servers.devcontrol] +command = "sp-devcontrol" +args = ["mcp"] + +[mcp_servers.devcontrol.env] +DEVCONTROL_PROJECT_ROOT = "${CLAUDE_PROJECT_DIR:-.}" diff --git a/INSTALL_DEVCONTROL.md b/INSTALL_DEVCONTROL.md new file mode 100644 index 0000000..72459ed --- /dev/null +++ b/INSTALL_DEVCONTROL.md @@ -0,0 +1,149 @@ +# Instalación de DevControl por editor + +DevControl es una CLI con servidor MCP integrado. Las superficies de integración son: CLI, MCP server, plugin Claude, skill Codex, connector para ChatGPT Desktop y extensión VS Code-compatible para panel dentro del editor. + +--- + +## 1. Claude Code (plugin) + +```bash +# Opción A — desde el repositorio (desarrollo) +cp -r .claude-plugin ~/.claude/plugins/sp-devcontrol + +# Opción B — instalar el MCP en .mcp.json (ya existe en el repo) +# El archivo .mcp.json configura el servidor MCP automáticamente. +``` + +El plugin registra el MCP server `devcontrol` y el skill `govern-changes` en `.claude/skills/govern-changes/SKILL.md`. + +**Uso:** Dentro de Claude Code, el skill se carga automáticamente cuando el usuario pide tareas de gobernanza. Las herramientas MCP están disponibles sin configuración adicional. + +--- + +## 2. Codex (OpenAI) + +```bash +# Agregar el MCP server +codex mcp add devcontrol sp-devcontrol mcp + +# O configurar manualmente en .codex/config.toml (ya incluido en el repo) +``` + +El archivo `.codex/config.toml` define el servidor MCP y `.codex/AGENTS.md` establece las reglas de gobernanza para agentes Codex. + +**Uso:** Codex conecta automáticamente al MCP y expone las herramientas de gobernanza. + +--- + +## 3. opencode + +El archivo `opencode.json` en la raíz del proyecto configura DevControl como herramienta MCP. opencode lo detecta automáticamente al abrir el directorio del proyecto. + +```json +{ + "mcpServers": { + "devcontrol": { + "type": "sse", + "url": "http://localhost:7893/mcp" + } + } +} +``` + +**Nota:** El MCP server debe estar ejecutándose. Iniciar con: +```bash +sp-devcontrol daemon +# o +sp-devcontrol mcp +``` + +--- + +## 4. ChatGPT Desktop (connector MCP) + +ChatGPT Desktop soporta conectores MCP. Para usar DevControl: + +1. Asegúrate de que el MCP server esté ejecutándose en `http://localhost:7893/mcp`. +2. En ChatGPT Desktop, ve a **Settings > Connectors**. +3. Agrega un conector personalizado con URL: `http://localhost:7893/mcp`. +4. Las herramientas de DevControl aparecerán disponibles en la conversación. + +**Importante:** DevControl no tiene API HTTP propia. La única interfaz remota es el MCP. ChatGPT web (navegador) NO soporta conectores MCP; esta funcionalidad solo está disponible en ChatGPT Desktop. + +--- + +## 5. Servidor MCP standalone + +Si necesitas ejecutar el MCP server independientemente: + +```bash +# Modo stdio (para integración con herramientas que lo gestionan) +sp-devcontrol mcp + +# Modo daemon HTTP (puerto 7893) +sp-devcontrol daemon +``` + +El servidor expone estas herramientas: + +| Tool | Descripción | +|------|-------------| +| `devcontrol_status` | Estado del proyecto, preflight, sesión activa | +| `devcontrol_session_start` | Iniciar sesión de gobernanza | +| `devcontrol_approve_change` | Aprobar cambio pendiente | +| `devcontrol_reject_change` | Rechazar cambio pendiente | +| `devcontrol_policy_check` | Evaluar riesgo de path/command | +| `devcontrol_compliance_report` | Reporte de cumplimiento completo | + +--- + +## 6. VS Code / Cursor / Windsurf (extensión VSIX) + +La carpeta `extension/` contiene una extensión compatible con VS Code y forks como Cursor y Windsurf. Añade una pestaña DevControl en la Activity Bar y ejecuta el CLI/MCP real del módulo desde el workspace abierto. + +```bash +# 1. Instalar la CLI que invoca la extensión +npm install -g sp-devcontrol + +# 2. Empaquetar la extensión +cd extension +npm run package + +# 3. Instalar el VSIX generado +code --install-extension sp-devcontrol-editor-2.1.0.vsix +``` + +En Cursor/Windsurf usa el instalador de extensiones VSIX del editor o su comando equivalente. Desde la pestaña DevControl puedes ejecutar estado del proyecto, gates, preflight, reporte de compliance, `inject`, daemon y generación de config MCP local. + +Para asistentes del editor que consumen MCP por stdio: + +```json +{ + "mcpServers": { + "devcontrol": { + "type": "stdio", + "command": "sp-devcontrol", + "args": ["mcp:stdio"] + } + } +} +``` + +--- + +## Requisitos previos + +- Node.js >= 18 +- DevControl inicializado en el proyecto: `sp-devcontrol init` +- Para HTTP: token de autenticación en `~/.devcontrol/api-token` (chmod 600) +- Para la extensión: CLI `sp-devcontrol` disponible en `PATH` + +## Verificación + +```bash +# Verificar que la CLI funciona +sp-devcontrol status + +# Verificar que el MCP responde +curl -s http://localhost:7893/health +``` diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..e376b6a --- /dev/null +++ b/extension/README.md @@ -0,0 +1,52 @@ +# SP-DevControl editor extension + +VS Code-compatible extension for SP-DevControl. It adds a DevControl tab in the Activity Bar with commands for project status, gates, preflight, compliance, editor config injection, daemon control and MCP setup. + +The extension is a thin adapter over the CLI/MCP server. Install the CLI first: + +```bash +npm install -g sp-devcontrol +``` + +## Package VSIX + +```bash +cd extension +npm install +npm run validate +npm run package +``` + +The `npm run package` command uses `vsce package` through `@vscode/vsce` and generates `sp-devcontrol-editor-2.1.0.vsix`. + +Install it in VS Code: + +```bash +code --install-extension sp-devcontrol-editor-2.1.0.vsix +``` + +Cursor and Windsurf can install the same VSIX through their VS Code-compatible extension installer. + +## MCP + +Use the `DevControl: Write MCP Config` command from the command palette or the panel to write local MCP config files: + +- `.mcp.json` +- `.cursor/mcp.json` +- `.windsurf/mcp.json` + +The generated config uses the real stdio MCP server: + +```json +{ + "mcpServers": { + "devcontrol": { + "type": "stdio", + "command": "sp-devcontrol", + "args": ["mcp:stdio"] + } + } +} +``` + +For HTTP MCP, run `DevControl: Start MCP HTTP Server`; it opens an editor terminal with `sp-devcontrol mcp:serve --port 7893`. diff --git a/extension/extension.js b/extension/extension.js new file mode 100644 index 0000000..e74aae7 --- /dev/null +++ b/extension/extension.js @@ -0,0 +1,370 @@ +const vscode = require('vscode'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const VIEW_ID = 'sp-devcontrol.panel'; +const OUTPUT_NAME = 'SP-DevControl'; + +function activate(context) { + const output = vscode.window.createOutputChannel(OUTPUT_NAME); + const provider = new DevControlViewProvider(context.extensionUri, output); + + context.subscriptions.push( + output, + vscode.window.registerWebviewViewProvider(VIEW_ID, provider), + registerCommand('sp-devcontrol.openPanel', () => vscode.commands.executeCommand('workbench.view.extension.sp-devcontrol')), + registerCommand('sp-devcontrol.projectStatus', () => provider.runCli(['project:status'])), + registerCommand('sp-devcontrol.gateStatus', () => provider.runCli(['gate:status'])), + registerCommand('sp-devcontrol.projectCheck', () => provider.runCli(['project:check'])), + registerCommand('sp-devcontrol.complianceReport', () => provider.runCli(['report:compliance'])), + registerCommand('sp-devcontrol.inject', () => provider.runCli(['inject'])), + registerCommand('sp-devcontrol.daemonStart', () => provider.runCli(['daemon', 'start'])), + registerCommand('sp-devcontrol.daemonStatus', () => provider.runCli(['daemon', 'status'])), + registerCommand('sp-devcontrol.daemonStop', () => provider.runCli(['daemon', 'stop'])), + registerCommand('sp-devcontrol.startMcpHttp', () => provider.startMcpHttp()), + registerCommand('sp-devcontrol.writeMcpConfig', () => provider.writeMcpConfig()) + ); +} + +function deactivate() {} + +function registerCommand(id, callback) { + return vscode.commands.registerCommand(id, callback); +} + +class DevControlViewProvider { + constructor(extensionUri, output) { + this.extensionUri = extensionUri; + this.output = output; + this.view = undefined; + } + + resolveWebviewView(webviewView) { + this.view = webviewView; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + webviewView.webview.html = this.renderHtml(webviewView.webview); + webviewView.webview.onDidReceiveMessage((message) => { + if (!message || typeof message.command !== 'string') return; + this.handleMessage(message.command); + }); + } + + handleMessage(command) { + const actions = { + status: () => this.runCli(['project:status']), + gates: () => this.runCli(['gate:status']), + preflight: () => this.runCli(['project:check']), + compliance: () => this.runCli(['report:compliance']), + inject: () => this.runCli(['inject']), + daemonStart: () => this.runCli(['daemon', 'start']), + daemonStatus: () => this.runCli(['daemon', 'status']), + daemonStop: () => this.runCli(['daemon', 'stop']), + mcpHttp: () => this.startMcpHttp(), + mcpConfig: () => this.writeMcpConfig(), + }; + const action = actions[command]; + if (action) action(); + } + + runCli(args) { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + vscode.window.showWarningMessage('Open a workspace before running DevControl.'); + return; + } + + const cliPath = getCliPath(); + this.setBusy(true); + this.append(`$ ${cliPath} ${args.join(' ')}\n`); + + const child = spawn(cliPath, args, { + cwd: workspaceRoot, + shell: process.platform === 'win32', + env: { + ...process.env, + DEVCONTROL_PROJECT_ROOT: workspaceRoot, + }, + }); + + child.stdout.on('data', (chunk) => this.append(chunk.toString())); + child.stderr.on('data', (chunk) => this.append(chunk.toString())); + child.on('error', (error) => { + this.append(`\nError: ${error.message}\n`); + this.setBusy(false); + vscode.window.showErrorMessage(`DevControl failed: ${error.message}`); + }); + child.on('close', (code) => { + this.append(`\nExit code: ${code}\n`); + this.setBusy(false); + if (code === 0) { + vscode.window.showInformationMessage(`DevControl command completed: ${args.join(' ')}`); + } else { + vscode.window.showErrorMessage(`DevControl command failed: ${args.join(' ')}`); + } + }); + } + + startMcpHttp() { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + vscode.window.showWarningMessage('Open a workspace before starting MCP.'); + return; + } + + const cliPath = getCliPath(); + const port = vscode.workspace.getConfiguration('spDevcontrol').get('mcpPort', 7893); + const terminal = vscode.window.createTerminal({ + name: 'DevControl MCP', + cwd: workspaceRoot, + env: { + DEVCONTROL_PROJECT_ROOT: workspaceRoot, + }, + }); + terminal.sendText(`${quoteForShell(cliPath)} mcp:serve --port ${port}`); + terminal.show(); + this.append(`Started MCP terminal: ${cliPath} mcp:serve --port ${port}\n`); + } + + writeMcpConfig() { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + vscode.window.showWarningMessage('Open a workspace before writing MCP config.'); + return; + } + + const cliPath = getCliPath(); + const devcontrolServer = { + type: 'stdio', + command: cliPath, + args: ['mcp:stdio'], + }; + + const targets = [ + path.join(workspaceRoot, '.mcp.json'), + path.join(workspaceRoot, '.cursor', 'mcp.json'), + path.join(workspaceRoot, '.windsurf', 'mcp.json'), + ]; + + for (const target of targets) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + const config = readJsonFile(target); + config.mcpServers = { + ...(isObject(config.mcpServers) ? config.mcpServers : {}), + devcontrol: devcontrolServer, + }; + fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + } + + this.append(`Wrote MCP config:\n${targets.map((target) => `- ${target}`).join('\n')}\n`); + vscode.window.showInformationMessage('DevControl MCP config written for VS Code-compatible editors.'); + } + + append(text) { + this.output.append(text); + this.output.show(true); + if (this.view) { + this.view.webview.postMessage({ type: 'output', text }); + } + } + + setBusy(isBusy) { + if (this.view) { + this.view.webview.postMessage({ type: 'busy', isBusy }); + } + } + + renderHtml(webview) { + const nonce = createNonce(); + const cspSource = webview.cspSource; + return ` + + + + + + SP-DevControl + + + +
+

SP-DevControl

+
Ready
+
+ +
Project
+
+ + + + + +
+ +
Runtime
+
+ + + +
+ +
MCP
+
+ + +
+ +

+
+  
+
+`;
+  }
+}
+
+function getWorkspaceRoot() {
+  const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0];
+  return folder ? folder.uri.fsPath : undefined;
+}
+
+function getCliPath() {
+  return vscode.workspace.getConfiguration('spDevcontrol').get('cliPath', 'sp-devcontrol');
+}
+
+function quoteForShell(value) {
+  if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
+  return `"${value.replace(/(["$`\\])/g, '\\$1')}"`;
+}
+
+function readJsonFile(filePath) {
+  try {
+    if (!fs.existsSync(filePath)) return {};
+    const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+    return isObject(parsed) ? parsed : {};
+  } catch {
+    return {};
+  }
+}
+
+function isObject(value) {
+  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function createNonce() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let text = '';
+  for (let i = 0; i < 32; i += 1) {
+    text += chars.charAt(Math.floor(Math.random() * chars.length));
+  }
+  return text;
+}
+
+module.exports = {
+  activate,
+  deactivate,
+};
diff --git a/extension/media/devcontrol.svg b/extension/media/devcontrol.svg
new file mode 100644
index 0000000..e6b41d6
--- /dev/null
+++ b/extension/media/devcontrol.svg
@@ -0,0 +1,4 @@
+
+  
+  
+
diff --git a/extension/package.json b/extension/package.json
new file mode 100644
index 0000000..6c80290
--- /dev/null
+++ b/extension/package.json
@@ -0,0 +1,143 @@
+{
+  "name": "sp-devcontrol-editor",
+  "displayName": "SP-DevControl",
+  "description": "VS Code-compatible control panel for SP-DevControl governance, gates, compliance and MCP setup.",
+  "version": "2.1.0",
+  "publisher": "solucionespro",
+  "license": "MIT",
+  "engines": {
+    "vscode": "^1.90.0"
+  },
+  "categories": [
+    "Other"
+  ],
+  "keywords": [
+    "devcontrol",
+    "governance",
+    "mcp",
+    "cursor",
+    "windsurf"
+  ],
+  "activationEvents": [
+    "onView:sp-devcontrol.panel",
+    "onCommand:sp-devcontrol.openPanel",
+    "onCommand:sp-devcontrol.projectStatus",
+    "onCommand:sp-devcontrol.gateStatus",
+    "onCommand:sp-devcontrol.projectCheck",
+    "onCommand:sp-devcontrol.complianceReport",
+    "onCommand:sp-devcontrol.inject",
+    "onCommand:sp-devcontrol.daemonStart",
+    "onCommand:sp-devcontrol.daemonStatus",
+    "onCommand:sp-devcontrol.daemonStop",
+    "onCommand:sp-devcontrol.startMcpHttp",
+    "onCommand:sp-devcontrol.writeMcpConfig"
+  ],
+  "main": "./extension.js",
+  "contributes": {
+    "configuration": {
+      "title": "SP-DevControl",
+      "properties": {
+        "spDevcontrol.cliPath": {
+          "type": "string",
+          "default": "sp-devcontrol",
+          "description": "Path or command used to run the SP-DevControl CLI."
+        },
+        "spDevcontrol.mcpPort": {
+          "type": "number",
+          "default": 7893,
+          "description": "Port used when starting the DevControl MCP HTTP server from the extension."
+        }
+      }
+    },
+    "viewsContainers": {
+      "activitybar": [
+        {
+          "id": "sp-devcontrol",
+          "title": "DevControl",
+          "icon": "media/devcontrol.svg"
+        }
+      ]
+    },
+    "views": {
+      "sp-devcontrol": [
+        {
+          "id": "sp-devcontrol.panel",
+          "name": "Control"
+        }
+      ]
+    },
+    "commands": [
+      {
+        "command": "sp-devcontrol.openPanel",
+        "title": "DevControl: Open Panel",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.projectStatus",
+        "title": "DevControl: Project Status",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.gateStatus",
+        "title": "DevControl: Gate Status",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.projectCheck",
+        "title": "DevControl: Run Preflight",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.complianceReport",
+        "title": "DevControl: Compliance Report",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.inject",
+        "title": "DevControl: Inject Editor Config",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.daemonStart",
+        "title": "DevControl: Start Daemon",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.daemonStatus",
+        "title": "DevControl: Daemon Status",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.daemonStop",
+        "title": "DevControl: Stop Daemon",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.startMcpHttp",
+        "title": "DevControl: Start MCP HTTP Server",
+        "category": "DevControl"
+      },
+      {
+        "command": "sp-devcontrol.writeMcpConfig",
+        "title": "DevControl: Write MCP Config",
+        "category": "DevControl"
+      }
+    ],
+    "menus": {
+      "view/title": [
+        {
+          "command": "sp-devcontrol.projectStatus",
+          "when": "view == sp-devcontrol.panel",
+          "group": "navigation"
+        }
+      ]
+    }
+  },
+  "scripts": {
+    "validate": "node -e \"JSON.parse(require('fs').readFileSync('package.json', 'utf8')); console.log('extension/package.json valid')\"",
+    "package": "npx @vscode/vsce package"
+  },
+  "devDependencies": {
+    "@vscode/vsce": "^3.2.2"
+  }
+}
diff --git a/src/api.ts b/src/api.ts
index 4faa7b1..115fc32 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -32,6 +32,7 @@ import { DB_PATH } from './paths.js'
 import { loadConfig, hasConfig } from './config.js'
 import { generateSessionId, createSession } from './session.js'
 import type { Session } from './types.js'
+import { verifyHumanApprovalToken } from './human-approval.js'
 
 const DEFAULT_PORT = 7891
 const GLOBAL_PROJECTS_FILE = join(homedir(), '.devcontrol', 'projects.json')
@@ -71,7 +72,18 @@ function writeProjectsRegistry(projects: ProjectEntry[]): void {
 
 function resolveProjectRoot(req: Request): string {
   const header = req.headers['x-project-path']
-  if (typeof header === 'string' && header.trim()) return resolve(header.trim())
+  if (typeof header === 'string' && header.trim()) {
+    const candidate = resolve(header.trim())
+    if (!existsSync(candidate)) {
+      throw new Error(`Project path does not exist: ${candidate}`)
+    }
+    const projects = readProjectsRegistry()
+    const isRegistered = projects.some(p => candidate === p.path || candidate.startsWith(p.path + '/'))
+    if (!isRegistered && projects.length > 0) {
+      throw new Error(`Project path is not registered: ${candidate}. Register it first via POST /projects/register.`)
+    }
+    return candidate
+  }
   return process.cwd()
 }
 
@@ -93,7 +105,7 @@ function corsLocalhostMiddleware(req: Request, res: Response, next: NextFunction
 
   if (origin) res.setHeader('Access-Control-Allow-Origin', origin)
   res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
-  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Project-Path, Authorization')
+  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Project-Path, Authorization, X-Human-Approval-Token')
   res.setHeader('Vary', 'Origin')
 
   if (req.method === 'OPTIONS') {
@@ -116,6 +128,25 @@ function buildAuthMiddleware(token: string) {
   }
 }
 
+function humanApprovalTokenFromRequest(req: Request): string | undefined {
+  const body = req.body as { humanApprovalToken?: unknown } | undefined
+  if (typeof body?.humanApprovalToken === 'string') return body.humanApprovalToken
+
+  const header = req.headers['x-human-approval-token']
+  if (typeof header === 'string') return header
+  if (Array.isArray(header)) return header[0]
+  return undefined
+}
+
+function requireHumanApprovalForHttp(req: Request, res: Response): boolean {
+  const verification = verifyHumanApprovalToken(humanApprovalTokenFromRequest(req))
+  if (verification.ok) return true
+
+  const status = verification.reason?.includes('not configured') ? 403 : 401
+  res.status(status).json({ error: verification.reason })
+  return false
+}
+
 export function createApiServer(port: number, token?: string): http.Server {
   const app = express()
 
@@ -135,7 +166,8 @@ export function createApiServer(port: number, token?: string): http.Server {
     try {
       res.json(readProjectsRegistry())
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -171,7 +203,8 @@ export function createApiServer(port: number, token?: string): http.Server {
       writeProjectsRegistry(projects)
       res.status(201).json(entry)
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -182,7 +215,8 @@ export function createApiServer(port: number, token?: string): http.Server {
       const limit = parseInt(String(req.query['limit'] ?? '20'), 10)
       res.json(listSessions(db, Math.min(isNaN(limit) ? 20 : Math.max(1, limit), 200)))
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -211,7 +245,8 @@ export function createApiServer(port: number, token?: string): http.Server {
       insertSession(db, session)
       res.status(201).json(session)
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -226,7 +261,8 @@ export function createApiServer(port: number, token?: string): http.Server {
       }
       res.json(session)
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -251,7 +287,8 @@ export function createApiServer(port: number, token?: string): http.Server {
       updateSession(db, updated)
       res.json(updated)
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -266,12 +303,15 @@ export function createApiServer(port: number, token?: string): http.Server {
       }
       res.json(getChangesForSession(db, req.params['id']!))
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
   app.post('/sessions/:id/changes/:cid/approve', (req: Request, res: Response) => {
     try {
+      if (!requireHumanApprovalForHttp(req, res)) return
+
       const projectRoot = resolveProjectRoot(req)
       const db = getProjectDb(projectRoot)
       const { id, cid } = req.params as { id: string; cid: string }
@@ -312,7 +352,8 @@ export function createApiServer(port: number, token?: string): http.Server {
 
       res.json({ change: getChange(db, cid), approval })
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -348,7 +389,8 @@ export function createApiServer(port: number, token?: string): http.Server {
 
       res.json({ change: getChange(db, cid) })
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
@@ -392,7 +434,8 @@ export function createApiServer(port: number, token?: string): http.Server {
         stack: config?.stack ?? [],
       })
     } catch (err) {
-      res.status(500).json({ error: String(err) })
+      const status = err instanceof Error && err.message.includes('Project path') ? 400 : 500
+      res.status(status).json({ error: String(err) })
     }
   })
 
diff --git a/src/cli.ts b/src/cli.ts
index 624b143..6646363 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -55,7 +55,6 @@ import {
   getChangesForSession,
   getDb,
   getSession,
-  insertApproval,
   insertChecklistItems,
   insertSession,
   insertSessionRequest,
@@ -77,6 +76,8 @@ import inquirer from 'inquirer'
 import { validateChangeset } from './validator.js'
 import { FileWatcher } from './watcher.js'
 import { approveGate, getGateSummaryTable, initGates, isGateOpen, loadGates, rejectGate, resetGate, type GatePhase } from './gates.js'
+import { HUMAN_APPROVAL_TOKEN_ENV, verifyHumanApprovalToken } from './human-approval.js'
+import { grantSessionApproval } from './session-approval.js'
 
 // Daemon worker mode: when spawned by startDaemon(), start the API server inline
 if (process.argv[2] === '__daemon_worker__') {
@@ -113,6 +114,29 @@ function sessionApprovals(projectRoot: string, sessionId?: string): ApprovalReco
   return listApprovals(db, sessionId)
 }
 
+async function resolveHumanApprovalToken(providedToken?: string): Promise {
+  if (providedToken) return providedToken
+  if (!process.stdin.isTTY) return undefined
+
+  const { token } = await inquirer.prompt<{ token: string }>([{
+    type: 'password',
+    name: 'token',
+    message: `Human approval token (${HUMAN_APPROVAL_TOKEN_ENV})`,
+    mask: '*',
+  }])
+
+  return token ?? ''
+}
+
+async function requireHumanApprovalToken(providedToken?: string): Promise {
+  const token = await resolveHumanApprovalToken(providedToken)
+  const verification = verifyHumanApprovalToken(token)
+  if (!verification.ok) {
+    throw new Error(`${verification.reason} Set ${HUMAN_APPROVAL_TOKEN_ENV} out of band and provide it with --human-token or the TTY prompt.`)
+  }
+  return token ?? ''
+}
+
 function refreshSessionArtifacts(projectRoot: string, db: ReturnType, session: Session): void {
   const requests = listSessionRequests(db, session.id)
   const checklist = listChecklistItems(db, session.id)
@@ -203,8 +227,8 @@ program.command('init:interactive')
 
     // Auto-inject agent rules after init
     const injection = buildInjection(config, projectRoot)
-    const written = writeInjectionFiles(injection, projectRoot, config)
-    console.log(`Agent rules injected: ${written.length} files`)
+    const injectResults = writeInjectionFiles(injection, projectRoot, config)
+    console.log(`Agent rules injected: ${injectResults.filter(r => r.action !== 'skipped-custom').length} files`)
 
     console.log('\nTo start a governed session:')
     console.log('  sp-devcontrol session:start --objective "your goal"')
@@ -306,7 +330,9 @@ program.command('policy:command:review:remove')
 program.command('policy:command:approve')
   .description('Approve one command pattern explicitly')
   .requiredOption('--command ')
-  .action((options) => {
+  .option('--human-token ', `Required; must match ${HUMAN_APPROVAL_TOKEN_ENV}`)
+  .action(async (options) => {
+    await requireHumanApprovalToken(options.humanToken)
     const result = approveCommand(process.cwd(), options.command)
     console.log(JSON.stringify(result, null, 2))
   })
@@ -473,6 +499,7 @@ program.command('session:change:approve')
   .requiredOption('--change-id ')
   .option('--message ', 'decision message', 'approved from CLI')
   .option('--yes', 'Skip interactive TUI and approve immediately (non-interactive / CI mode)', false)
+  .option('--human-token ', `Required with --yes; must match ${HUMAN_APPROVAL_TOKEN_ENV}`)
   .option('--dry-run', 'Preview the approval without executing', false)
   .action(async (options) => {
     const projectRoot = process.cwd()
@@ -483,6 +510,12 @@ program.command('session:change:approve')
       return
     }
 
+    if (options.yes) {
+      await requireHumanApprovalToken(options.humanToken)
+    } else if (!process.stdin.isTTY) {
+      throw new Error(`Interactive change approval requires a TTY. For non-interactive approval use --yes with --human-token matching ${HUMAN_APPROVAL_TOKEN_ENV}.`)
+    }
+
     const db = openDb(projectRoot)
     const change = getChange(db, options.changeId)
     if (!change) throw new Error(`Change not found: ${options.changeId}`)
@@ -754,18 +787,16 @@ program.command('session:approval:grant')
   .requiredOption('--type ')
   .requiredOption('--target ')
   .option('--reason ', 'approval rationale', 'manual approval from CLI')
-  .action((options) => {
+  .option('--human-token ', `Required; must match ${HUMAN_APPROVAL_TOKEN_ENV}`)
+  .action(async (options) => {
+    const humanApprovalToken = await requireHumanApprovalToken(options.humanToken)
     const db = openDb(process.cwd())
-    const session = getSession(db, options.session)
-    if (!session) throw new Error(`Session not found: ${options.session}`)
-
-    const approval = insertApproval(db, {
+    const approval = grantSessionApproval(db, {
       sessionId: options.session,
       approvalType: options.type,
       target: options.target,
-      scope: 'session',
       reason: options.reason,
-      createdBy: 'user',
+      humanApprovalToken,
     })
     console.log(JSON.stringify(approval, null, 2))
     closeDb()
@@ -894,9 +925,15 @@ program.command('inject')
     const projectRoot = process.cwd()
     const config = loadConfig(projectRoot)
     const injection = buildInjection(config, projectRoot)
-    const written = writeInjectionFiles(injection, projectRoot, config)
+    const results = writeInjectionFiles(injection, projectRoot, config)
+    const written = results.filter(r => r.action === 'written' || r.action === 'created')
+    const skipped = results.filter(r => r.action === 'skipped-custom')
     console.log('Agent rule files generated:')
-    for (const file of written) console.log(`  ✓ ${file}`)
+    for (const r of written) console.log(`  ✓ ${r.path}`)
+    if (skipped.length > 0) {
+      console.log('\nSkipped (custom content preserved):')
+      for (const r of skipped) console.log(`  ⊘ ${r.path}`)
+    }
   })
 
 // ─── Compliance Report ──────────────────────────────────────────────────────
@@ -1205,9 +1242,11 @@ program.command('gate:approve')
   .requiredOption('--phase ', 'Phase to approve: design|development|review|publish')
   .requiredOption('--by ', 'Name of the person approving')
   .option('--notes ', 'Optional notes')
-  .action((opts) => {
+  .option('--human-token ', `Required; must match ${HUMAN_APPROVAL_TOKEN_ENV}`)
+  .action(async (opts) => {
+    const humanApprovalToken = await requireHumanApprovalToken(opts.humanToken)
     const root = process.cwd()
-    const data = approveGate(root, opts.phase as GatePhase, opts.by, opts.notes)
+    const data = approveGate(root, opts.phase as GatePhase, opts.by, opts.notes, { humanApprovalToken })
     console.log(`✅ Gate approved: ${opts.phase} (by ${opts.by})`)
     console.log(getGateSummaryTable(data))
   })
diff --git a/src/gates.ts b/src/gates.ts
index c96b382..0720be3 100644
--- a/src/gates.ts
+++ b/src/gates.ts
@@ -9,6 +9,7 @@
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
 import { resolve } from 'path'
 import { CONTROL_DIR } from './paths.js'
+import { verifyHumanApprovalToken } from './human-approval.js'
 
 export type GatePhase = 'design' | 'development' | 'review' | 'publish'
 export type GateStatus = 'pending' | 'open' | 'blocked'
@@ -28,6 +29,10 @@ export interface GatesFile {
   gates: Record
 }
 
+export interface ApproveGateOptions {
+  humanApprovalToken?: string
+}
+
 const PHASES: GatePhase[] = ['design', 'development', 'review', 'publish']
 
 function gatesPath(projectRoot: string): string {
@@ -75,7 +80,18 @@ export function isGateOpen(projectRoot: string, phase: GatePhase): boolean {
   return getGate(projectRoot, phase).status === 'open'
 }
 
-export function approveGate(projectRoot: string, phase: GatePhase, approvedBy: string, notes?: string): GatesFile {
+export function approveGate(
+  projectRoot: string,
+  phase: GatePhase,
+  approvedBy: string,
+  notes?: string,
+  options: ApproveGateOptions = {},
+): GatesFile {
+  const verification = verifyHumanApprovalToken(options.humanApprovalToken)
+  if (!verification.ok) {
+    throw new Error(verification.reason)
+  }
+
   const data = loadGates(projectRoot)
   data.gates[phase] = { phase, status: 'open', updatedAt: new Date().toISOString(), approvedBy, notes }
   saveGates(projectRoot, data)
diff --git a/src/hooks.ts b/src/hooks.ts
index 215de35..616a94a 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -21,22 +21,49 @@ ${HOOK_MARKER}
 DB_PATH=".devcontrol/storage/devcontrol.db.json"
 
 if [ ! -f "$DB_PATH" ]; then
-  exit 0
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: COMMIT BLOCKED                             ║"
+  echo "║  DevControl state could not be verified.                   ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  echo "  .devcontrol/storage/devcontrol.db.json is missing."
+  echo ""
+  echo "Initialize or repair DevControl before committing."
+  echo ""
+  exit 1
 fi
 
 PENDING_HIGH=$(node -e "
   try {
     const db = JSON.parse(require('fs').readFileSync('$DB_PATH', 'utf-8'));
     const pending = (db.changes || []).filter(c =>
-      c.status === 'pending' && (c.riskLevel === 'HIGH' || c.controlViolations.some(v => v.severity === 'error'))
+      c.status === 'pending' && (c.riskLevel === 'HIGH' || (c.controlViolations || []).some(v => v.severity === 'error'))
     );
     if (pending.length > 0) {
       console.log('BLOCKED');
       pending.forEach(c => console.error('  [' + c.riskLevel + '] ' + c.id + ' — ' + c.files.map(f => f.filepath).join(', ')));
     }
-  } catch(e) { /* allow commit if DB unreadable */ }
+  } catch(e) {
+    console.log('DB_UNREADABLE');
+    console.error('  Unable to read DevControl DB: ' + e.message);
+  }
 " 2>&1)
 
+if echo "$PENDING_HIGH" | grep -q "DB_UNREADABLE"; then
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: COMMIT BLOCKED                             ║"
+  echo "║  DevControl state could not be verified.                   ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  echo "$PENDING_HIGH" | grep -v "DB_UNREADABLE"
+  echo ""
+  echo "Fix the DevControl DB or reinstall hooks before committing."
+  echo ""
+  exit 1
+fi
+
 if echo "$PENDING_HIGH" | grep -q "BLOCKED"; then
   echo ""
   echo "╔══════════════════════════════════════════════════════════════╗"
@@ -48,7 +75,43 @@ if echo "$PENDING_HIGH" | grep -q "BLOCKED"; then
   echo ""
   echo "To approve:  sp-devcontrol session:change:approve --change-id "
   echo "To reject:   sp-devcontrol session:change:reject --change-id "
-  echo "To bypass:   git commit --no-verify  (WARNING: skips all governance hooks)"
+  echo ""
+  exit 1
+fi
+
+GATES_FILE=".devcontrol/gates.json"
+if [ ! -f "$GATES_FILE" ]; then
+  COMMIT_GATE_STATUS="MISSING"
+else
+  COMMIT_GATE_STATUS=$(node -e "
+    try {
+      const g = JSON.parse(require('fs').readFileSync('$GATES_FILE', 'utf-8'));
+      const development = g.gates && g.gates.development;
+      if (!development || development.status !== 'open') {
+        console.log('NOT_OPEN');
+      }
+    } catch(e) {
+      console.log('GATE_UNREADABLE');
+      console.error('  Unable to read DevControl gates: ' + e.message);
+    }
+  " 2>&1)
+fi
+
+if [ "$COMMIT_GATE_STATUS" != "" ]; then
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: COMMIT BLOCKED                             ║"
+  echo "║  Development gate is not open.                             ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  if [ "$COMMIT_GATE_STATUS" = "MISSING" ]; then
+    echo "  .devcontrol/gates.json is missing."
+  else
+    echo "$COMMIT_GATE_STATUS" | grep -v "NOT_OPEN" | grep -v "GATE_UNREADABLE"
+  fi
+  echo ""
+  echo "A human must approve the development gate before committing:"
+  echo "  sp-devcontrol gate:approve --phase development --by \"Your Name\""
   echo ""
   exit 1
 fi
@@ -62,7 +125,17 @@ ${HOOK_MARKER}
 DB_PATH=".devcontrol/storage/devcontrol.db.json"
 
 if [ ! -f "$DB_PATH" ]; then
-  exit 0
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: PUSH BLOCKED                               ║"
+  echo "║  DevControl state could not be verified.                   ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  echo "  .devcontrol/storage/devcontrol.db.json is missing."
+  echo ""
+  echo "Initialize or repair DevControl before pushing."
+  echo ""
+  exit 1
 fi
 
 ISSUES=$(node -e "
@@ -77,9 +150,26 @@ ISSUES=$(node -e "
       console.log('BLOCKED');
       issues.forEach(i => console.error('  - ' + i));
     }
-  } catch(e) { /* allow push if DB unreadable */ }
+  } catch(e) {
+    console.log('DB_UNREADABLE');
+    console.error('  Unable to read DevControl DB: ' + e.message);
+  }
 " 2>&1)
 
+if echo "$ISSUES" | grep -q "DB_UNREADABLE"; then
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: PUSH BLOCKED                               ║"
+  echo "║  DevControl state could not be verified.                   ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  echo "$ISSUES" | grep -v "DB_UNREADABLE"
+  echo ""
+  echo "Fix the DevControl DB or reinstall hooks before pushing."
+  echo ""
+  exit 1
+fi
+
 if echo "$ISSUES" | grep -q "BLOCKED"; then
   echo ""
   echo "╔══════════════════════════════════════════════════════════════╗"
@@ -90,28 +180,45 @@ if echo "$ISSUES" | grep -q "BLOCKED"; then
   echo "$ISSUES" | grep -v "BLOCKED"
   echo ""
   echo "Resolve all pending changes and close sessions before pushing."
-  echo "To bypass:   git push --no-verify  (WARNING: skips all governance hooks)"
   echo ""
   exit 1
 fi
 
 GATES_FILE=".devcontrol/gates.json"
-if [ -f "$GATES_FILE" ]; then
+if [ ! -f "$GATES_FILE" ]; then
+  REVIEW_STATUS="MISSING"
+else
   REVIEW_STATUS=$(node -e "
     try {
       const g = JSON.parse(require('fs').readFileSync('$GATES_FILE', 'utf-8'));
       const review = g.gates && g.gates.review;
-      if (review && review.status !== 'open') {
+      if (!review || review.status !== 'open') {
         console.log('NOT_OPEN');
       }
-    } catch(e) { /* ignore */ }
-  " 2>/dev/null)
-  if [ "$REVIEW_STATUS" = "NOT_OPEN" ]; then
-    echo ""
-    echo "⚠  SP-DevControl WARNING: review gate not approved."
-    echo "   Run: sp-devcontrol gate:approve --phase review --by \"Your Name\""
-    echo ""
+    } catch(e) {
+      console.log('GATE_UNREADABLE');
+      console.error('  Unable to read DevControl gates: ' + e.message);
+    }
+  " 2>&1)
+fi
+
+if [ "$REVIEW_STATUS" != "" ]; then
+  echo ""
+  echo "╔══════════════════════════════════════════════════════════════╗"
+  echo "║  SP-DevControl: PUSH BLOCKED                               ║"
+  echo "║  Review gate is not open.                                  ║"
+  echo "╚══════════════════════════════════════════════════════════════╝"
+  echo ""
+  if [ "$REVIEW_STATUS" = "MISSING" ]; then
+    echo "  .devcontrol/gates.json is missing."
+  else
+    echo "$REVIEW_STATUS" | grep -v "NOT_OPEN" | grep -v "GATE_UNREADABLE"
   fi
+  echo ""
+  echo "A human must approve the review gate before pushing:"
+  echo "  sp-devcontrol gate:approve --phase review --by \"Your Name\""
+  echo ""
+  exit 1
 fi
 `
 
diff --git a/src/human-approval.ts b/src/human-approval.ts
new file mode 100644
index 0000000..9033864
--- /dev/null
+++ b/src/human-approval.ts
@@ -0,0 +1,56 @@
+/**
+ * SP-DevControl v2.0.0
+ * Human approval token verification for non-interactive approval paths
+ *
+ * Copyright (c) 2026 Pedro Rojas — SolucionesPro (Ecuador)
+ * MIT License — see LICENSE file for details
+ */
+
+import { timingSafeEqual } from 'node:crypto'
+
+export const HUMAN_APPROVAL_TOKEN_ENV = 'DEVCONTROL_HUMAN_APPROVAL_TOKEN'
+
+export interface HumanApprovalVerification {
+  ok: boolean
+  reason?: string
+}
+
+export function verifyHumanApprovalToken(
+  providedToken: string | undefined,
+  env: NodeJS.ProcessEnv = process.env,
+): HumanApprovalVerification {
+  const expectedToken = env[HUMAN_APPROVAL_TOKEN_ENV]
+
+  if (!expectedToken || expectedToken.trim().length === 0) {
+    return {
+      ok: false,
+      reason: `${HUMAN_APPROVAL_TOKEN_ENV} is not configured; non-interactive approval is disabled.`,
+    }
+  }
+
+  if (!providedToken || providedToken.length === 0) {
+    return {
+      ok: false,
+      reason: 'Human approval token is required for non-interactive approval.',
+    }
+  }
+
+  const provided = Buffer.from(providedToken)
+  const expected = Buffer.from(expectedToken)
+
+  if (provided.length !== expected.length) {
+    return {
+      ok: false,
+      reason: 'Human approval token is invalid.',
+    }
+  }
+
+  if (!timingSafeEqual(provided, expected)) {
+    return {
+      ok: false,
+      reason: 'Human approval token is invalid.',
+    }
+  }
+
+  return { ok: true }
+}
diff --git a/src/mcp.ts b/src/mcp.ts
index 16e28fd..8c10e44 100644
--- a/src/mcp.ts
+++ b/src/mcp.ts
@@ -23,6 +23,7 @@ import { DB_PATH } from "./paths.js";
 import { runPreflightChecks } from "./preflight.js";
 import { generateSessionId, createSession } from "./session.js";
 import { generateComplianceReport, renderComplianceMarkdown } from "./compliance.js";
+import { HUMAN_APPROVAL_TOKEN_ENV, verifyHumanApprovalToken } from "./human-approval.js";
 
 const DEFAULT_HTTP_PORT = 7893;
 const MAX_MCP_SESSIONS = 50;
@@ -176,16 +177,29 @@ function buildMcpServer(defaultProjectRoot?: string): McpServer {
   server.registerTool(
     "devcontrol_approve_change",
     {
-      description: "Approve a pending change by ID.",
+      description: `Non-interactive change approval request. This MCP tool is not human approval by itself; it requires an out-of-band human token matching ${HUMAN_APPROVAL_TOKEN_ENV} and is disabled when that variable is not configured.`,
       inputSchema: {
         changeId: z.string().describe("Change ID to approve (e.g. ds-20260625-001-c01)"),
         message: z.string().optional().describe("Optional approval note"),
+        humanApprovalToken: z.string().optional().describe(`Required out-of-band human approval token. Must match ${HUMAN_APPROVAL_TOKEN_ENV}.`),
         projectRoot: z.string().optional().describe("Absolute path to project root (defaults to server CWD)"),
       },
     },
     async (params) => {
-      const { changeId, message } = params;
+      const { changeId, message, humanApprovalToken } = params;
       const projectRoot = resolveRoot(params);
+
+      const humanApproval = verifyHumanApprovalToken(humanApprovalToken);
+      if (!humanApproval.ok) {
+        return {
+          content: [{
+            type: "text" as const,
+            text: `Change \`${changeId}\` was not approved. ${humanApproval.reason}`,
+          }],
+          isError: true,
+        };
+      }
+
       const db = getMcpDb(projectRoot);
       const change = getChange(db, changeId);
 
@@ -412,7 +426,17 @@ export async function startMcpHttp(
       }
 
       const chunks: Buffer[] = [];
-      req.on("data", (chunk: Buffer) => chunks.push(chunk));
+      let totalSize = 0;
+      const MAX_BODY_BYTES = 1024 * 1024; // 1MB
+      req.on("data", (chunk: Buffer) => {
+        totalSize += chunk.length;
+        if (totalSize > MAX_BODY_BYTES) {
+          res.writeHead(413).end("Request body too large (max 1MB)");
+          req.destroy();
+          return;
+        }
+        chunks.push(chunk);
+      });
       req.on("end", async () => {
         const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : undefined;
         try {
diff --git a/src/policy.ts b/src/policy.ts
index e70ebbc..e1212de 100644
--- a/src/policy.ts
+++ b/src/policy.ts
@@ -12,7 +12,16 @@ import { CONTROL_DIR } from './paths.js'
 import { loadConfig } from './config.js'
 import type { ApprovalRecord } from './types.js'
 
-const COMMAND_PREFIXES = ['sudo ', 'npx ', 'command ', 'time ', 'env ', 'nohup ', 'nice ', 'eval ', 'xargs ', 'timeout ', 'stdbuf ', 'bundled ']
+const COMMAND_PREFIXES = ['sudo ', 'npx ', 'command ', 'time ', 'env ', 'nohup ', 'nice ', 'xargs ', 'timeout ', 'stdbuf ', 'bundled ']
+
+const HARDCODED_BLOCKED = [
+  'rm -rf', 'rm -fr',
+  'git reset --hard', 'git push --force', 'git push -f',
+  'DROP TABLE', 'DROP DATABASE', 'TRUNCATE', 'DELETE FROM',
+  'FORMAT', 'mkfs', 'dd if=',
+  ':(){ :|:& };:',
+  'eval ',
+]
 
 function normalizeCommand(cmd: string): string {
   let normalized = cmd.trim().replace(/\s+/g, ' ')
@@ -200,6 +209,23 @@ export function evaluateCommandRisk(projectRoot: string, command: string, approv
     }
   }
 
+  const hardMatch = HARDCODED_BLOCKED.find(entry => commandMatches(command, entry))
+  if (hardMatch) {
+    return {
+      command,
+      decision: 'BLOCK',
+      reason: `Command matches hardcoded safety block: ${hardMatch}`,
+    }
+  }
+
+  if (/\$\(.*\)/.test(command) || /`[^`]+`/.test(command)) {
+    return {
+      command,
+      decision: 'BLOCK',
+      reason: 'Command contains command substitution ($() or backticks) — potential injection',
+    }
+  }
+
   const matched = policy.blockedCommands.find(entry => commandMatches(command, entry))
   if (matched) {
     return {
@@ -257,5 +283,8 @@ function matchesPattern(filepath: string, pattern: string): boolean {
   if (pattern.endsWith('/**')) {
     return filepath.startsWith(pattern.slice(0, -3))
   }
-  return filepath === pattern || filepath.startsWith(pattern.replace(/\*\*/g, ''))
+  if (pattern.includes('*')) {
+    return filepath === pattern || filepath.startsWith(pattern.replace(/\*\*/g, ''))
+  }
+  return filepath === pattern || filepath === pattern + '/'
 }
diff --git a/tests/api-integration.test.ts b/tests/api-integration.test.ts
index 8704ab4..bdf0a9b 100644
--- a/tests/api-integration.test.ts
+++ b/tests/api-integration.test.ts
@@ -12,11 +12,16 @@ import { tmpdir } from 'os'
 import { join } from 'path'
 import { describe, it, expect, beforeEach, afterEach } from 'vitest'
 import { createApiServer } from '../src/api.js'
-import { closeDb } from '../src/storage.js'
+import { closeDb, getDb, getChange, insertChange, insertSession } from '../src/storage.js'
+import { DB_PATH } from '../src/paths.js'
+import { createSession } from '../src/session.js'
+import { HUMAN_APPROVAL_TOKEN_ENV } from '../src/human-approval.js'
+import type { ChangeSet } from '../src/types.js'
 
 let server: http.Server
 let port: number
 let tmpDir: string
+let previousHumanApprovalToken: string | undefined
 
 interface ApiResponse {
   status: number
@@ -65,6 +70,7 @@ async function apiRequest(
 }
 
 beforeEach(async () => {
+  previousHumanApprovalToken = process.env[HUMAN_APPROVAL_TOKEN_ENV]
   tmpDir = await mkdtemp(join(tmpdir(), 'devcontrol-int-test-'))
   await new Promise((resolve, reject) => {
     server = createApiServer(0)
@@ -78,6 +84,11 @@ beforeEach(async () => {
 })
 
 afterEach(async () => {
+  if (previousHumanApprovalToken === undefined) {
+    delete process.env[HUMAN_APPROVAL_TOKEN_ENV]
+  } else {
+    process.env[HUMAN_APPROVAL_TOKEN_ENV] = previousHumanApprovalToken
+  }
   await new Promise((resolve, reject) => {
     server.close(err => { if (err) reject(err); else resolve() })
   })
@@ -86,6 +97,43 @@ afterEach(async () => {
   await rm(tmpDir, { recursive: true, force: true })
 })
 
+function createPendingChange(sessionId: string, changeId: string): ChangeSet {
+  return {
+    id: changeId,
+    sessionId,
+    agent: 'test-agent',
+    files: [{
+      filepath: 'src/example.ts',
+      eventType: 'modified',
+      linesAdded: 1,
+      linesRemoved: 0,
+      outOfScope: false,
+      diffContent: '+const value = 1',
+      snapshotBefore: '',
+      snapshotAfter: 'const value = 1',
+    }],
+    depsAdded: [],
+    depsInvalid: [],
+    riskLevel: 'LOW',
+    status: 'pending',
+    detectedAt: new Date().toISOString(),
+    controlViolations: [],
+  }
+}
+
+async function seedPendingApiChange(): Promise<{ sessionId: string; changeId: string }> {
+  await apiRequest(server, 'POST', '/projects/register', { path: tmpDir })
+  const db = getDb(join(tmpDir, DB_PATH))
+  const sessionId = 'ds-20260719-001'
+  const changeId = `${sessionId}.001`
+  const session = createSession(sessionId, tmpDir, 'test-agent', 'watch')
+  session.status = 'active'
+  session.totalChanges = 1
+  insertSession(db, session)
+  insertChange(db, createPendingChange(sessionId, changeId))
+  return { sessionId, changeId }
+}
+
 describe('API Integration', () => {
   it('GET /health returns 200 with status ok and version 2.1.0', async () => {
     const { status, data } = await apiRequest(server, 'GET', '/health')
@@ -125,6 +173,7 @@ describe('API Integration', () => {
   })
 
   it('GET /sessions with project that has no existing DB returns empty array', async () => {
+    await apiRequest(server, 'POST', '/projects/register', { path: tmpDir })
     const { status, data } = await apiRequest(
       server,
       'GET',
@@ -138,6 +187,7 @@ describe('API Integration', () => {
   })
 
   it('POST /sessions/start without objective returns 400', async () => {
+    await apiRequest(server, 'POST', '/projects/register', { path: tmpDir })
     const { status, data } = await apiRequest(
       server,
       'POST',
@@ -151,6 +201,7 @@ describe('API Integration', () => {
   })
 
   it('POST /sessions/start with valid objective returns 201 with session id', async () => {
+    await apiRequest(server, 'POST', '/projects/register', { path: tmpDir })
     const { status, data } = await apiRequest(
       server,
       'POST',
@@ -165,4 +216,41 @@ describe('API Integration', () => {
     expect(d.objective).toBe('Integration test session')
     expect(d.status).toBe('active')
   })
+
+  it('POST /sessions/:id/changes/:cid/approve rejects missing human token', async () => {
+    process.env[HUMAN_APPROVAL_TOKEN_ENV] = 'human-secret'
+    const { sessionId, changeId } = await seedPendingApiChange()
+
+    const { status, data } = await apiRequest(
+      server,
+      'POST',
+      `/sessions/${sessionId}/changes/${changeId}/approve`,
+      { message: 'approved via test' },
+      { 'X-Project-Path': tmpDir },
+    )
+
+    expect(status).toBe(401)
+    const d = data as { error: string }
+    expect(d.error).toMatch(/token is required/i)
+
+    const db = getDb(join(tmpDir, DB_PATH))
+    expect(getChange(db, changeId)?.status).toBe('pending')
+  })
+
+  it('POST /sessions/:id/changes/:cid/approve accepts valid human token header', async () => {
+    process.env[HUMAN_APPROVAL_TOKEN_ENV] = 'human-secret'
+    const { sessionId, changeId } = await seedPendingApiChange()
+
+    const { status, data } = await apiRequest(
+      server,
+      'POST',
+      `/sessions/${sessionId}/changes/${changeId}/approve`,
+      { message: 'approved via test' },
+      { 'X-Project-Path': tmpDir, 'X-Human-Approval-Token': 'human-secret' },
+    )
+
+    expect(status).toBe(200)
+    const d = data as { change: { status: string } }
+    expect(d.change.status).toBe('approved')
+  })
 })
diff --git a/tests/gates.test.ts b/tests/gates.test.ts
index 522832d..a0fb80e 100644
--- a/tests/gates.test.ts
+++ b/tests/gates.test.ts
@@ -7,11 +7,12 @@
 import { mkdtempSync, rmSync, mkdirSync } from 'fs'
 import { tmpdir } from 'os'
 import { join } from 'path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import {
   approveGate, getGate, getGateSummaryTable, initGates,
   isGateOpen, loadGates, rejectGate, resetGate,
 } from '../src/gates.js'
+import { HUMAN_APPROVAL_TOKEN_ENV, verifyHumanApprovalToken } from '../src/human-approval.js'
 
 function tempProject(): string {
   const dir = mkdtempSync(join(tmpdir(), 'sp-devcontrol-gates-'))
@@ -19,7 +20,26 @@ function tempProject(): string {
   return dir
 }
 
+let previousHumanApprovalToken: string | undefined
+
+const humanApproval = (humanApprovalToken?: string) => ({ humanApprovalToken })
+
+const validHumanApproval = () => humanApproval('human-secret')
+
 describe('human authorization gates', () => {
+  beforeEach(() => {
+    previousHumanApprovalToken = process.env[HUMAN_APPROVAL_TOKEN_ENV]
+    process.env[HUMAN_APPROVAL_TOKEN_ENV] = 'human-secret'
+  })
+
+  afterEach(() => {
+    if (previousHumanApprovalToken === undefined) {
+      delete process.env[HUMAN_APPROVAL_TOKEN_ENV]
+    } else {
+      process.env[HUMAN_APPROVAL_TOKEN_ENV] = previousHumanApprovalToken
+    }
+  })
+
   it('all gates start as pending after initGates', () => {
     const dir = tempProject()
     const data = initGates(dir, 'test-project')
@@ -27,13 +47,17 @@ describe('human authorization gates', () => {
     expect(data.gates.development.status).toBe('pending')
     expect(data.gates.review.status).toBe('pending')
     expect(data.gates.publish.status).toBe('pending')
+    expect(verifyHumanApprovalToken('agent-token', {}).ok).toBe(false)
+    expect(verifyHumanApprovalToken(undefined, { [HUMAN_APPROVAL_TOKEN_ENV]: 'human-secret' }).ok).toBe(false)
+    expect(verifyHumanApprovalToken('agent-token', { [HUMAN_APPROVAL_TOKEN_ENV]: 'human-secret' }).ok).toBe(false)
+    expect(verifyHumanApprovalToken('human-secret', { [HUMAN_APPROVAL_TOKEN_ENV]: 'human-secret' }).ok).toBe(true)
     rmSync(dir, { recursive: true, force: true })
   })
 
   it('approveGate sets status open with approvedBy and notes', () => {
     const dir = tempProject()
     initGates(dir, 'test-project')
-    approveGate(dir, 'design', 'Pedro', 'architecture reviewed')
+    approveGate(dir, 'design', 'Pedro', 'architecture reviewed', validHumanApproval())
     const gate = getGate(dir, 'design')
     expect(gate.status).toBe('open')
     expect(gate.approvedBy).toBe('Pedro')
@@ -42,6 +66,14 @@ describe('human authorization gates', () => {
     rmSync(dir, { recursive: true, force: true })
   })
 
+  it('approveGate rejects direct approvals without a valid human token', () => {
+    const dir = tempProject()
+    initGates(dir, 'test-project')
+    expect(() => approveGate(dir, 'design', 'Pedro', 'architecture reviewed', humanApproval(undefined))).toThrow(/required/i)
+    expect(getGate(dir, 'design').status).toBe('pending')
+    rmSync(dir, { recursive: true, force: true })
+  })
+
   it('rejectGate sets status blocked with reason', () => {
     const dir = tempProject()
     initGates(dir, 'test-project')
@@ -55,7 +87,7 @@ describe('human authorization gates', () => {
   it('resetGate returns gate to pending', () => {
     const dir = tempProject()
     initGates(dir, 'test-project')
-    approveGate(dir, 'review', 'Pedro')
+    approveGate(dir, 'review', 'Pedro', undefined, validHumanApproval())
     resetGate(dir, 'review')
     expect(getGate(dir, 'review').status).toBe('pending')
     rmSync(dir, { recursive: true, force: true })
@@ -67,7 +99,7 @@ describe('human authorization gates', () => {
     expect(isGateOpen(dir, 'design')).toBe(false)
     rejectGate(dir, 'development', 'not ready')
     expect(isGateOpen(dir, 'development')).toBe(false)
-    approveGate(dir, 'publish', 'Pedro')
+    approveGate(dir, 'publish', 'Pedro', undefined, validHumanApproval())
     expect(isGateOpen(dir, 'publish')).toBe(true)
     rmSync(dir, { recursive: true, force: true })
   })
@@ -84,7 +116,7 @@ describe('human authorization gates', () => {
   it('getGateSummaryTable includes all 4 phases', () => {
     const dir = tempProject()
     initGates(dir, 'test-project')
-    approveGate(dir, 'design', 'Pedro', 'approved')
+    approveGate(dir, 'design', 'Pedro', 'approved', validHumanApproval())
     const data = loadGates(dir)
     const table = getGateSummaryTable(data)
     expect(table).toContain('design')
@@ -99,7 +131,7 @@ describe('human authorization gates', () => {
   it('gates persist across loadGates calls', () => {
     const dir = tempProject()
     initGates(dir, 'test-project')
-    approveGate(dir, 'design', 'Alice', 'spec done')
+    approveGate(dir, 'design', 'Alice', 'spec done', validHumanApproval())
     rejectGate(dir, 'publish', 'not ready for prod')
     const reloaded = loadGates(dir)
     expect(reloaded.gates.design.status).toBe('open')
diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts
index 4000484..f369426 100644
--- a/tests/hooks.test.ts
+++ b/tests/hooks.test.ts
@@ -30,6 +30,10 @@ describe('git hooks', () => {
     const preCommit = readFileSync(join(dir, '.git', 'hooks', 'pre-commit'), 'utf-8')
     expect(preCommit).toContain('SP-DevControl managed hook')
     expect(preCommit).toContain('COMMIT BLOCKED')
+    expect(preCommit).toContain('DB_UNREADABLE')
+    expect(preCommit).toContain('DevControl state could not be verified')
+    expect(preCommit).toContain('Development gate is not open')
+    expect(preCommit).not.toContain('--no-verify')
     rmSync(dir, { recursive: true, force: true })
   })
 
@@ -39,6 +43,14 @@ describe('git hooks', () => {
     const status = checkHooksInstalled(dir)
     expect(status.installed).toHaveLength(3)
     expect(status.missing).toHaveLength(0)
+
+    const prePush = readFileSync(join(dir, '.git', 'hooks', 'pre-push'), 'utf-8')
+    expect(prePush).toContain('DB_UNREADABLE')
+    expect(prePush).toContain('DevControl state could not be verified')
+    expect(prePush).toContain('Review gate is not open')
+    expect(prePush).toContain('exit 1')
+    expect(prePush).not.toContain('WARNING: review gate')
+    expect(prePush).not.toContain('--no-verify')
     rmSync(dir, { recursive: true, force: true })
   })