import express from "express"; import { spawnSync } from "child_process"; import path from "path"; import { actions } from "./actions.js"; import { enqueue, getEntry, listPending, resolve } from "./queue.js"; const PORT = process.env.CONDUIT_PORT || 3015; const WORKSPACE_ROOT = process.env.CONDUIT_ROOT || "/workspace"; const app = express(); app.use(express.json()); // Resolve a path param relative to WORKSPACE_ROOT, preventing traversal. function resolvePath(userPath) { const resolved = path.resolve(WORKSPACE_ROOT, userPath.replace(/^\//, "")); if (!resolved.startsWith(WORKSPACE_ROOT)) { throw new Error(`Path escapes workspace root: ${userPath}`); } return resolved; } // Execute a binary with an argument list — no shell interpolation. function exec(bin, args = []) { return new Promise((resolve, reject) => { const result = spawnSync(bin, args, { stdio: "inherit" }); if (result.error) { reject(result.error); } else { resolve(result.status); } }); } const helpers = { resolvePath, exec }; // Validate that all required params are present. function validateParams(actionDef, params) { const errors = []; for (const p of actionDef.params) { if (p.required && (params[p.name] === undefined || params[p.name] === null)) { errors.push(`Missing required param: ${p.name}`); } } return errors; } // POST /action — main entry point app.post("/action", async (req, res) => { const { action, ...params } = req.body ?? {}; if (!action) { return res.status(400).json({ error: "Missing 'action' field" }); } const def = actions[action]; if (!def) { return res.status(404).json({ error: `Unknown action: ${action}` }); } const errors = validateParams(def, params); if (errors.length) { return res.status(400).json({ error: "Invalid params", details: errors }); } if (def.policy === "auto-deny") { return res.status(403).json({ status: "denied", reason: "Policy: auto-deny" }); } if (def.policy === "auto-accept") { try { const result = await def.handler(params, helpers); return res.json({ status: "accepted", result }); } catch (err) { return res.status(500).json({ status: "error", error: err.message }); } } if (def.policy === "queue") { const id = enqueue(action, params); return res.status(202).json({ status: "queued", id }); } }); // GET /queue — list pending items app.get("/queue", (req, res) => { res.json(listPending()); }); // POST /queue/:id/approve — user approves a queued action app.post("/queue/:id/approve", async (req, res) => { const entry = getEntry(req.params.id); if (!entry) { return res.status(404).json({ error: "Not found" }); } if (entry.status !== "pending") { return res.status(409).json({ error: "Already resolved" }); } resolve(req.params.id, "approved"); const def = actions[entry.action]; try { const result = await def.handler(entry.params, helpers); res.json({ status: "approved", result }); } catch (err) { res.status(500).json({ status: "error", error: err.message }); } }); // POST /queue/:id/deny — user denies a queued action app.post("/queue/:id/deny", (req, res) => { const entry = getEntry(req.params.id); if (!entry) { return res.status(404).json({ error: "Not found" }); } if (entry.status !== "pending") { return res.status(409).json({ error: "Already resolved" }); } resolve(req.params.id, "denied"); res.json({ status: "denied" }); }); app.listen(PORT, () => { console.log(`claude-code-conduit server running on port ${PORT}`); console.log(`Workspace root: ${WORKSPACE_ROOT}`); });