From 441ea454fcdbfe8050aa4124739923ceec425e86 Mon Sep 17 00:00:00 2001
From: Gaya KACI <163902005+kacigaya@users.noreply.github.com>
Date: Tue, 3 Nov 2026 00:26:11 +0000
Subject: [PATCH 1/2] feat: add review controls, agent usage, security digest
- Retry failed or cancelled reviews and cancel queued or running ones
from the dashboard. Admin only. A per-PR lock and the worker lease
keep retries from racing pushes or overlapping a stopping run.
- Explain failed and cancelled reviews in plain words. Keep the raw
error under Error details.
- Record agent time and the token usage that each CLI reports. Codex
runs with --json. Show harness availability from the model list check.
- Add an opt-in weekly security digest. It posts new, fixed, and
overdue Dependabot alerts to an HTTPS chat webhook. Delivery connects
only to public addresses and never exposes the URL.
- Store findings before posting and mark them published with the
GitHub review ID in one transaction.
---
Cargo.lock | 2 +
README.md | 5 +-
crates/server/Cargo.toml | 2 +
crates/server/src/agents.rs | 207 ++++-
crates/server/src/api.rs | 134 ++-
crates/server/src/db.rs | 376 +++++++-
crates/server/src/digest.rs | 863 ++++++++++++++++++
crates/server/src/github.rs | 53 +-
crates/server/src/main.rs | 1 +
crates/server/src/models.rs | 20 +-
crates/server/src/review.rs | 53 +-
crates/server/src/tests.rs | 419 ++++++++-
crates/server/src/vulnerabilities.rs | 2 +-
crates/server/src/webhook.rs | 7 +-
crates/server/src/worker.rs | 31 +-
docs/api.md | 95 +-
docs/configuration.md | 39 +
docs/github-app.md | 2 +
docs/security.md | 17 +-
migrations/0004_usage_health_digest.sql | 24 +
.../(dashboard)/installations/[id]/actions.ts | 9 +
.../(dashboard)/installations/[id]/page.tsx | 24 +-
.../installations/[id]/settings/page.tsx | 3 +-
web/app/(dashboard)/reviews/[id]/actions.ts | 32 +
web/app/(dashboard)/reviews/[id]/page.tsx | 113 ++-
web/components/agent-health.tsx | 134 +++
web/components/review-controls.tsx | 51 ++
web/components/settings-form.tsx | 78 ++
web/lib/api-client.ts | 10 +-
web/lib/api.ts | 2 +-
web/lib/docs-content.tsx | 30 +-
web/lib/failure.test.ts | 57 ++
web/lib/failure.ts | 164 ++++
web/lib/format.test.ts | 15 +-
web/lib/format.ts | 9 +
web/lib/types.ts | 34 +
web/scripts/smoke.ts | 96 +-
37 files changed, 3053 insertions(+), 160 deletions(-)
create mode 100644 crates/server/src/digest.rs
create mode 100644 migrations/0004_usage_health_digest.sql
create mode 100644 web/app/(dashboard)/reviews/[id]/actions.ts
create mode 100644 web/components/agent-health.tsx
create mode 100644 web/components/review-controls.tsx
create mode 100644 web/lib/failure.test.ts
create mode 100644 web/lib/failure.ts
diff --git a/Cargo.lock b/Cargo.lock
index c9d7731..0fe7234 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -427,11 +427,13 @@ dependencies = [
"sqlx",
"thiserror",
"tokio",
+ "tokio-rustls",
"tokio-util",
"tower",
"tracing",
"tracing-subscriber",
"uuid",
+ "webpki-roots",
]
[[package]]
diff --git a/README.md b/README.md
index edacba9..868fade 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,10 @@ A new push cancels the old review. A commit that already has a review is not rev
Sign in with GitHub. Choose an installation to set its agent and model and to
follow its review history. Open a review to read each finding and its
-suggested fix. The screenshots use demo data.
+suggested fix. Admins can retry failed reviews and cancel queued ones. The
+overview shows agent health, run time, and token usage. An opt-in weekly
+security digest posts Dependabot alert changes to a chat webhook. The
+screenshots use demo data.
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index 9510b6d..4cdfff1 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -26,10 +26,12 @@ sha2.workspace = true
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate", "uuid", "chrono", "json"] }
thiserror.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "signal", "time", "fs", "io-util", "sync"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "serde"] }
+webpki-roots = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
diff --git a/crates/server/src/agents.rs b/crates/server/src/agents.rs
index cacbf15..a339691 100644
--- a/crates/server/src/agents.rs
+++ b/crates/server/src/agents.rs
@@ -165,6 +165,8 @@ pub fn command(
"read-only",
"--skip-git-repo-check",
"--ephemeral",
+ // JSONL events on stdout carry token usage and errors.
+ "--json",
"--ignore-user-config",
"--ignore-rules",
"--color",
@@ -239,9 +241,13 @@ pub fn parse_output(agent: Agent, stdout: &str, result_file: Option<&str>) -> Re
}
}
Agent::Codex => {
- let text = result_file
- .filter(|t| !t.trim().is_empty())
- .ok_or_else(|| anyhow!("codex wrote no final message"))?;
+ let text =
+ result_file
+ .filter(|t| !t.trim().is_empty())
+ .ok_or_else(|| match codex_error(stdout) {
+ Some(message) => anyhow!("codex error: {}", short(&message)),
+ None => anyhow!("codex wrote no final message"),
+ })?;
extract_json_object(text).context("codex final message has no JSON object")?
}
Agent::OpenCode => {
@@ -273,6 +279,138 @@ pub fn parse_output(agent: Agent, stdout: &str, result_file: Option<&str>) -> Re
serde_json::from_value(value).context("agent JSON does not match the review schema")
}
+/// The last error message in Codex JSONL events.
+fn codex_error(stdout: &str) -> Option {
+ stdout
+ .lines()
+ .filter_map(|line| serde_json::from_str::(line).ok())
+ .filter_map(|event| match event["type"].as_str()? {
+ "error" => event["message"].as_str().map(str::to_owned),
+ "turn.failed" => event.pointer("/error/message")?.as_str().map(str::to_owned),
+ _ => None,
+ })
+ .next_back()
+}
+
+/// Token usage that an agent CLI reports. Input counts include cached input.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Usage {
+ pub input_tokens: Option,
+ pub cached_input_tokens: Option,
+ pub output_tokens: Option,
+ /// The model that used the most tokens, when the CLI names it.
+ pub model: Option,
+}
+
+impl Usage {
+ fn sum_opt(a: Option, b: Option) -> Option {
+ match (a, b) {
+ (None, None) => None,
+ (a, b) => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
+ }
+ }
+
+ /// Adds another run, such as a fallback agent run.
+ pub fn add(&mut self, other: Usage) {
+ self.input_tokens = Self::sum_opt(self.input_tokens, other.input_tokens);
+ self.cached_input_tokens =
+ Self::sum_opt(self.cached_input_tokens, other.cached_input_tokens);
+ self.output_tokens = Self::sum_opt(self.output_tokens, other.output_tokens);
+ if other.model.is_some() {
+ self.model = other.model;
+ }
+ }
+}
+
+fn tokens(value: &Value, pointer: &str) -> Option {
+ value.pointer(pointer)?.as_i64().filter(|n| *n >= 0)
+}
+
+/// Reads token usage from agent stdout. Fields the CLI does not report stay
+/// `None`. Formats:
+/// - Claude: `modelUsage` per model, or `usage`, in the result envelope.
+/// - Codex: `usage` of each `turn.completed` event. Input includes cached input.
+/// - OpenCode: `part.tokens` of each `step_finish` event.
+pub fn parse_usage(agent: Agent, stdout: &str) -> Usage {
+ let mut usage = Usage::default();
+ match agent {
+ Agent::Claude => {
+ let Ok(envelope) = serde_json::from_str::(stdout.trim()) else {
+ return usage;
+ };
+ if let Some(models) = envelope["modelUsage"].as_object().filter(|m| !m.is_empty()) {
+ let mut largest = 0;
+ for (model, m) in models {
+ let cached = tokens(m, "/cacheReadInputTokens");
+ let input = [
+ tokens(m, "/inputTokens"),
+ tokens(m, "/cacheCreationInputTokens"),
+ ]
+ .into_iter()
+ .fold(cached, Usage::sum_opt);
+ let output = tokens(m, "/outputTokens");
+ let total = input.unwrap_or(0) + output.unwrap_or(0);
+ usage.add(Usage {
+ input_tokens: input,
+ cached_input_tokens: cached,
+ output_tokens: output,
+ model: None,
+ });
+ if total >= largest && valid_model(model) {
+ largest = total;
+ usage.model = Some(model.clone());
+ }
+ }
+ } else if envelope["usage"].is_object() {
+ let cached = tokens(&envelope, "/usage/cache_read_input_tokens");
+ usage.input_tokens = [
+ tokens(&envelope, "/usage/input_tokens"),
+ tokens(&envelope, "/usage/cache_creation_input_tokens"),
+ ]
+ .into_iter()
+ .fold(cached, Usage::sum_opt);
+ usage.cached_input_tokens = cached;
+ usage.output_tokens = tokens(&envelope, "/usage/output_tokens");
+ }
+ }
+ Agent::Codex | Agent::OpenCode => {
+ for event in stdout
+ .lines()
+ .filter_map(|line| serde_json::from_str::(line).ok())
+ {
+ let run = match (agent, event["type"].as_str()) {
+ (Agent::Codex, Some("turn.completed")) => Usage {
+ input_tokens: tokens(&event, "/usage/input_tokens"),
+ cached_input_tokens: tokens(&event, "/usage/cached_input_tokens"),
+ output_tokens: tokens(&event, "/usage/output_tokens"),
+ model: None,
+ },
+ (Agent::OpenCode, Some("step_finish")) => {
+ let cached = tokens(&event, "/part/tokens/cache/read");
+ Usage {
+ input_tokens: [
+ tokens(&event, "/part/tokens/input"),
+ tokens(&event, "/part/tokens/cache/write"),
+ ]
+ .into_iter()
+ .fold(cached, Usage::sum_opt),
+ cached_input_tokens: cached,
+ output_tokens: Usage::sum_opt(
+ tokens(&event, "/part/tokens/output"),
+ tokens(&event, "/part/tokens/reasoning"),
+ ),
+ model: None,
+ }
+ }
+ _ => continue,
+ };
+ usage.add(run);
+ }
+ }
+ }
+ usage
+}
+
fn short(text: &str) -> String {
text.chars().take(300).collect()
}
@@ -306,6 +444,7 @@ mod tests {
let (codex, _) = command(Agent::Codex, None, &paths(), false);
assert!(codex.windows(2).any(|w| w == ["--sandbox", "read-only"]));
+ assert!(codex.iter().any(|arg| arg == "--json"));
assert_eq!(codex.last().unwrap(), "-");
let (opencode, env) = command(Agent::OpenCode, None, &paths(), false);
@@ -387,6 +526,68 @@ mod tests {
assert!(parse_output(Agent::Codex, "", None).is_err());
}
+ #[test]
+ fn usage_from_each_cli() {
+ // Shapes from Claude Code 2.1.310 and Codex 0.166.0 output on 2026-11-02.
+ let claude = r#"{"type":"result","usage":{"input_tokens":2,"cache_creation_input_tokens":1250,"cache_read_input_tokens":1670,"output_tokens":4},
+ "modelUsage":{"claude-sonnet-5-5":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":1670,"cacheCreationInputTokens":1250},
+ "claude-haiku-5-5":{"inputTokens":10,"outputTokens":1}}}"#;
+ assert_eq!(
+ parse_usage(Agent::Claude, claude),
+ Usage {
+ input_tokens: Some(2932),
+ cached_input_tokens: Some(1670),
+ output_tokens: Some(5),
+ model: Some("claude-sonnet-5-5".into()),
+ }
+ );
+ let envelope_only =
+ r#"{"usage":{"input_tokens":5,"cache_read_input_tokens":3,"output_tokens":7}}"#;
+ assert_eq!(
+ parse_usage(Agent::Claude, envelope_only),
+ Usage {
+ input_tokens: Some(8),
+ cached_input_tokens: Some(3),
+ output_tokens: Some(7),
+ model: None,
+ }
+ );
+ let codex = concat!(
+ r#"{"type":"thread.started","thread_id":"x"}"#,
+ "\n",
+ r#"{"type":"turn.completed","usage":{"input_tokens":24388,"cached_input_tokens":12288,"output_tokens":5}}"#,
+ "\n",
+ r#"{"type":"turn.completed","usage":{"input_tokens":12,"cached_input_tokens":0,"output_tokens":1}}"#,
+ );
+ let usage = parse_usage(Agent::Codex, codex);
+ assert_eq!(usage.input_tokens, Some(24400));
+ assert_eq!(usage.cached_input_tokens, Some(12288));
+ assert_eq!(usage.output_tokens, Some(6));
+ let opencode = r#"{"type":"step_finish","part":{"type":"step-finish","tokens":{"input":10,"output":4,"reasoning":2,"cache":{"read":30,"write":5}}}}"#;
+ assert_eq!(
+ parse_usage(Agent::OpenCode, opencode),
+ Usage {
+ input_tokens: Some(45),
+ cached_input_tokens: Some(30),
+ output_tokens: Some(6),
+ model: None,
+ }
+ );
+ for agent in [Agent::Claude, Agent::Codex, Agent::OpenCode] {
+ assert_eq!(parse_usage(agent, "not json"), Usage::default());
+ }
+ let mut total = Usage::default();
+ total.add(Usage::default());
+ assert_eq!(total, Usage::default());
+ }
+
+ #[test]
+ fn codex_errors_come_from_json_events() {
+ let out = r#"{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
+ let err = parse_output(Agent::Codex, out, None).unwrap_err();
+ assert!(err.to_string().contains("usage limit"), "{err}");
+ }
+
#[test]
fn parses_opencode_events() {
let out = concat!(
diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs
index ed51719..5cce99b 100644
--- a/crates/server/src/api.rs
+++ b/crates/server/src/api.rs
@@ -12,7 +12,7 @@ use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
-use axum::routing::{get, patch};
+use axum::routing::{get, patch, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -39,6 +39,8 @@ pub fn router() -> Router {
.route("/installations/{id}/vulnerabilities", get(vulnerabilities))
.route("/reviews/{id}", get(get_review))
.route("/reviews/{id}/pull", get(review_pull))
+ .route("/reviews/{id}/retry", post(retry_review))
+ .route("/reviews/{id}/cancel", post(cancel_review))
}
pub struct ApiError(StatusCode, String);
@@ -200,22 +202,34 @@ async fn me(access: Access) -> ApiResult {
/// Needs a token: OpenCode model lists show which providers have keys.
async fn agents(State(state): State, _: Access) -> ApiResult {
let names: Vec<&str> = state.settings.agents.iter().map(|a| a.name()).collect();
- // Only enabled agents. An agent without a stored list maps to `[]`.
- let stored: HashMap =
- db::agent_models(&state.db).await?.into_iter().collect();
- let models: serde_json::Map = names
- .iter()
- .map(|name| {
- (
- name.to_string(),
- stored.get(*name).cloned().unwrap_or(json!([])),
- )
- })
+ // Only enabled agents. An agent without a stored list maps to `[]`, and
+ // to `null` health until its first check.
+ let stored: HashMap = db::agent_models(&state.db)
+ .await?
+ .into_iter()
+ .map(|row| (row.agent.clone(), row))
.collect();
+ let mut models = serde_json::Map::new();
+ let mut health = serde_json::Map::new();
+ for name in &names {
+ let row = stored.get(*name);
+ models.insert(
+ name.to_string(),
+ row.map_or(json!([]), |r| r.models.clone()),
+ );
+ health.insert(
+ name.to_string(),
+ row.map_or(
+ serde_json::Value::Null,
+ |r| json!({ "available": r.available, "checked_at": r.checked_at }),
+ ),
+ );
+ }
Ok(Json(json!({
"agents": names,
"default": state.settings.default_agent.name(),
"models": models,
+ "health": health,
})))
}
@@ -230,6 +244,7 @@ struct InstallationView {
suspended: bool,
created_at: DateTime,
settings: SettingsView,
+ digest: DigestView,
can_admin: bool,
}
@@ -240,6 +255,16 @@ struct SettingsView {
model: Option,
}
+/// The webhook URL is a secret. Only its host is shown.
+#[derive(Serialize)]
+struct DigestView {
+ enabled: bool,
+ webhook_host: Option,
+ sent_at: Option>,
+ attempted_at: Option>,
+ error: Option,
+}
+
fn view(i: db::Installation, can_admin: bool) -> InstallationView {
InstallationView {
id: i.id,
@@ -254,6 +279,16 @@ fn view(i: db::Installation, can_admin: bool) -> InstallationView {
agent: i.agent,
model: i.model,
},
+ digest: DigestView {
+ enabled: i.digest_webhook_url.is_some(),
+ webhook_host: i
+ .digest_webhook_url
+ .as_deref()
+ .and_then(crate::digest::webhook_host),
+ sent_at: i.digest_sent_at,
+ attempted_at: i.digest_attempted_at,
+ error: i.digest_error,
+ },
can_admin,
}
}
@@ -312,6 +347,9 @@ struct SettingsPatch {
agent: Option
The weekly security digest is off by default. An installation admin turns it on in Settings with an HTTPS chat webhook URL. CroCode posts a Slack-compatible {'{"text": "..."}'} body. Slack and Mattermost accept it. For Discord, add /slack to the end of the webhook URL.
+
Every 7 days, the worker reads the Dependabot alerts of all installation repositories with a token that can only read alerts and metadata. The digest counts new alerts, fixed alerts, and open alerts past their deadline. It lists at most 5 alerts in each section.
The message names repositories, packages, and CVE or GHSA IDs. It leaves GitHub. The API never returns the webhook URL. The worker sends only to public addresses on port 443 and follows no redirects. After a failed send it tries again every hour.
Choose a model from the list, or type a model name. Leave the model blank to use the agent default.
+
Optional: turn on the Weekly security digest and enter an HTTPS chat webhook URL.
Save the settings.
The model list shows the models that each agent reports. For Claude, it shows the aliases and the versioned models from the Anthropic Models API. The worker checks them at start and every 6 hours.
The Overview tab shows open pull requests and the state of their newest review. It also shows review totals, reviews per day, and findings by severity for the last 30 days.
+
The Agent health and usage card shows whether each agent CLI answered its last model list check in the sandbox. This check does not test credentials. The card also shows reviews, median agent time, token counts, and models for each agent. Token counts appear only when the agent CLI reports them.
Only the account owner or an active organization admin can change settings. The server checks this access again when you save. Repository config can override the installation’s agent and model.
>,
},
@@ -352,7 +366,9 @@ export const DOCS_CONTENT: Record = {
["cancelled", "The job was cancelled, for example after a new push."],
["skipped", "Review policy skipped the job."],
]} />
-
Check the error text for failed reviews. The current API has no manual rerun endpoint. A head commit that already has a queue row is not queued again.
+
A failed or cancelled review shows a plain reason and a next step. Open Error details to read the stored error.
+
An admin can select Cancel review on a queued or running review. A running agent stops within about 20 seconds. An admin can select Retry review on a failed or cancelled review when it is the newest review of its pull request. A retry starts with fresh attempts and does not post a GitHub review twice.
+
The Summary tab also shows agent time and the token counts that the agent CLI reported.
/healthz, "No authentication. Returns ok."],
["GET", /api/v1/me, "Signed-in user ID and login."],
- ["GET", /api/v1/agents, "Enabled agents, server default, and detected models."],
+ ["GET", /api/v1/agents, "Enabled agents, server default, detected models, and harness health."],
["GET", /api/v1/installations, "Accessible installations that exist in the database."],
["GET", /api/v1/installations/{"{id}"}, "One accessible installation."],
["PATCH", /api/v1/installations/{"{id}"}/settings, "Update settings. Requires can_admin."],
@@ -446,14 +462,16 @@ export const DOCS_CONTENT: Record = {
["GET", /api/v1/installations/{"{id}"}/pulls, "Open pull requests with their newest review."],
["GET", /api/v1/reviews/{"{id}"}, "One review with its summary and findings."],
["GET", /api/v1/reviews/{"{id}"}/pull, "Live pull request details from GitHub."],
+ ["POST", /api/v1/reviews/{"{id}"}/retry, "Queue the newest failed or cancelled review again. Requires can_admin."],
+ ["POST", /api/v1/reviews/{"{id}"}/cancel, "Cancel a queued or running review. Requires can_admin."],
]} />,
},
{
id: "installations", title: "Installations and settings",
content: <>
-
+
can_admin is true for the account owner or an organization admin. A null agent uses the server default. A null model uses the selected agent’s default.
-
All fields in the settings PATCH body are optional. The response is the updated installation.
+
All fields in the settings PATCH body are optional. The response is the updated installation. Set digest_webhook_url to an HTTPS URL to turn on the weekly security digest, or to null to turn it off. The API never returns the URL.
Use limit from 1 to 100. The default is 50. Supply an RFC 3339 timestamp as before for older reviews. Add repo and pr together to list the reviews of one pull request.
-
-
Status is queued, running, succeeded, failed, cancelled, or skipped. The agent is null until a worker selects one.
+
+
Status is queued, running, succeeded, failed, cancelled, or skipped. The agent is null until a worker selects one. Token fields are null when the agent CLI reports no usage.
>,
},
{
diff --git a/web/lib/failure.test.ts b/web/lib/failure.test.ts
new file mode 100644
index 0000000..e1b1c11
--- /dev/null
+++ b/web/lib/failure.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test";
+import { canCancel, canRetry, explainReview } from "./failure";
+
+const title = (status: "failed" | "cancelled" | "succeeded", error: string | null) =>
+ explainReview({ status, error })?.title;
+
+describe("explainReview", () => {
+ test("maps stored worker errors to plain reasons", () => {
+ for (const [error, expected] of [
+ ["agent timed out after 900s", "The agent took too long"],
+ ["worker lost", "The worker stopped during the review"],
+ ["codex error: You've hit your usage limit", "The agent reached a usage limit"],
+ [
+ "claude returned no structured output: Not logged in (exit ok: false; stderr: )",
+ "The agent could not sign in",
+ ],
+ [
+ "agent JSON does not match the review schema (exit ok: true; stderr: )",
+ "The agent returned no valid review",
+ ],
+ ["start agent process: No such file or directory", "The sandbox could not start"],
+ ["git fetch failed: fatal: couldn't find remote ref", "The pull request could not be fetched"],
+ ["create installation token: GitHub", "A GitHub request failed"],
+ ["invalid .crocode.yml: unknown field", "The repository config is not valid"],
+ ["agent opencode is not enabled on this server", "The agent or model is not available"],
+ ["something new", "The review failed"],
+ [null, "The review failed"],
+ ] as const)
+ expect(title("failed", error)).toBe(expected);
+ });
+
+ test("explains each cancel reason", () => {
+ expect(title("cancelled", "superseded")).toBe("A newer commit replaced this review");
+ expect(title("cancelled", "cancelled from the dashboard")).toBe(
+ "An admin cancelled this review",
+ );
+ expect(title("cancelled", null)).toBe("The review was cancelled");
+ expect(explainReview({ status: "cancelled", error: "odd" })?.detail).toBe("odd");
+ });
+
+ test("has no reason for other states", () => {
+ expect(title("succeeded", "ignored")).toBeUndefined();
+ });
+
+ test("allows controls only in matching states", () => {
+ expect([canRetry("failed"), canRetry("cancelled"), canRetry("running")]).toEqual([
+ true,
+ true,
+ false,
+ ]);
+ expect([canCancel("queued"), canCancel("running"), canCancel("failed")]).toEqual([
+ true,
+ true,
+ false,
+ ]);
+ });
+});
diff --git a/web/lib/failure.ts b/web/lib/failure.ts
new file mode 100644
index 0000000..3ba140f
--- /dev/null
+++ b/web/lib/failure.ts
@@ -0,0 +1,164 @@
+import type { Review } from "./types";
+
+export type FailureReason = {
+ title: string;
+ /** What happened, in plain words. */
+ detail: string;
+ /** What to do next. Empty when no action helps. */
+ action: string;
+};
+
+/** Patterns match the messages that the worker and webhook store. */
+const failed: [RegExp, FailureReason][] = [
+ [
+ /usage limit|rate.?limit|insufficient_quota|you've hit your|free usage limit/i,
+ {
+ title: "The agent reached a usage limit",
+ detail: "The model provider refused more requests for now.",
+ action:
+ "Wait until the limit resets, then retry. Set CROCODE_FALLBACK_AGENT to switch agents automatically.",
+ },
+ ],
+ [
+ /not logged in|invalid api key|authentication|unauthori[sz]ed|oauth|credential|login/i,
+ {
+ title: "The agent could not sign in",
+ detail: "The agent CLI rejected its credentials.",
+ action:
+ "Check the agent key or subscription token on the worker, then retry.",
+ },
+ ],
+ [
+ /agent timed out/i,
+ {
+ title: "The agent took too long",
+ detail: "The agent did not finish before the worker timeout.",
+ action:
+ "Retry. If it fails again, raise CROCODE_AGENT_TIMEOUT_SECS or split the pull request.",
+ },
+ ],
+ [
+ /^worker lost$/,
+ {
+ title: "The worker stopped during the review",
+ detail:
+ "A worker crashed or restarted and stopped sending heartbeats too many times.",
+ action: "Check the worker logs, then retry.",
+ },
+ ],
+ [
+ /structured output|no JSON object|review schema|no final message|codex error|opencode error/i,
+ {
+ title: "The agent returned no valid review",
+ detail: "The agent output did not match the review format.",
+ action: "Retry. If it fails again, choose another model.",
+ },
+ ],
+ [
+ /start agent process|docker|sandbox/i,
+ {
+ title: "The sandbox could not start",
+ detail: "The worker could not run the agent container.",
+ action:
+ "Check Docker access, DOCKER_GID, and the sandbox image on the worker, then retry.",
+ },
+ ],
+ [
+ /^git .*(failed|timed out)|run git/i,
+ {
+ title: "The pull request could not be fetched",
+ detail: "Git could not download the pull request commit.",
+ action: "Check that the commit still exists, then retry.",
+ },
+ ],
+ [
+ /installation token|GitHub|status code|HTTP \d{3}/i,
+ {
+ title: "A GitHub request failed",
+ detail: "GitHub refused or could not answer a request for this review.",
+ action:
+ "Check that the app can still access the repository, then retry.",
+ },
+ ],
+ [
+ /^invalid \.crocode\.yml/i,
+ {
+ title: "The repository config is not valid",
+ detail: "CroCode reads .crocode.yml from the base branch.",
+ action: "Fix .crocode.yml on the base branch, then retry.",
+ },
+ ],
+ [
+ /is not enabled on this server|unknown agent|invalid model name/i,
+ {
+ title: "The agent or model is not available",
+ detail: "The selected agent or model cannot run on this server.",
+ action:
+ "Choose an enabled agent and a valid model in Settings or .crocode.yml, then retry.",
+ },
+ ],
+ [
+ /^installation not found$/,
+ {
+ title: "The installation is gone",
+ detail: "The GitHub App installation no longer exists.",
+ action: "",
+ },
+ ],
+];
+
+const cancelled: Record = {
+ superseded: {
+ title: "A newer commit replaced this review",
+ detail: "CroCode reviews only the newest commit of a pull request.",
+ action: "",
+ },
+ "pull request closed": {
+ title: "The pull request was closed",
+ detail: "CroCode stops reviews of closed pull requests.",
+ action: "",
+ },
+ "pull request converted to draft": {
+ title: "The pull request became a draft",
+ detail: "CroCode stops reviews of drafts unless review_drafts is on.",
+ action: "",
+ },
+ "installation removed": {
+ title: "The app was suspended or removed",
+ detail: "The GitHub App installation stopped while this review waited.",
+ action: "",
+ },
+ "cancelled from the dashboard": {
+ title: "An admin cancelled this review",
+ detail: "Someone cancelled the review from the dashboard.",
+ action: "",
+ },
+};
+
+/** Plain-language reason for a failed or cancelled review. Null otherwise. */
+export function explainReview(
+ review: Pick,
+): FailureReason | null {
+ const error = review.error?.trim() ?? "";
+ if (review.status === "cancelled")
+ return (
+ cancelled[error] ?? {
+ title: "The review was cancelled",
+ detail: error || "The review stopped before it finished.",
+ action: "",
+ }
+ );
+ if (review.status !== "failed") return null;
+ return (
+ failed.find(([pattern]) => pattern.test(error))?.[1] ?? {
+ title: "The review failed",
+ detail: "The worker stopped with an unexpected error.",
+ action: "See the error details. Retry if the cause was temporary.",
+ }
+ );
+}
+
+export const canRetry = (status: Review["status"]) =>
+ status === "failed" || status === "cancelled";
+export const canCancel = (status: Review["status"]) =>
+ status === "queued" || status === "running";
diff --git a/web/lib/format.test.ts b/web/lib/format.test.ts
index 69dccf9..95b8fae 100644
--- a/web/lib/format.test.ts
+++ b/web/lib/format.test.ts
@@ -1,5 +1,18 @@
import { describe, expect, test } from "bun:test";
-import { formatDuration, formatRelative, formatSeconds } from "./format";
+import {
+ formatCount,
+ formatDuration,
+ formatRelative,
+ formatSeconds,
+} from "./format";
+
+test("formatCount stays compact and keeps unknown values", () => {
+ expect(formatCount(0)).toBe("0");
+ expect(formatCount(950)).toBe("950");
+ expect(formatCount(24_388)).toBe("24.4K");
+ expect(formatCount(1_250_000)).toBe("1.3M");
+ expect(formatCount(null)).toBeNull();
+});
const now = Date.parse("2026-11-01T14:00:00Z");
diff --git a/web/lib/format.ts b/web/lib/format.ts
index 82d3780..c9c82e9 100644
--- a/web/lib/format.ts
+++ b/web/lib/format.ts
@@ -55,6 +55,15 @@ export function formatDuration(start: string, end: string | null) {
}
/** "2m 41s" for a number of seconds, or null when it is not valid. */
+/** Compact count, such as `12.3K`. Null stays null. */
+export function formatCount(value: number | null) {
+ if (value === null || !Number.isFinite(value)) return null;
+ return new Intl.NumberFormat("en", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ }).format(value);
+}
+
export function formatSeconds(value: number | null) {
if (value === null) return null;
const seconds = Math.round(value);
diff --git a/web/lib/types.ts b/web/lib/types.ts
index ad22288..56c186c 100644
--- a/web/lib/types.ts
+++ b/web/lib/types.ts
@@ -1,10 +1,14 @@
export type User = { id: number; login: string };
export type AgentModel = { id: string; name: string };
+/** Result of the latest model list run in the sandbox. */
+export type AgentHealth = { available: boolean; checked_at: string };
export type Agents = {
agents: string[];
default: string;
/** Models each enabled agent CLI reports. Empty until the worker detects them. */
models: Record;
+ /** Null until the worker checks the agent. */
+ health: Record;
};
/** Form value for "use the server default agent". The API stores it as null. */
export const DEFAULT_AGENT = "default";
@@ -24,8 +28,17 @@ export type Installation = {
suspended: boolean;
created_at: string;
settings: Settings;
+ digest: Digest;
can_admin: boolean;
};
+/** Opt-in weekly security digest. The API never returns the webhook URL. */
+export type Digest = {
+ enabled: boolean;
+ webhook_host: string | null;
+ sent_at: string | null;
+ attempted_at: string | null;
+ error: string | null;
+};
export type ReviewStatus =
| "queued"
| "running"
@@ -49,6 +62,12 @@ export type Review = {
error: string | null;
created_at: string;
finished_at: string | null;
+ /** Time in agent processes. Null when no agent finished. */
+ agent_duration_ms: number | null;
+ /** Token counts that the agent CLI reported. Input includes cached input. */
+ input_tokens: number | null;
+ cached_input_tokens: number | null;
+ output_tokens: number | null;
};
export const SEVERITIES = [
"critical",
@@ -98,6 +117,21 @@ export type Stats = {
categories: NameCount[];
/** One entry per UTC day, oldest first. */
daily: { date: string; reviews: number; failed: number }[];
+ /** Reviews with a picked agent, most used first. */
+ agents: AgentUsage[];
+};
+export type AgentUsage = {
+ name: string;
+ reviews: number;
+ succeeded: number;
+ failed: number;
+ median_agent_secs: number | null;
+ /** Reviews that reported token usage. Token sums cover only these. */
+ usage_reviews: number;
+ input_tokens: number;
+ cached_input_tokens: number;
+ output_tokens: number;
+ models: string[];
};
export type OpenPull = {
repo_full_name: string;
diff --git a/web/scripts/smoke.ts b/web/scripts/smoke.ts
index 7c303ea..7b1e0d9 100644
--- a/web/scripts/smoke.ts
+++ b/web/scripts/smoke.ts
@@ -16,6 +16,13 @@ const installation = {
suspended: false,
created_at: "2026-11-01T10:00:00Z",
settings: { enabled: true, agent: "codex", model: null },
+ digest: {
+ enabled: false,
+ webhook_host: null,
+ sent_at: null,
+ attempted_at: null,
+ error: null,
+ },
can_admin: true,
};
const review = {
@@ -30,10 +37,15 @@ const review = {
model: null,
attempts: 1,
finding_count: 2,
- error: null,
+ error: null as string | null,
created_at: "2026-11-01T10:00:00.123456Z",
finished_at: "2026-11-01T10:03:00Z",
+ agent_duration_ms: 95_000,
+ input_tokens: 24_388,
+ cached_input_tokens: 12_288,
+ output_tokens: 512,
};
+const controls: string[] = [];
let apiStatus = 200;
let canAdmin = true;
let empty = false;
@@ -60,7 +72,18 @@ const mock = Bun.serve({
codex: [{ id: "gpt-smoke", name: "GPT Smoke" }],
claude: [{ id: "opus", name: "opus" }],
},
+ health: {
+ codex: { available: true, checked_at: "2026-11-01T10:00:00Z" },
+ claude: { available: false, checked_at: "2026-11-01T10:00:00Z" },
+ },
});
+ if (
+ request.method === "POST" &&
+ /^\/api\/v1\/reviews\/[^/]+\/(retry|cancel)$/.test(url.pathname)
+ ) {
+ controls.push(url.pathname);
+ return Response.json({ ...review, status: "queued" });
+ }
if (url.pathname.endsWith("/settings") && request.method === "PATCH") {
const body: unknown = await request.json();
patches.push(body);
@@ -115,6 +138,20 @@ const mock = Bun.serve({
reviews: i === 29 ? 20 : 0,
failed: i === 29 ? 1 : 0,
})),
+ agents: [
+ {
+ name: "codex",
+ reviews: 20,
+ succeeded: 19,
+ failed: 1,
+ median_agent_secs: 95,
+ usage_reviews: 19,
+ input_tokens: 1_250_000,
+ cached_input_tokens: 600_000,
+ output_tokens: 40_000,
+ models: ["gpt-smoke"],
+ },
+ ],
});
if (url.pathname === "/api/v1/installations/123/pulls") {
if (pullsFail) return Response.json({ error: "GitHub" }, { status: 502 });
@@ -389,6 +426,9 @@ try {
assert.equal(response.status, 200);
assert.match(overview, /Load older/);
assert.match(overview, /Not reviewed/);
+ assert.match(overview, /Agent health and usage/);
+ assert.match(overview, /Unavailable/);
+ assert.match(overview, /1\.3M()? in ·()? ()?40K()? out/);
assert.ok(overview.includes("<b>Open pull request</b>"));
assert.ok(!overview.includes("Open pull request"));
assert.ok(!overview.includes('name="agent"'), "Settings live in their own tab");
@@ -455,7 +495,7 @@ try {
response = await get("/installations/123/settings");
const settings = await response.text();
assert.equal(response.status, 200);
- assert.match(settings, /1 model detected from the codex CLI/);
+ assert.match(settings, /1 model detected for codex/);
assert.match(settings, /]*name="model"/, "Model field submits as model");
assert.match(settings, /]*name="agent"/, "Agent field submits as agent");
const form = actionForm(settings, 'name="agent"');
@@ -475,6 +515,36 @@ try {
"/installations/123/settings?saved=1",
);
assert.deepEqual(patches, [{ enabled: false, agent: "codex", model: null }]);
+ assert.match(settings, /Weekly security digest/);
+ form.set("digest", "on");
+ const missingUrl = await fetch(origin + "/installations/123/settings", {
+ method: "POST",
+ headers: { Cookie: sessionCookie, Origin: origin },
+ body: form,
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ });
+ assert.equal(
+ missingUrl.headers.get("location"),
+ "/installations/123/settings?error=digest_url",
+ );
+ form.set("digest_webhook_url", " https://hooks.slack.com/services/T/B/x ");
+ await fetch(origin + "/installations/123/settings", {
+ method: "POST",
+ headers: { Cookie: sessionCookie, Origin: origin },
+ body: form,
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ });
+ assert.deepEqual(patches.at(-1), {
+ enabled: false,
+ agent: "codex",
+ model: null,
+ digest_webhook_url: "https://hooks.slack.com/services/T/B/x",
+ });
+ patches.length = 1;
+ form.delete("digest");
+ form.delete("digest_webhook_url");
assert.match(
await (await get("/installations/123/settings?saved=1")).text(),
/Settings saved/,
@@ -525,6 +595,26 @@ try {
assert.ok(!detail.includes(''));
assert.ok(detail.includes("<script>"));
assert.ok(!detail.includes(''));
+ assert.match(detail, /24\.4K in · 512 out/);
+ assert.ok(!detail.includes("Retry review"), "No retry for a succeeded review");
+ review.status = "failed";
+ review.error = "agent timed out after 900s";
+ const failedDetail = await (await get(`/reviews/${reviewId}`)).text();
+ assert.match(failedDetail, /The agent took too long/);
+ assert.match(failedDetail, /Error details/);
+ assert.match(failedDetail, /Retry review/);
+ console.log("Submitting retry action");
+ const retry = await fetch(origin + `/reviews/${reviewId}`, {
+ method: "POST",
+ headers: { Cookie: sessionCookie, Origin: origin },
+ body: actionForm(failedDetail, "Retry review"),
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ });
+ assert.equal(retry.headers.get("location"), `/reviews/${reviewId}?done=retry`);
+ assert.deepEqual(controls, [`/api/v1/reviews/${reviewId}/retry`]);
+ review.status = "succeeded";
+ review.error = null;
assert.match(await (await get("/reviews/invalid")).text(), /Page not found/);
empty = true;
assert.match(
@@ -643,7 +733,7 @@ try {
`Sign-out clears ${name}`,
);
console.log(
- "PASS: standalone runtime; public docs, Better Auth GitHub flow, landing, installations, overview, open pull requests and GitHub failure, vulnerabilities, fix priority queue and repository pages, settings and models, admin guard, pagination, review escaping, empty/404/401, expired token, disabled token endpoints, sign-in and sign-out actions.",
+ "PASS: standalone runtime; public docs, agent health, review retry, digest settings, Better Auth GitHub flow, landing, installations, overview, open pull requests and GitHub failure, vulnerabilities, fix priority queue and repository pages, settings and models, admin guard, pagination, review escaping, empty/404/401, expired token, disabled token endpoints, sign-in and sign-out actions.",
);
} catch (error) {
console.error(output, calls);
From 1d529f70e8ec05a8d223cf4413fb1dedefc5f888 Mon Sep 17 00:00:00 2001
From: Gaya KACI <163902005+kacigaya@users.noreply.github.com>
Date: Tue, 3 Nov 2026 08:19:37 +0000
Subject: [PATCH 2/2] feat(digest): encrypt webhook URL at rest
Seal the URL with AES-256-GCM under an HKDF key derived from the
GitHub App private key. Bind the installation ID as associated data.
Keep only the host in plain text for display. A database dump alone
no longer reveals the URL. After an App key rotation the digest
reports an error and the admin enters the URL again.
---
Cargo.lock | 1 +
crates/server/Cargo.toml | 1 +
crates/server/src/api.rs | 22 +++--
crates/server/src/db.rs | 38 ++++----
crates/server/src/digest.rs | 118 ++++++++++++++++++++--
crates/server/src/main.rs | 2 +
crates/server/src/tests.rs | 124 +++++++++++++++---------
docs/configuration.md | 3 +
docs/security.md | 8 +-
migrations/0004_usage_health_digest.sql | 8 +-
web/components/settings-form.tsx | 2 +-
web/lib/docs-content.tsx | 2 +-
12 files changed, 247 insertions(+), 82 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0fe7234..269be63 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -421,6 +421,7 @@ dependencies = [
"hex",
"hmac 0.13.0",
"octocrab",
+ "ring",
"serde",
"serde_json",
"sha2 0.11.1",
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index 4cdfff1..dd0fa81 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -20,6 +20,7 @@ crocode-core.workspace = true
hex.workspace = true
hmac = "0.13"
octocrab = "0.54"
+ring = "0.17"
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs
index 5cce99b..e0e9da7 100644
--- a/crates/server/src/api.rs
+++ b/crates/server/src/api.rs
@@ -280,11 +280,8 @@ fn view(i: db::Installation, can_admin: bool) -> InstallationView {
model: i.model,
},
digest: DigestView {
- enabled: i.digest_webhook_url.is_some(),
- webhook_host: i
- .digest_webhook_url
- .as_deref()
- .and_then(crate::digest::webhook_host),
+ enabled: i.digest_webhook_sealed.is_some(),
+ webhook_host: i.digest_webhook_host,
sent_at: i.digest_sent_at,
attempted_at: i.digest_attempted_at,
error: i.digest_error,
@@ -388,14 +385,25 @@ async fn update_settings(
Some(None) => None,
None => current.model,
};
- let digest = match &patch.digest_webhook_url {
+ let sealed = match &patch.digest_webhook_url {
Some(Some(url)) => {
crate::digest::validate_webhook_url(url).map_err(unprocessable)?;
- Some(Some(url.as_str()))
+ let host = crate::digest::webhook_host(url)
+ .ok_or_else(|| unprocessable("invalid webhook URL"))?;
+ let sealed = state.webhook_key.seal(id, url).map_err(|err| {
+ tracing::error!(error = err, "seal webhook URL");
+ ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
+ })?;
+ Some(Some((sealed, host)))
}
Some(None) => Some(None),
None => None,
};
+ let digest = sealed.as_ref().map(|value| {
+ value
+ .as_ref()
+ .map(|(sealed, host)| (sealed.as_str(), host.as_str()))
+ });
let enabled = patch.enabled.unwrap_or(current.enabled);
db::update_settings(
&state.db,
diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs
index c106d28..358ba49 100644
--- a/crates/server/src/db.rs
+++ b/crates/server/src/db.rs
@@ -61,8 +61,9 @@ pub struct Installation {
pub agent: Option,
pub model: Option,
pub created_at: DateTime,
- /// Secret. Never return it from the API.
- pub digest_webhook_url: Option,
+ /// The webhook URL, sealed. Open it only to send. See `digest::WebhookKey`.
+ pub digest_webhook_sealed: Option,
+ pub digest_webhook_host: Option,
pub digest_sent_at: Option>,
pub digest_attempted_at: Option>,
pub digest_error: Option,
@@ -71,7 +72,7 @@ pub struct Installation {
const INSTALLATION_COLUMNS: &str =
"id, account_id, account_login, account_type, repository_selection,
suspended, enabled, agent, model, created_at,
- digest_webhook_url, digest_sent_at, digest_attempted_at, digest_error";
+ digest_webhook_sealed, digest_webhook_host, digest_sent_at, digest_attempted_at, digest_error";
pub struct InstallationUpsert<'a> {
pub id: i64,
@@ -148,24 +149,25 @@ pub async fn installations(db: &Db, ids: &[i64]) -> sqlx::Result,
model: Option<&str>,
- digest_webhook_url: Option
>,
+ digest: Option
>,
) -> sqlx::Result<()> {
sqlx::query(
"UPDATE installations SET enabled = $2, agent = $3, model = $4,
- digest_webhook_url = CASE WHEN $5 THEN $6 ELSE digest_webhook_url END,
- digest_attempted_at = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_url
+ digest_webhook_sealed = CASE WHEN $5 THEN $6 ELSE digest_webhook_sealed END,
+ digest_webhook_host = CASE WHEN $5 THEN $7 ELSE digest_webhook_host END,
+ digest_attempted_at = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_sealed
THEN NULL ELSE digest_attempted_at END,
- digest_error = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_url
+ digest_error = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_sealed
THEN NULL ELSE digest_error END,
- digest_sent_at = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_url
+ digest_sent_at = CASE WHEN $5 AND $6 IS DISTINCT FROM digest_webhook_sealed
THEN NULL ELSE digest_sent_at END,
updated_at = now()
WHERE id = $1",
@@ -174,8 +176,9 @@ pub async fn update_settings(
.bind(enabled)
.bind(agent)
.bind(model)
- .bind(digest_webhook_url.is_some())
- .bind(digest_webhook_url.flatten())
+ .bind(digest.is_some())
+ .bind(digest.flatten().map(|(sealed, _)| sealed))
+ .bind(digest.flatten().map(|(_, host)| host))
.execute(db)
.await?;
Ok(())
@@ -998,7 +1001,8 @@ pub async fn review_contents(
pub struct DigestJob {
pub id: i64,
pub account_login: String,
- pub digest_webhook_url: String,
+ /// Sealed. See `digest::WebhookKey`.
+ pub digest_webhook_sealed: String,
pub digest_sent_at: Option>,
}
@@ -1009,13 +1013,13 @@ pub async fn claim_digest(db: &Db) -> sqlx::Result
> {
"UPDATE installations SET digest_attempted_at = now()
WHERE id = (
SELECT id FROM installations
- WHERE digest_webhook_url IS NOT NULL AND NOT deleted AND NOT suspended
+ WHERE digest_webhook_sealed IS NOT NULL AND NOT deleted AND NOT suspended
AND (digest_sent_at IS NULL OR digest_sent_at <= now() - interval '7 days')
AND (digest_attempted_at IS NULL OR digest_attempted_at <= now() - interval '1 hour')
ORDER BY digest_attempted_at NULLS FIRST
FOR UPDATE SKIP LOCKED
LIMIT 1)
- RETURNING id, account_login, digest_webhook_url, digest_sent_at",
+ RETURNING id, account_login, digest_webhook_sealed, digest_sent_at",
)
.fetch_optional(db)
.await
@@ -1032,10 +1036,10 @@ pub async fn finish_digest(
sqlx::query(
"UPDATE installations SET digest_error = $3,
digest_sent_at = COALESCE($4, digest_sent_at)
- WHERE id = $1 AND digest_webhook_url = $2",
+ WHERE id = $1 AND digest_webhook_sealed = $2",
)
.bind(job.id)
- .bind(&job.digest_webhook_url)
+ .bind(&job.digest_webhook_sealed)
.bind(result.err())
.bind(result.ok())
.execute(db)
diff --git a/crates/server/src/digest.rs b/crates/server/src/digest.rs
index 79ea7ad..c4398c8 100644
--- a/crates/server/src/digest.rs
+++ b/crates/server/src/digest.rs
@@ -1,6 +1,7 @@
//! Opt-in weekly security digest.
//!
-//! An installation admin sets an HTTPS webhook URL. Every 7 days the worker
+//! An installation admin sets an HTTPS webhook URL. The database holds it
+//! sealed with AES-256-GCM, see [`WebhookKey`]. Every 7 days the worker
//! reads the Dependabot alerts of the installation with a read-only token and
//! posts a Slack-compatible message (`{"text": ...}`) to that URL.
//!
@@ -16,8 +17,12 @@ use std::time::Duration;
use anyhow::{Context, Result};
use axum::http::Uri;
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD;
use chrono::{DateTime, Utc};
use octocrab::{Octocrab, Page};
+use ring::rand::{SecureRandom, SystemRandom};
+use ring::{aead, hkdf};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -45,6 +50,71 @@ const DELIVER_TIMEOUT: Duration = Duration::from_secs(45 * 60);
/// Items listed per section. Counts always cover every alert.
const LIST_ITEMS: usize = 5;
+/// Seals webhook URLs at rest. The AES-256-GCM key comes from the GitHub App
+/// private key through HKDF-SHA256, so a database dump or backup alone does
+/// not reveal a URL. The installation ID is the associated data: a sealed
+/// value copied to another installation does not open. Rotating the App key
+/// makes stored URLs unreadable; the admin enters the URL again.
+pub struct WebhookKey(aead::LessSafeKey);
+
+const SEALED_PREFIX: &str = "v1:";
+
+impl WebhookKey {
+ pub fn derive(app_private_key: &[u8]) -> Self {
+ // The API and worker may read the same PEM with different line
+ // endings or trailing whitespace.
+ let normalized: Vec = String::from_utf8_lossy(app_private_key)
+ .replace("\r\n", "\n")
+ .trim()
+ .as_bytes()
+ .to_vec();
+ let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, b"crocode").extract(&normalized);
+ let okm = prk
+ .expand(&[b"digest webhook url v1"], &aead::AES_256_GCM)
+ .expect("AES-256 key length is a valid HKDF output length");
+ Self(aead::LessSafeKey::new(aead::UnboundKey::from(okm)))
+ }
+
+ pub fn seal(&self, installation: i64, url: &str) -> Result {
+ let mut nonce = [0u8; aead::NONCE_LEN];
+ SystemRandom::new()
+ .fill(&mut nonce)
+ .map_err(|_| "random number generator failed")?;
+ let mut data = url.as_bytes().to_vec();
+ self.0
+ .seal_in_place_append_tag(
+ aead::Nonce::assume_unique_for_key(nonce),
+ aead::Aad::from(installation.to_be_bytes()),
+ &mut data,
+ )
+ .map_err(|_| "cannot seal the webhook URL")?;
+ Ok(format!(
+ "{SEALED_PREFIX}{}",
+ STANDARD.encode([nonce.as_slice(), &data].concat())
+ ))
+ }
+
+ /// `None` when the value is damaged, belongs to another installation, or
+ /// was sealed with another key.
+ pub fn open(&self, installation: i64, sealed: &str) -> Option {
+ let mut bytes = STANDARD.decode(sealed.strip_prefix(SEALED_PREFIX)?).ok()?;
+ if bytes.len() < aead::NONCE_LEN {
+ return None;
+ }
+ let mut data = bytes.split_off(aead::NONCE_LEN);
+ let nonce = aead::Nonce::try_assume_unique_for_key(&bytes).ok()?;
+ let plain = self
+ .0
+ .open_in_place(
+ nonce,
+ aead::Aad::from(installation.to_be_bytes()),
+ &mut data,
+ )
+ .ok()?;
+ String::from_utf8(plain.to_vec()).ok()
+ }
+}
+
/// Days an open alert may stay open before the digest calls it overdue.
pub fn deadline_days(severity: &str) -> Option {
match severity {
@@ -541,6 +611,13 @@ pub async fn send(url: &str, text: &str) -> Result<(), String> {
/// Returns the start of the alert read on success.
async fn deliver(state: &AppState, job: &db::DigestJob) -> Result, String> {
+ let url = state
+ .webhook_key
+ .open(job.id, &job.digest_webhook_sealed)
+ .ok_or_else(|| {
+ "cannot decrypt the webhook URL; the GitHub App key may have changed, so enter the URL again"
+ .to_owned()
+ })?;
let now = Utc::now();
let since = job
.digest_sent_at
@@ -575,15 +652,12 @@ async fn deliver(state: &AppState, job: &db::DigestJob) -> Result,
match db::installation(&state.db, job.id).await {
Ok(Some(i))
if !i.suspended
- && i.digest_webhook_url.as_deref() == Some(job.digest_webhook_url.as_str()) => {}
+ && i.digest_webhook_sealed.as_deref()
+ == Some(job.digest_webhook_sealed.as_str()) => {}
Ok(_) => return Err("digest settings changed before sending".into()),
Err(_) => return Err("database error before sending".into()),
}
- send(
- &job.digest_webhook_url,
- &render(&job.account_login, &digest),
- )
- .await?;
+ send(&url, &render(&job.account_login, &digest)).await?;
Ok(now)
}
@@ -632,6 +706,36 @@ pub async fn run(state: AppState, shutdown: CancellationToken) {
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn webhook_urls_are_sealed_per_installation_and_key() {
+ let key = WebhookKey::derive(b"-----BEGIN KEY-----\nabc\n-----END KEY-----\n");
+ let url = "https://hooks.slack.com/services/T0/B0/secret";
+ let sealed = key.seal(7, url).unwrap();
+ assert!(sealed.starts_with("v1:") && !sealed.contains("secret"));
+ assert_ne!(
+ sealed,
+ key.seal(7, url).unwrap(),
+ "each seal uses a new nonce"
+ );
+ assert_eq!(key.open(7, &sealed).as_deref(), Some(url));
+ // The same PEM with other line endings derives the same key.
+ let crlf = WebhookKey::derive(b"-----BEGIN KEY-----\r\nabc\r\n-----END KEY-----");
+ assert_eq!(crlf.open(7, &sealed).as_deref(), Some(url));
+ assert_eq!(key.open(8, &sealed), None, "another installation");
+ assert_eq!(
+ WebhookKey::derive(b"other").open(7, &sealed),
+ None,
+ "another key"
+ );
+ let mut tampered = sealed.clone().into_bytes();
+ let last = tampered.len() - 2;
+ tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
+ assert_eq!(key.open(7, &String::from_utf8(tampered).unwrap()), None);
+ for bad in ["", "v1:", "v1:AAAA", url] {
+ assert_eq!(key.open(7, bad), None, "{bad}");
+ }
+ }
use axum::extract::OriginalUri;
use axum::response::IntoResponse;
use axum::{Router, routing::get};
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index c605bf5..944013f 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -36,6 +36,7 @@ pub struct AppState {
pub auth: Arc,
pub pulls: Arc,
pub vulnerability_checks: Arc,
+ pub webhook_key: Arc,
}
const USAGE: &str = "usage: crocode ";
@@ -75,6 +76,7 @@ async fn main() -> Result<()> {
&settings.github_private_key,
&settings.github_api_url,
)?,
+ webhook_key: Arc::new(digest::WebhookKey::derive(&settings.github_private_key)),
settings,
auth: Arc::default(),
pulls: Arc::default(),
diff --git a/crates/server/src/tests.rs b/crates/server/src/tests.rs
index 388c951..532e0c1 100644
--- a/crates/server/src/tests.rs
+++ b/crates/server/src/tests.rs
@@ -23,6 +23,7 @@ fn state(pool: PgPool) -> AppState {
auth: Arc::default(),
pulls: Arc::default(),
vulnerability_checks: Arc::default(),
+ webhook_key: Arc::new(crate::digest::WebhookKey::derive(b"test-key")),
}
}
@@ -1141,22 +1142,69 @@ async fn usage_is_stored_and_grouped_by_agent(pool: PgPool) {
#[sqlx::test(migrations = "../../migrations")]
#[ignore = "needs DATABASE_URL"]
-async fn digest_opt_in_claims_once_and_hides_the_url(pool: PgPool) {
+async fn digest_opt_in_claims_once_and_seals_the_url(pool: PgPool) {
installation(&pool, 1).await;
installation(&pool, 2).await;
assert!(db::claim_digest(&pool).await.unwrap().is_none());
+ let state = state(pool.clone());
+ with_access(&state, "admin", &[(1, true), (2, true)]);
+ let patch = |id: i64, body: Value| {
+ let state = state.clone();
+ async move {
+ router(state)
+ .oneshot(
+ Request::patch(format!("/api/v1/installations/{id}/settings"))
+ .header("authorization", "Bearer admin")
+ .header("content-type", "application/json")
+ .body(Body::from(body.to_string()))
+ .unwrap(),
+ )
+ .await
+ .unwrap()
+ .status()
+ }
+ };
+ for bad in [
+ "http://hooks.example.com/x",
+ "https://10.0.0.1/x",
+ "https://localhost/x",
+ ] {
+ assert_eq!(
+ patch(1, json!({ "digest_webhook_url": bad })).await,
+ StatusCode::UNPROCESSABLE_ENTITY,
+ "{bad}"
+ );
+ }
let url = "https://hooks.example.com/services/secret-path";
- db::update_settings(&pool, 1, true, None, None, Some(Some(url)))
- .await
- .unwrap();
+ assert_eq!(
+ patch(1, json!({ "digest_webhook_url": url })).await,
+ StatusCode::OK
+ );
+
+ // The database holds only the sealed value and the host.
+ let row: String =
+ sqlx::query_scalar("SELECT row_to_json(i)::text FROM installations i WHERE id = 1")
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert!(!row.contains("secret-path"), "{row}");
let job = db::claim_digest(&pool).await.unwrap().unwrap();
- assert_eq!((job.id, job.digest_webhook_url.as_str()), (1, url));
+ assert_eq!(job.id, 1);
+ assert_eq!(
+ state
+ .webhook_key
+ .open(1, &job.digest_webhook_sealed)
+ .as_deref(),
+ Some(url)
+ );
+ assert_eq!(state.webhook_key.open(2, &job.digest_webhook_sealed), None);
assert!(db::claim_digest(&pool).await.unwrap().is_none());
db::finish_digest(&pool, &job, Err("webhook returned HTTP 404"))
.await
.unwrap();
let i = db::installation(&pool, 1).await.unwrap().unwrap();
assert_eq!(i.digest_error.as_deref(), Some("webhook returned HTTP 404"));
+ assert_eq!(i.digest_webhook_host.as_deref(), Some("hooks.example.com"));
assert!(i.digest_sent_at.is_none());
// A failed send waits an hour before the next attempt.
@@ -1181,55 +1229,43 @@ async fn digest_opt_in_claims_once_and_hides_the_url(pool: PgPool) {
.unwrap();
assert!(db::claim_digest(&pool).await.unwrap().is_none());
- // A result for an old URL is dropped after the admin changes it.
- let other = "https://hooks.example.com/services/other";
- db::update_settings(&pool, 1, true, None, None, Some(Some(other)))
- .await
- .unwrap();
+ // A result for an old URL is dropped after the admin changes it, and the
+ // new URL gets its first digest at once.
+ assert_eq!(
+ patch(
+ 1,
+ json!({ "digest_webhook_url": "https://chat.example.org/hooks/other" })
+ )
+ .await,
+ StatusCode::OK
+ );
db::finish_digest(&pool, &job, Err("old")).await.unwrap();
let i = db::installation(&pool, 1).await.unwrap().unwrap();
assert!(i.digest_error.is_none() && i.digest_attempted_at.is_none());
- // A new URL gets its first digest at once.
assert!(i.digest_sent_at.is_none());
+ assert_eq!(i.digest_webhook_host.as_deref(), Some("chat.example.org"));
assert_eq!(db::claim_digest(&pool).await.unwrap().unwrap().id, 1);
- let state = state(pool.clone());
- with_access(&state, "admin", &[(1, true)]);
- for bad in [
- "http://hooks.example.com/x",
- "https://10.0.0.1/x",
- "https://localhost/x",
- ] {
- let response = router(state.clone())
- .oneshot(
- Request::patch("/api/v1/installations/1/settings")
- .header("authorization", "Bearer admin")
- .header("content-type", "application/json")
- .body(Body::from(json!({ "digest_webhook_url": bad }).to_string()))
- .unwrap(),
- )
- .await
- .unwrap();
- assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY, "{bad}");
- }
let (status, body) = call(&state, "GET", "/api/v1/installations/1", "admin").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["digest"]["enabled"], true);
- assert_eq!(body["digest"]["webhook_host"], "hooks.example.com");
- assert!(!body.to_string().contains("services/"));
+ assert_eq!(body["digest"]["webhook_host"], "chat.example.org");
+ assert!(!body.to_string().contains("hooks/other"));
+ assert!(!body.to_string().contains("v1:"));
- db::update_settings(&pool, 1, true, None, None, Some(None))
- .await
- .unwrap();
+ assert_eq!(
+ patch(1, json!({ "digest_webhook_url": null })).await,
+ StatusCode::OK
+ );
let i = db::installation(&pool, 1).await.unwrap().unwrap();
- assert!(i.digest_webhook_url.is_none() && i.digest_sent_at.is_none());
- // `None` leaves the digest unchanged.
- db::update_settings(&pool, 2, true, None, None, Some(Some(url)))
- .await
- .unwrap();
- db::update_settings(&pool, 2, false, None, None, None)
- .await
- .unwrap();
+ assert!(i.digest_webhook_sealed.is_none() && i.digest_webhook_host.is_none());
+ assert!(i.digest_sent_at.is_none());
+ // Leaving the field out keeps the digest.
+ assert_eq!(
+ patch(2, json!({ "digest_webhook_url": url })).await,
+ StatusCode::OK
+ );
+ assert_eq!(patch(2, json!({ "enabled": false })).await, StatusCode::OK);
let i = db::installation(&pool, 2).await.unwrap().unwrap();
- assert_eq!(i.digest_webhook_url.as_deref(), Some(url));
+ assert!(i.digest_webhook_sealed.is_some());
}
diff --git a/docs/configuration.md b/docs/configuration.md
index 869408d..79778df 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -185,6 +185,9 @@ same window, so no alert change is lost.
Dismissed alerts are not counted. This is GitHub's alert inventory, not a full security scan.
The worker checks for due digests every 10 minutes. After a failed send it tries again every hour.
+The database stores the webhook URL encrypted with a key derived from the GitHub App private key.
+After you rotate that private key, the worker cannot read stored URLs. The settings page then shows
+an error. Enter the webhook URL again to fix it.
The settings page shows the last send and the last error.
The GitHub App needs the read-only **Dependabot alerts** permission. See
[Enable the vulnerability panel](github-app.md#enable-the-vulnerability-panel-on-an-existing-app).
diff --git a/docs/security.md b/docs/security.md
index 028a460..4d2113a 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -183,8 +183,12 @@ Model IDs that contain a known secret value are dropped. Codex list runs receive
The weekly security digest is opt-in. It sends repository names, package names, and
advisory IDs from the worker to the webhook host that an admin chose. That host and
its chat service receive this data.
-The webhook URL works like a password. The database stores it in plain text.
-The API never returns it, and error messages never contain it.
+The webhook URL works like a password. The database stores it sealed with AES-256-GCM.
+The key comes from the GitHub App private key through HKDF-SHA256. The database never holds that key,
+so a database dump or backup alone does not reveal the URL. The installation ID is bound to each sealed value.
+A host that holds both the database and the App private key can still read the URL.
+The database keeps the webhook host in plain text for display.
+The API never returns the URL, and error messages never contain it.
The worker sends one HTTPS request to port 443 and follows no redirects.
It resolves the host once and refuses the send when any address is private, loopback,
link-local, shared, reserved, or otherwise not public. It then connects to a checked address.
diff --git a/migrations/0004_usage_health_digest.sql b/migrations/0004_usage_health_digest.sql
index 612db77..6f26c0f 100644
--- a/migrations/0004_usage_health_digest.sql
+++ b/migrations/0004_usage_health_digest.sql
@@ -15,10 +15,12 @@ ALTER TABLE agent_models
ALTER COLUMN checked_at SET NOT NULL,
ALTER COLUMN checked_at SET DEFAULT now();
--- Opt-in weekly security digest. The webhook URL is a secret: the API never
--- returns it.
+-- Opt-in weekly security digest. The webhook URL is a secret. It is stored
+-- sealed with AES-256-GCM under a key derived from the GitHub App private
+-- key, which the database never holds. The host is kept for display.
ALTER TABLE installations
- ADD COLUMN digest_webhook_url TEXT,
+ ADD COLUMN digest_webhook_sealed TEXT,
+ ADD COLUMN digest_webhook_host TEXT,
ADD COLUMN digest_sent_at TIMESTAMPTZ,
ADD COLUMN digest_attempted_at TIMESTAMPTZ,
ADD COLUMN digest_error TEXT;
diff --git a/web/components/settings-form.tsx b/web/components/settings-form.tsx
index 568baf9..c4787cf 100644
--- a/web/components/settings-form.tsx
+++ b/web/components/settings-form.tsx
@@ -265,7 +265,7 @@ export function SettingsForm({
: ""}
Use an HTTPS Slack or Mattermost incoming webhook, or a
Discord webhook URL that ends in /slack. CroCode stores the URL
- as a secret and never shows it again.
+ encrypted and never shows it again.
)}
diff --git a/web/lib/docs-content.tsx b/web/lib/docs-content.tsx
index 1a2468b..6b8e98d 100644
--- a/web/lib/docs-content.tsx
+++ b/web/lib/docs-content.tsx
@@ -277,7 +277,7 @@ export const DOCS_CONTENT: Record = {
[critical, "15 days"], [high, "30 days"],
[medium, "90 days"], [low, "180 days"],
]} />
-
The message names repositories, packages, and CVE or GHSA IDs. It leaves GitHub. The API never returns the webhook URL. The worker sends only to public addresses on port 443 and follows no redirects. After a failed send it tries again every hour.
+
The message names repositories, packages, and CVE or GHSA IDs. It leaves GitHub. The database stores the webhook URL encrypted with a key derived from the GitHub App private key, and the API never returns it. After a private key rotation, enter the URL again. The worker sends only to public addresses on port 443 and follows no redirects. After a failed send it tries again every hour.