From 67c1c3f9a4d0ae408261829a4386e4955f777d26 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 7 Mar 2026 20:18:41 +0000 Subject: [PATCH] Add HMAC auth, user permissions, snake_case rename Each request is signed with HMAC-SHA256 over timestamp+body using a per-user secret loaded from a --secrets file (never env vars or git). Users have a canApprove list controlling who may approve queued actions. Queue entries track submitted_by for permission checks on approve/deny. Also renames all identifiers to snake_case throughout the codebase. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + client/auth.mjs | 13 +++++++++ client/conduit.mjs | 43 ++++++++++++++++++---------- client/index.mjs | 68 +++++++++++++++++++++++++++++++++++--------- secrets.example.json | 6 ++++ server/actions.mjs | 8 +++--- server/auth.mjs | 46 ++++++++++++++++++++++++++++++ server/helpers.mjs | 6 ++-- server/index.mjs | 49 ++++++++++++++++++++++++------- server/queue.mjs | 18 ++++++------ server/secrets.mjs | 23 +++++++++++++++ 11 files changed, 226 insertions(+), 55 deletions(-) create mode 100644 client/auth.mjs create mode 100644 secrets.example.json create mode 100644 server/auth.mjs create mode 100644 server/secrets.mjs diff --git a/.gitignore b/.gitignore index c2658d7..8c17d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +secrets.json diff --git a/client/auth.mjs b/client/auth.mjs new file mode 100644 index 0000000..6f0c83f --- /dev/null +++ b/client/auth.mjs @@ -0,0 +1,13 @@ +import { createHmac } from "crypto"; + +export function sign_request(secret, username, body_string) { + const timestamp = String(Date.now()); + const signature = createHmac("sha256", secret) + .update(timestamp + "." + body_string) + .digest("hex"); + return { + "X-Conduit-User": username, + "X-Conduit-Timestamp": timestamp, + "X-Conduit-Signature": signature, + }; +} diff --git a/client/conduit.mjs b/client/conduit.mjs index d743a6a..2c1e010 100644 --- a/client/conduit.mjs +++ b/client/conduit.mjs @@ -1,22 +1,35 @@ // Conduit client module — import this when using conduit programmatically. -// Designed for use by Claude or other tools that want to call conduit actions. +// Usage: const client = create_conduit_client("agent", secret); await client.call_action("list-actions"); + +import { sign_request } from "./auth.mjs"; const BASE_URL = process.env.CONDUIT_URL || "http://localhost:3015"; -export async function callAction(action, params = {}) { - const res = await fetch(`${BASE_URL}/action`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, ...params }), - }); - return res.json(); -} +export function create_conduit_client(username, secret) { + function auth_headers(body_string) { + return sign_request(secret, username, body_string); + } -export async function listActions() { - return callAction("list-actions"); -} + async function call_action(action, params = {}) { + const body_string = JSON.stringify({ action, ...params }); + const res = await fetch(`${BASE_URL}/action`, { + method: "POST", + headers: { "Content-Type": "application/json", ...auth_headers(body_string) }, + body: body_string, + }); + return res.json(); + } -export async function getQueue() { - const res = await fetch(`${BASE_URL}/queue`); - return res.json(); + async function list_actions() { + return call_action("list-actions"); + } + + async function get_queue() { + const res = await fetch(`${BASE_URL}/queue`, { + headers: auth_headers(""), + }); + return res.json(); + } + + return { call_action, list_actions, get_queue }; } diff --git a/client/index.mjs b/client/index.mjs index 57dfe17..45430f9 100644 --- a/client/index.mjs +++ b/client/index.mjs @@ -1,25 +1,44 @@ #!/usr/bin/env node // Conduit client — thin CLI wrapper for Claude to call the conduit server. // Usage: -// node client/index.mjs [key=value ...] -// node client/index.mjs list-actions -// node client/index.mjs edit-file filename=/workspace/foo.js +// node client/index.mjs --secrets /path/to/secrets.json --user agent [key=value ...] +// node client/index.mjs --secrets /path/to/secrets.json --user agent list-actions +// node client/index.mjs --secrets /path/to/secrets.json --user agent edit-file filename=/workspace/foo.mjs + +import { readFileSync } from "fs"; +import { sign_request } from "./auth.mjs"; const BASE_URL = process.env.CONDUIT_URL || "http://localhost:3015"; -async function callAction(action, params = {}) { +function get_arg(argv, flag) { + const i = argv.indexOf(flag); + return i !== -1 ? argv[i + 1] : null; +} + +async function call_action(action, params, auth_headers) { + const body_string = JSON.stringify({ action, ...params }); const res = await fetch(`${BASE_URL}/action`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, ...params }), + headers: { "Content-Type": "application/json", ...auth_headers(body_string) }, + body: body_string, }); - const body = await res.json(); return { status: res.status, body }; } -function parseArgs(argv) { - const [, , action, ...rest] = argv; +function parse_args(argv) { + // Skip --secrets and --user flags and their values + const filtered = []; + let i = 2; + while (i < argv.length) { + if (argv[i] === "--secrets" || argv[i] === "--user") { + i += 2; + } else { + filtered.push(argv[i]); + i++; + } + } + const [action, ...rest] = filtered; const params = {}; for (const arg of rest) { const eq = arg.indexOf("="); @@ -33,13 +52,36 @@ function parseArgs(argv) { } async function main() { - if (process.argv.length < 3) { - console.error("Usage: conduit [key=value ...]"); + const secrets_path = get_arg(process.argv, "--secrets"); + const username = get_arg(process.argv, "--user"); + + if (!secrets_path || !username) { + console.error("Usage: conduit --secrets --user [key=value ...]"); process.exit(1); } - const { action, params } = parseArgs(process.argv); - const { status, body } = await callAction(action, params); + let secrets; + try { + secrets = JSON.parse(readFileSync(secrets_path, "utf8")); + } catch (err) { + console.error(`Cannot read secrets file: ${err.message}`); + process.exit(1); + } + + const user_entry = secrets.users?.[username]; + if (!user_entry) { + console.error(`User '${username}' not found in secrets file`); + process.exit(1); + } + + const { action, params } = parse_args(process.argv); + if (!action) { + console.error("Usage: conduit --secrets --user [key=value ...]"); + process.exit(1); + } + + const auth_headers = (body_string) => sign_request(user_entry.secret, username, body_string); + const { status, body } = await call_action(action, params, auth_headers); console.log(JSON.stringify(body, null, 2)); process.exit(status >= 400 ? 1 : 0); diff --git a/secrets.example.json b/secrets.example.json new file mode 100644 index 0000000..bb31364 --- /dev/null +++ b/secrets.example.json @@ -0,0 +1,6 @@ +{ + "users": { + "agent": { "secret": "change-me-agent", "canApprove": [] }, + "user": { "secret": "change-me-user", "canApprove": ["agent"] } + } +} diff --git a/server/actions.mjs b/server/actions.mjs index 371979a..a91f00e 100644 --- a/server/actions.mjs +++ b/server/actions.mjs @@ -1,7 +1,7 @@ // Action registry — defines all available actions, their parameters, and policies. // policy: "auto-accept" | "auto-deny" | "queue" -import { resolvePath, exec } from "./helpers.mjs"; +import { resolve_path, exec } from "./helpers.mjs"; export const actions = { "list-actions": { @@ -23,7 +23,7 @@ export const actions = { params: [{ name: "filename", required: true, type: "path" }], policy: "auto-accept", handler: async ({ filename }) => { - const resolved = resolvePath(filename); + const resolved = resolve_path(filename); await exec("xdg-open", [resolved]); return { opened: resolved }; }, @@ -34,7 +34,7 @@ export const actions = { params: [{ name: "path", required: true, type: "path" }], policy: "auto-accept", handler: async ({ path }) => { - const resolved = resolvePath(path); + const resolved = resolve_path(path); await exec("xdg-open", [resolved]); return { opened: resolved }; }, @@ -55,7 +55,7 @@ export const actions = { params: [{ name: "path", required: false, type: "path" }], policy: "queue", handler: async ({ path }) => { - const resolved = path ? resolvePath(path) : process.env.HOME; + const resolved = path ? resolve_path(path) : process.env.HOME; await exec("xdg-open", [resolved]); return { opened: resolved }; }, diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..17be4bf --- /dev/null +++ b/server/auth.mjs @@ -0,0 +1,46 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +export function create_auth_middleware(users) { + return function hmac_auth(req, res, next) { + const username = req.headers["x-conduit-user"]; + const timestamp = req.headers["x-conduit-timestamp"]; + const signature = req.headers["x-conduit-signature"]; + + if (!username || !timestamp || !signature) { + return res.status(401).json({ error: "Missing auth headers" }); + } + + const ts = parseInt(timestamp, 10); + if (!Number.isFinite(ts) || Date.now() - ts > 30_000) { + return res.status(401).json({ error: "Request expired" }); + } + + const user = users[username]; + if (!user) { + return res.status(401).json({ error: "Unknown user" }); + } + + const raw_body = req.raw_body ?? ""; + const expected = createHmac("sha256", user.secret) + .update(timestamp + "." + raw_body) + .digest("hex"); + + const sig_buf = Buffer.from(signature, "hex"); + const expected_buf = Buffer.from(expected, "hex"); + + if (sig_buf.length !== expected_buf.length || !timingSafeEqual(sig_buf, expected_buf)) { + return res.status(401).json({ error: "Invalid signature" }); + } + + req.conduit_user = username; + next(); + }; +} + +export function check_can_approve(users, requester_name, submitter_name) { + const requester = users[requester_name]; + if (!requester) { + return false; + } + return requester.canApprove.includes(submitter_name); +} diff --git a/server/helpers.mjs b/server/helpers.mjs index 497013d..654e072 100644 --- a/server/helpers.mjs +++ b/server/helpers.mjs @@ -4,10 +4,10 @@ import path from "path"; const WORKSPACE_ROOT = process.env.CONDUIT_ROOT || "/workspace"; // Resolve a path param relative to WORKSPACE_ROOT, preventing traversal. -export function resolvePath(userPath) { - const resolved = path.resolve(WORKSPACE_ROOT, userPath.replace(/^\//, "")); +export function resolve_path(user_path) { + const resolved = path.resolve(WORKSPACE_ROOT, user_path.replace(/^\//, "")); if (!resolved.startsWith(WORKSPACE_ROOT)) { - throw new Error(`Path escapes workspace root: ${userPath}`); + throw new Error(`Path escapes workspace root: ${user_path}`); } return resolved; } diff --git a/server/index.mjs b/server/index.mjs index 08e7399..a6f0b5d 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,16 +1,37 @@ import express from "express"; import { actions } from "./actions.mjs"; -import { enqueue, getEntry, listPending, resolve } from "./queue.mjs"; +import { enqueue, get_entry, list_pending, resolve } from "./queue.mjs"; +import { load_secrets } from "./secrets.mjs"; +import { create_auth_middleware, check_can_approve } from "./auth.mjs"; const PORT = process.env.CONDUIT_PORT || 3015; -const app = express(); -app.use(express.json()); +function get_arg(argv, flag) { + const i = argv.indexOf(flag); + return i !== -1 ? argv[i + 1] : null; +} -// Validate that all required params are present. -function validateParams(actionDef, params) { +const secrets_path = get_arg(process.argv, "--secrets"); +let secrets; +try { + secrets = load_secrets(secrets_path); +} catch (err) { + console.error(`Fatal: ${err.message}`); + process.exit(1); +} +const { users } = secrets; + +const app = express(); +app.use(express.json({ + verify: (req, _res, buf) => { + req.raw_body = buf.toString("utf8"); + }, +})); +app.use(create_auth_middleware(users)); + +function validate_params(action_def, params) { const errors = []; - for (const p of actionDef.params) { + for (const p of action_def.params) { if (p.required && (params[p.name] === undefined || params[p.name] === null)) { errors.push(`Missing required param: ${p.name}`); } @@ -31,7 +52,7 @@ app.post("/action", async (req, res) => { return res.status(404).json({ error: `Unknown action: ${action}` }); } - const errors = validateParams(def, params); + const errors = validate_params(def, params); if (errors.length) { return res.status(400).json({ error: "Invalid params", details: errors }); } @@ -50,25 +71,28 @@ app.post("/action", async (req, res) => { } if (def.policy === "queue") { - const id = enqueue(action, params); + const id = enqueue(action, params, req.conduit_user); return res.status(202).json({ status: "queued", id }); } }); // GET /queue — list pending items app.get("/queue", (req, res) => { - res.json(listPending()); + res.json(list_pending()); }); // POST /queue/:id/approve — user approves a queued action app.post("/queue/:id/approve", async (req, res) => { - const entry = getEntry(req.params.id); + const entry = get_entry(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" }); } + if (!check_can_approve(users, req.conduit_user, entry.submitted_by)) { + return res.status(403).json({ error: "Not authorized to approve this entry" }); + } resolve(req.params.id, "approved"); @@ -83,13 +107,16 @@ app.post("/queue/:id/approve", async (req, res) => { // POST /queue/:id/deny — user denies a queued action app.post("/queue/:id/deny", (req, res) => { - const entry = getEntry(req.params.id); + const entry = get_entry(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" }); } + if (!check_can_approve(users, req.conduit_user, entry.submitted_by)) { + return res.status(403).json({ error: "Not authorized to deny this entry" }); + } resolve(req.params.id, "denied"); res.json({ status: "denied" }); diff --git a/server/queue.mjs b/server/queue.mjs index 28fed24..fa28e7e 100644 --- a/server/queue.mjs +++ b/server/queue.mjs @@ -1,32 +1,32 @@ -// Pending queue — holds actions awaiting user approval. - import { randomUUID } from "crypto"; const pending = new Map(); -export function enqueue(action, params) { +export function enqueue(action, params, submitted_by) { const id = randomUUID(); const entry = { id, action, params, + submitted_by, status: "pending", - createdAt: new Date().toISOString(), + created_at: new Date().toISOString(), }; pending.set(id, entry); console.log(`\n[QUEUE] New request #${id.slice(0, 8)}`); - console.log(` Action: ${action}`); - console.log(` Params: ${JSON.stringify(params)}`); + console.log(` Action: ${action}`); + console.log(` Params: ${JSON.stringify(params)}`); + console.log(` Submitted by: ${submitted_by}`); console.log(` Approve: POST /queue/${id}/approve`); console.log(` Deny: POST /queue/${id}/deny\n`); return id; } -export function getEntry(id) { +export function get_entry(id) { return pending.get(id) ?? null; } -export function listPending() { +export function list_pending() { return [...pending.values()].filter((e) => e.status === "pending"); } @@ -36,6 +36,6 @@ export function resolve(id, decision) { return null; } entry.status = decision; // "approved" | "denied" - entry.resolvedAt = new Date().toISOString(); + entry.resolved_at = new Date().toISOString(); return entry; } diff --git a/server/secrets.mjs b/server/secrets.mjs new file mode 100644 index 0000000..a4cc937 --- /dev/null +++ b/server/secrets.mjs @@ -0,0 +1,23 @@ +import { readFileSync } from "fs"; + +export function load_secrets(file_path) { + if (!file_path) { + throw new Error("--secrets is required"); + } + let raw; + try { + raw = readFileSync(file_path, "utf8"); + } catch (err) { + throw new Error(`Cannot read secrets file at ${file_path}: ${err.message}`); + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Secrets file is not valid JSON: ${err.message}`); + } + if (!parsed.users || typeof parsed.users !== "object") { + throw new Error("Secrets file must have a top-level 'users' object"); + } + return parsed; +}