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
+
+
+
+
+
+ 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 })
})