Author SHA1 Message Date
claude 90ff337f7c fix: satisfy clippy -D warnings so CI can pass
CI runs `cargo clippy -- -D warnings`, which already failed on main with
four errors predating this branch:

  duplicated attribute            src/models.rs, src/config.rs
  literal with an empty format    src/cli_main.rs (x2)

Both structs carried #[allow(dead_code)] twice; dropped the duplicate.
The println! calls moved the literal into the format string as clippy
suggested. No behaviour change -- output is byte-identical.

Kept separate from the feature commit so it can be dropped or landed on
its own.
2026-08-13 03:07:34 -07:00
claude bfb10b9ab2 feat: token-authenticated approval API + structured webhook payload
Adds POST /api/actions/:id/approve and /reject so a notifier (desktop
popup, chat bot, phone) can resolve an action without driving the web UI
or holding a login session.

Auth is the per-action approve_token already minted at submit time: a
UUIDv4 scoped to exactly one action and delivered only in the approval
notification. The agent API key is deliberately NOT accepted on these
routes -- the agent submits actions, so letting it approve them would
defeat the human-in-the-loop guarantee. The token may be supplied in the
JSON body or as ?token=, matching the existing approve link.

Both endpoints return 401 for an unknown id/token and 409 (with the
current status) if the action is no longer pending, so a notifier can
tell "someone else already handled this" from "this failed".

The notification webhook gains a structured "action" object alongside the
existing "text" field, which is unchanged so current consumers keep
working. Previously the id, command and approve URL were only available
by regexing the human-readable blob.

Also collapses three copies of the actions SELECT and its row mapping
onto ACTION_COLUMNS/row_to_action, and extracts the execute and
reject-chain logic into helpers now shared by the web and API handlers.
Those helpers no longer .unwrap() DB errors while holding the connection
mutex, which would poison it for the life of the process.
2026-08-13 03:03:19 -07:00
4 changed files with 258 additions and 113 deletions
+2 -2
View File
@@ -65,7 +65,7 @@ fn main() {
if users.is_empty() {
println!("No users.");
} else {
println!("{:<20} {}", "Username", "Created");
println!("{:<20} Created", "Username");
println!("{}", "-".repeat(40));
for (u, c) in users {
println!("{:<20} {}", u, c);
@@ -115,7 +115,7 @@ fn main() {
if keys.is_empty() {
println!("No API keys.");
} else {
println!("{:<20} {}", "Label", "Created");
println!("{:<20} Created", "Label");
println!("{}", "-".repeat(40));
for (l, c) in keys {
println!("{:<20} {}", l, c);
-1
View File
@@ -1,7 +1,6 @@
use serde::Deserialize;
use std::fs;
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct Config {
+8 -1
View File
@@ -27,7 +27,6 @@ pub struct SubmitAction {
pub run_as: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct ActionResult {
@@ -36,6 +35,14 @@ pub struct ActionResult {
pub result: Option<String>,
}
/// Body for the approval API. The `approve_token` is the per-action secret
/// minted at submit time and delivered in the approval notification.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ApproveRequest {
pub approve_token: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LoginForm {
+248 -109
View File
@@ -28,6 +28,11 @@ pub fn router(state: AppState) -> Router {
// Agent API
.route("/api/actions", post(submit_action))
.route("/api/actions/:id/result", get(get_result))
// Approval API. Authenticated by the per-action approve_token, NOT by
// the agent API key -- an agent must not be able to approve its own
// actions, or the human-in-the-loop guarantee is worthless.
.route("/api/actions/:id/approve", post(api_approve_action))
.route("/api/actions/:id/reject", post(api_reject_action))
.with_state(state)
}
@@ -99,6 +104,113 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
})
}
/// Column list for `actions`, in the order `row_to_action` expects.
const ACTION_COLUMNS: &str =
"id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at";
fn row_to_action(row: &rusqlite::Row) -> rusqlite::Result<Action> {
Ok(Action {
id: row.get(0)?,
chain_id: row.get(1)?,
sequence: row.get(2)?,
description: row.get(3)?,
command: row.get(4)?,
run_as: row.get(5)?,
status: row.get(6)?,
result: row.get(7)?,
approve_token: row.get(8)?,
submitted_at: row.get(9)?,
resolved_at: row.get(10)?,
})
}
fn fetch_action(state: &AppState, id: &str) -> Option<Action> {
let db = state.db.lock().ok()?;
db.query_row(
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1"),
[id],
row_to_action,
)
.ok()
}
/// Look up an action by id *and* its approve_token. Both must match, so a
/// bare action id is not enough to act on it.
fn fetch_action_with_token(state: &AppState, id: &str, token: &str) -> Option<Action> {
let db = state.db.lock().ok()?;
db.query_row(
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1 AND approve_token = ?2"),
[id, token],
row_to_action,
)
.ok()
}
/// Execute an approved action's command and record the outcome.
/// Returns the resulting status ("executed" or "failed") and the captured output.
fn execute_action(state: &AppState, action: &Action) -> (String, String) {
let output = match action.run_as.as_deref() {
Some("root") => Command::new("sudo")
.args(["-n", "sh", "-c", &action.command])
.output(),
Some(user) => Command::new("sudo")
.args(["-n", "-u", user, "sh", "-c", &action.command])
.output(),
None => Command::new("sh").args(["-c", &action.command]).output(),
};
let (status, result) = match output {
Ok(out) => {
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\nSTDERR:\n{}", stdout, stderr)
};
if out.status.success() {
("executed", combined)
} else {
("failed", combined)
}
}
Err(e) => ("failed", e.to_string()),
};
if let Ok(db) = state.db.lock() {
let _ = db.execute(
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
[status, &result, &action.id],
);
}
(status.to_string(), result)
}
/// Mark an action rejected, along with every still-pending action in its chain.
fn reject_action_and_chain(state: &AppState, id: &str) {
let Ok(db) = state.db.lock() else { return };
let chain_id: Option<String> = db
.query_row("SELECT chain_id FROM actions WHERE id = ?1", [id], |row| {
row.get(0)
})
.ok()
.flatten();
let _ = db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
[id],
);
if let Some(cid) = chain_id {
let _ = db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
[&cid],
);
}
}
// ── Web UI ────────────────────────────────────────────────────────
async fn login_page() -> Html<String> {
@@ -190,28 +302,15 @@ async fn dashboard(State(state): State<AppState>, jar: CookieJar) -> impl IntoRe
let actions: Vec<Action> = {
let db = state.db.lock().unwrap();
let mut stmt = db.prepare(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at
FROM actions ORDER BY submitted_at DESC LIMIT 50"
).unwrap();
stmt.query_map([], |row| {
Ok(Action {
id: row.get(0)?,
chain_id: row.get(1)?,
sequence: row.get(2)?,
description: row.get(3)?,
command: row.get(4)?,
run_as: row.get(5)?,
status: row.get(6)?,
result: row.get(7)?,
approve_token: row.get(8)?,
submitted_at: row.get(9)?,
resolved_at: row.get(10)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
let mut stmt = db
.prepare(&format!(
"SELECT {ACTION_COLUMNS} FROM actions ORDER BY submitted_at DESC LIMIT 50"
))
.unwrap();
stmt.query_map([], row_to_action)
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
let rows: String = actions
@@ -308,20 +407,7 @@ async fn action_detail(
) -> impl IntoResponse {
// Allow approve via token link (no login required)
if let Some(token) = q.token {
let action: Option<Action> = {
let db = state.db.lock().unwrap();
db.query_row(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1 AND approve_token = ?2",
[&id, &token],
|row| Ok(Action {
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
}),
).ok()
};
if let Some(a) = action {
if let Some(a) = fetch_action_with_token(&state, &id, &token) {
if a.status == "pending" {
let content = format!(
r#"
@@ -364,56 +450,9 @@ async fn approve_action(
return Redirect::to("/login").into_response();
}
let action: Option<Action> = {
let db = state.db.lock().unwrap();
db.query_row(
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1",
[&id],
|row| Ok(Action {
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
}),
).ok()
};
if let Some(a) = action {
if let Some(a) = fetch_action(&state, &id) {
if a.status == "pending" {
// Build command with run_as support
let output = match a.run_as.as_deref() {
Some("root") => Command::new("sudo")
.args(["-n", "sh", "-c", &a.command])
.output(),
Some(user) => Command::new("sudo")
.args(["-n", "-u", user, "sh", "-c", &a.command])
.output(),
None => Command::new("sh").args(["-c", &a.command]).output(),
};
let (status, result) = match output {
Ok(out) => {
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\nSTDERR:\n{}", stdout, stderr)
};
if out.status.success() {
("executed", combined)
} else {
("failed", combined)
}
}
Err(e) => ("failed", e.to_string()),
};
let db = state.db.lock().unwrap();
db.execute(
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
[status, &result, &a.id],
).unwrap();
execute_action(&state, &a);
}
}
@@ -429,29 +468,7 @@ async fn reject_action(
return Redirect::to("/login").into_response();
}
// Reject this action and all pending actions in same chain
let chain_id: Option<String> = {
let db = state.db.lock().unwrap();
db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| {
row.get(0)
})
.ok()
.flatten()
};
let db = state.db.lock().unwrap();
db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
[&id],
)
.unwrap();
if let Some(cid) = chain_id {
db.execute(
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
[&cid],
).unwrap();
}
reject_action_and_chain(&state, &id);
Redirect::to("/").into_response()
}
@@ -495,12 +512,29 @@ async fn submit_action(
let description = body.description.clone();
let command = body.command.clone();
let url = approve_url.clone();
let action_id = id.clone();
let run_as = body.run_as.clone();
let approve_token_out = approve_token.clone();
let api_base = state.config.base_url.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
let mut req = client.post(&webhook)
.json(&serde_json::json!({
// `text` is kept verbatim so existing consumers keep working.
"text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url),
"mode": "now"
"mode": "now",
// Structured form, so a notifier can render buttons and call
// the approval API without scraping the text blob above.
"action": {
"id": action_id,
"description": description,
"command": command,
"run_as": run_as,
"approve_url": url,
"approve_token": approve_token_out,
"api_approve": format!("{}/api/actions/{}/approve", api_base, action_id),
"api_reject": format!("{}/api/actions/{}/reject", api_base, action_id),
}
}));
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {}", t));
@@ -520,6 +554,111 @@ async fn submit_action(
.into_response()
}
// ── Approval API ──────────────────────────────────────────────────
//
// These endpoints exist so a notifier (desktop popup, chat bot, phone) can
// resolve an action without driving the web UI or holding a login session.
//
// Auth is the per-action `approve_token`: a UUIDv4 minted at submit time,
// scoped to exactly one action, and delivered only in the approval
// notification. The agent API key is deliberately NOT accepted here -- the
// agent submits actions, so letting it approve them too would defeat the
// entire point of the service.
/// Resolve `token` from either the JSON body or the `?token=` query string,
/// so callers can use whichever is convenient. Body wins if both are present.
fn approve_token_from(body: &Option<Json<ApproveRequest>>, q: &ApproveQuery) -> Option<String> {
body.as_ref()
.map(|b| b.approve_token.clone())
.or_else(|| q.token.clone())
}
async fn api_approve_action(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> impl IntoResponse {
let Some(token) = approve_token_from(&body, &q) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "missing approve_token"})),
)
.into_response();
};
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
)
.into_response();
};
if action.status != "pending" {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "action is no longer pending",
"id": action.id,
"status": action.status,
})),
)
.into_response();
}
let (status, result) = execute_action(&state, &action);
(
StatusCode::OK,
Json(serde_json::json!({"id": action.id, "status": status, "result": result})),
)
.into_response()
}
async fn api_reject_action(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<ApproveQuery>,
body: Option<Json<ApproveRequest>>,
) -> impl IntoResponse {
let Some(token) = approve_token_from(&body, &q) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "missing approve_token"})),
)
.into_response();
};
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
)
.into_response();
};
if action.status != "pending" {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "action is no longer pending",
"id": action.id,
"status": action.status,
})),
)
.into_response();
}
reject_action_and_chain(&state, &action.id);
(
StatusCode::OK,
Json(serde_json::json!({"id": action.id, "status": "rejected"})),
)
.into_response()
}
async fn get_result(
State(state): State<AppState>,
headers: HeaderMap,