feat: add claw approval MVP with privileged broker
Implement Postgres-backed claw approval flow and integrate gateway methods for create/list/get/approve/reject/execute/audit. Add a minimal systemd-run privileged broker with bearer auth, strict scope and exact-command validation, dangerous-shell blocking, atomic once-grant consumption, and execution audit updates.
This commit is contained in:
11
scripts/claw-broker/.env.example
Normal file
11
scripts/claw-broker/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
CLAW_BROKER_BIND=127.0.0.1
|
||||
CLAW_BROKER_PORT=8787
|
||||
CLAW_BROKER_TOKEN=change-me
|
||||
CLAW_BROKER_CMD_TIMEOUT_MS=120000
|
||||
CLAW_BROKER_MAX_SUMMARY_CHARS=2000
|
||||
|
||||
PGHOST=147.45.189.234
|
||||
PGPORT=5432
|
||||
PGDATABASE=default_db
|
||||
PGUSER=gen_user
|
||||
PGPASSWORD=change-me
|
||||
43
scripts/claw-broker/README.md
Normal file
43
scripts/claw-broker/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Claw Broker (MVP)
|
||||
|
||||
Minimal privileged broker for claw.approvals.execute.
|
||||
|
||||
## API
|
||||
|
||||
- POST /v1/execute
|
||||
- Bearer token via CLAW_BROKER_TOKEN
|
||||
|
||||
Request fields:
|
||||
|
||||
- executionId
|
||||
- approvalRequestId
|
||||
- approvalGrantId
|
||||
- exactCommand
|
||||
- targetHost
|
||||
- targetUser
|
||||
- requestedBy
|
||||
- channel
|
||||
- chatId
|
||||
- humanUserId
|
||||
- sessionId
|
||||
|
||||
Response fields:
|
||||
|
||||
- executionId
|
||||
- status
|
||||
- exitCode
|
||||
- stdoutSummary
|
||||
- stderrSummary
|
||||
- startedAt
|
||||
- finishedAt
|
||||
|
||||
## Validation
|
||||
|
||||
Broker re-checks in Postgres before execution:
|
||||
|
||||
- request/grant exist
|
||||
- status allows execution
|
||||
- once grant atomic consume
|
||||
- command exact match
|
||||
- scope match (targetHost, targetUser, channel, chatId, humanUserId, sessionId)
|
||||
- dangerous shell policy
|
||||
437
scripts/claw-broker/broker.mjs
Normal file
437
scripts/claw-broker/broker.mjs
Normal file
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const MAX_SUMMARY_CHARS = Number(process.env.CLAW_BROKER_MAX_SUMMARY_CHARS ?? "2000");
|
||||
const CMD_TIMEOUT_MS = Number(process.env.CLAW_BROKER_CMD_TIMEOUT_MS ?? "120000");
|
||||
const BIND_HOST = process.env.CLAW_BROKER_BIND ?? "127.0.0.1";
|
||||
const BIND_PORT = Number(process.env.CLAW_BROKER_PORT ?? "8787");
|
||||
const REQUIRED_TOKEN = (process.env.CLAW_BROKER_TOKEN ?? "").trim();
|
||||
|
||||
function env(name, fallback = undefined) {
|
||||
return process.env[`CLAW_${name}`] ?? process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = env(name, "");
|
||||
if (!value || !String(value).trim()) {
|
||||
throw new Error(`missing env: ${name} (or CLAW_${name})`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: requiredEnv("PGHOST"),
|
||||
port: Number(env("PGPORT", "5432")),
|
||||
user: requiredEnv("PGUSER"),
|
||||
password: requiredEnv("PGPASSWORD"),
|
||||
database: requiredEnv("PGDATABASE"),
|
||||
max: 10,
|
||||
});
|
||||
|
||||
if (!REQUIRED_TOKEN) {
|
||||
throw new Error("missing CLAW_BROKER_TOKEN");
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function normalizeCommand(input) {
|
||||
return String(input).trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function hasDangerousShellConstruct(command) {
|
||||
const source = String(command).toLowerCase();
|
||||
const checks = [
|
||||
/\bbash\s+-c\b/,
|
||||
/\bsh\s+-c\b/,
|
||||
/\bsudo\s+su\b/,
|
||||
/\bsudo\s+-i\b/,
|
||||
/&&/,
|
||||
/\|\|/,
|
||||
/;/,
|
||||
/\|/,
|
||||
/>/,
|
||||
/</,
|
||||
/\$\(/,
|
||||
/`/,
|
||||
/<<[-\w]*/,
|
||||
];
|
||||
return checks.some((r) => r.test(source));
|
||||
}
|
||||
|
||||
function summarize(text) {
|
||||
const value = String(text ?? "");
|
||||
return value.length <= MAX_SUMMARY_CHARS ? value : `${value.slice(0, MAX_SUMMARY_CHARS)}…`;
|
||||
}
|
||||
|
||||
async function insertAudit(client, args) {
|
||||
await client.query(
|
||||
`INSERT INTO claw_audit_events (
|
||||
event_type, request_id, grant_id, execution_id,
|
||||
actor_type, actor_id, target_host, target_user,
|
||||
command_snapshot, status, exit_code, stdout_summary, stderr_summary, metadata
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb
|
||||
)`,
|
||||
[
|
||||
args.eventType,
|
||||
args.requestId ?? null,
|
||||
args.grantId ?? null,
|
||||
args.executionId ?? null,
|
||||
args.actorType,
|
||||
args.actorId,
|
||||
args.targetHost ?? null,
|
||||
args.targetUser ?? null,
|
||||
args.commandSnapshot ?? null,
|
||||
args.status ?? null,
|
||||
args.exitCode ?? null,
|
||||
args.stdoutSummary ?? null,
|
||||
args.stderrSummary ?? null,
|
||||
JSON.stringify(args.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function requireString(body, key) {
|
||||
const value = body?.[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${key} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async function verifyAndMarkStarted(body) {
|
||||
const executionId = requireString(body, "executionId");
|
||||
const approvalRequestId = requireString(body, "approvalRequestId");
|
||||
const approvalGrantId = requireString(body, "approvalGrantId");
|
||||
const exactCommand = requireString(body, "exactCommand");
|
||||
const targetHost = requireString(body, "targetHost");
|
||||
const targetUser = requireString(body, "targetUser");
|
||||
const requestedBy = requireString(body, "requestedBy");
|
||||
const channel = requireString(body, "channel");
|
||||
const chatId = requireString(body, "chatId");
|
||||
const humanUserId = requireString(body, "humanUserId");
|
||||
const sessionId = requireString(body, "sessionId");
|
||||
|
||||
if (hasDangerousShellConstruct(exactCommand)) {
|
||||
throw new Error("dangerous shell policy violation");
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const reqRes = await client.query(
|
||||
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
|
||||
[approvalRequestId],
|
||||
);
|
||||
if (reqRes.rowCount === 0) {
|
||||
throw new Error("approval request not found");
|
||||
}
|
||||
const request = reqRes.rows[0];
|
||||
|
||||
if (!["approved_once", "approved_always"].includes(String(request.status))) {
|
||||
throw new Error(`request status does not allow execution: ${request.status}`);
|
||||
}
|
||||
|
||||
const grantRes = await client.query(
|
||||
`SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`,
|
||||
[approvalGrantId, approvalRequestId],
|
||||
);
|
||||
if (grantRes.rowCount === 0) {
|
||||
throw new Error("approval grant not found");
|
||||
}
|
||||
const grant = grantRes.rows[0];
|
||||
|
||||
const dbExact = String(request.exact_command);
|
||||
if (normalizeCommand(dbExact) !== normalizeCommand(exactCommand)) {
|
||||
throw new Error("exact command mismatch");
|
||||
}
|
||||
if (normalizeCommand(String(grant.exact_command)) !== normalizeCommand(exactCommand)) {
|
||||
throw new Error("grant command mismatch");
|
||||
}
|
||||
|
||||
const scopeChecks = [
|
||||
[String(request.target_host), targetHost, "targetHost"],
|
||||
[String(request.target_user), targetUser, "targetUser"],
|
||||
[String(request.channel), channel, "channel"],
|
||||
[String(request.chat_id), chatId, "chatId"],
|
||||
[String(request.human_user_id), humanUserId, "humanUserId"],
|
||||
[String(request.session_id), sessionId, "sessionId"],
|
||||
[String(grant.target_host), targetHost, "grant.targetHost"],
|
||||
[String(grant.target_user), targetUser, "grant.targetUser"],
|
||||
[String(grant.channel), channel, "grant.channel"],
|
||||
[String(grant.chat_id), chatId, "grant.chatId"],
|
||||
[String(grant.human_user_id), humanUserId, "grant.humanUserId"],
|
||||
[String(grant.session_id), sessionId, "grant.sessionId"],
|
||||
];
|
||||
for (const [db, incoming, label] of scopeChecks) {
|
||||
if (db !== incoming) {
|
||||
throw new Error(`scope mismatch: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDangerousShellConstruct(String(request.exact_command))) {
|
||||
throw new Error("dangerous shell policy violation (request)");
|
||||
}
|
||||
|
||||
if (String(grant.grant_type) === "once") {
|
||||
const consumeRes = await client.query(
|
||||
`UPDATE claw_approval_grants
|
||||
SET used_at = now()
|
||||
WHERE id = $1
|
||||
AND grant_type = 'once'
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING id`,
|
||||
[approvalGrantId],
|
||||
);
|
||||
if (consumeRes.rowCount === 0) {
|
||||
throw new Error("once grant expired/revoked/already used");
|
||||
}
|
||||
await insertAudit(client, {
|
||||
eventType: "grant_consumed",
|
||||
actorType: "broker",
|
||||
actorId: requestedBy,
|
||||
requestId: approvalRequestId,
|
||||
grantId: approvalGrantId,
|
||||
executionId,
|
||||
targetHost,
|
||||
targetUser,
|
||||
commandSnapshot: exactCommand,
|
||||
status: "grant_consumed",
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE claw_approval_requests SET execution_id = $2, updated_at = now() WHERE id = $1`,
|
||||
[approvalRequestId, executionId],
|
||||
);
|
||||
|
||||
await insertAudit(client, {
|
||||
eventType: "execution_started",
|
||||
actorType: "broker",
|
||||
actorId: requestedBy,
|
||||
requestId: approvalRequestId,
|
||||
grantId: approvalGrantId,
|
||||
executionId,
|
||||
targetHost,
|
||||
targetUser,
|
||||
commandSnapshot: exactCommand,
|
||||
status: "execution_started",
|
||||
});
|
||||
|
||||
await client.query("COMMIT");
|
||||
return {
|
||||
executionId,
|
||||
approvalRequestId,
|
||||
approvalGrantId,
|
||||
exactCommand,
|
||||
targetHost,
|
||||
targetUser,
|
||||
requestedBy,
|
||||
cwd: request.cwd ? String(request.cwd) : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(command, cwd) {
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("bash", ["-lc", command], {
|
||||
cwd: cwd || undefined,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, CMD_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
exitCode: timedOut ? 124 : Number(code ?? 1),
|
||||
stdout,
|
||||
stderr: timedOut ? `${stderr}\nCommand timed out.` : stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeExecution({
|
||||
executionId,
|
||||
approvalRequestId,
|
||||
approvalGrantId,
|
||||
exactCommand,
|
||||
targetHost,
|
||||
targetUser,
|
||||
requestedBy,
|
||||
ok,
|
||||
exitCode,
|
||||
stdoutSummary,
|
||||
stderrSummary,
|
||||
}) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const finalStatus = ok ? "executed" : "execution_failed";
|
||||
const lastError = ok ? null : stderrSummary;
|
||||
await client.query(
|
||||
`UPDATE claw_approval_requests
|
||||
SET status = $2::claw_approval_status,
|
||||
executed_at = now(),
|
||||
updated_at = now(),
|
||||
last_error = $3
|
||||
WHERE id = $1`,
|
||||
[approvalRequestId, finalStatus, lastError],
|
||||
);
|
||||
await insertAudit(client, {
|
||||
eventType: ok ? "execution_succeeded" : "execution_failed",
|
||||
actorType: "broker",
|
||||
actorId: requestedBy,
|
||||
requestId: approvalRequestId,
|
||||
grantId: approvalGrantId,
|
||||
executionId,
|
||||
targetHost,
|
||||
targetUser,
|
||||
commandSnapshot: exactCommand,
|
||||
status: ok ? "executed" : "execution_failed",
|
||||
exitCode,
|
||||
stdoutSummary,
|
||||
stderrSummary,
|
||||
});
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecute(body) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const validated = await verifyAndMarkStarted(body);
|
||||
const run = await runCommand(validated.exactCommand, validated.cwd);
|
||||
const ok = run.exitCode === 0;
|
||||
const stdoutSummary = summarize(run.stdout);
|
||||
const stderrSummary = summarize(run.stderr);
|
||||
|
||||
await finalizeExecution({
|
||||
executionId: validated.executionId,
|
||||
approvalRequestId: validated.approvalRequestId,
|
||||
approvalGrantId: validated.approvalGrantId,
|
||||
exactCommand: validated.exactCommand,
|
||||
targetHost: validated.targetHost,
|
||||
targetUser: validated.targetUser,
|
||||
requestedBy: validated.requestedBy,
|
||||
ok,
|
||||
exitCode: run.exitCode,
|
||||
stdoutSummary,
|
||||
stderrSummary,
|
||||
});
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
return {
|
||||
ok,
|
||||
executionId: validated.executionId,
|
||||
status: ok ? "executed" : "execution_failed",
|
||||
exitCode: run.exitCode,
|
||||
stdoutSummary,
|
||||
stderrSummary,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function getBearerToken(req) {
|
||||
const raw = String(req.headers.authorization ?? "");
|
||||
if (!raw.toLowerCase().startsWith("bearer ")) {
|
||||
return "";
|
||||
}
|
||||
return raw.slice(7).trim();
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/healthz") {
|
||||
json(res, 200, { ok: true, service: "claw-broker" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || url.pathname !== "/v1/execute") {
|
||||
json(res, 404, { ok: false, error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token || token !== REQUIRED_TOKEN) {
|
||||
json(res, 401, { ok: false, error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk.toString("utf8");
|
||||
if (raw.length > 1_000_000) {
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", async () => {
|
||||
const fallbackExecutionId = randomUUID();
|
||||
try {
|
||||
const body = raw.length ? JSON.parse(raw) : {};
|
||||
if (!body.executionId) {
|
||||
body.executionId = fallbackExecutionId;
|
||||
}
|
||||
const result = await handleExecute(body);
|
||||
json(res, 200, result);
|
||||
} catch (err) {
|
||||
console.error("[claw-broker] execute error:", err);
|
||||
const nowIso = new Date().toISOString();
|
||||
json(res, 400, {
|
||||
ok: false,
|
||||
executionId: fallbackExecutionId,
|
||||
status: "execution_failed",
|
||||
exitCode: 1,
|
||||
stdoutSummary: "",
|
||||
stderrSummary: String(err),
|
||||
startedAt: nowIso,
|
||||
finishedAt: nowIso,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(BIND_PORT, BIND_HOST, () => {
|
||||
process.stdout.write(`claw-broker listening on http://${BIND_HOST}:${BIND_PORT}\n`);
|
||||
});
|
||||
20
scripts/claw-broker/claw-broker.service
Normal file
20
scripts/claw-broker/claw-broker.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=OpenClaw Privileged Broker (MVP)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/home/negodiy/claw-broker
|
||||
EnvironmentFile=/home/negodiy/claw-broker/.env
|
||||
ExecStart=/usr/bin/node /home/negodiy/claw-broker/broker.mjs
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
scripts/claw-broker/package.json
Normal file
13
scripts/claw-broker/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "claw-broker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "broker.mjs",
|
||||
"scripts": {
|
||||
"start": "node broker.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.20.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user