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 <noreply@anthropic.com>
This commit is contained in:
13
client/auth.mjs
Normal file
13
client/auth.mjs
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 <action> [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 <action> [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 <action> [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 <path> --user <name> <action> [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 <path> --user <name> <action> [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);
|
||||
|
||||
Reference in New Issue
Block a user