Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ duplicate comments.
| `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. |
| Worker container isolation | Implemented | `worker.backend = "container"` runs each attempt in a fresh hardened container (read-only rootfs, cap-drop ALL, cpu/memory/pids/tmpfs/network limits, env-only token injection, kill-by-name on timeout); hosted posture refuses host execution without explicit opt-in — see [docs/container-isolation.md](docs/container-isolation.md). |
| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. |
| CovenCave task polling | Implemented | Task API served from the durable store, survives restarts, and is gated by the tenant boundary — `token` mode fails closed, tenant tokens are installation-scoped, and every read is audited (see [docs/security.md](docs/security.md)). |
| CovenCave task polling | Implemented | Task API served from the durable store, survives restarts, and is gated by the tenant boundary — `token` mode fails closed, tenant tokens are installation-scoped, and authenticated reads are audited (see [docs/security.md](docs/security.md)). |
| Cave oversight dashboard | Implemented | Tenant-scoped data behind the four Cave views: task history (`/api/github/tasks`), usage (`/api/github/usage`), familiar routing (`/api/github/routing`), and a task-lifecycle audit stream (`/api/github/audit`) — acceptance, execution/retries/timeout, and PR creation. |
| Durable queue / task store | Implemented | Deliveries deduplicated by `X-GitHub-Delivery` before GitHub hears success; the SQLite `tasks` table is the queue (atomic claims, no drop path) and interrupted work is requeued at startup ([design](docs/durable-task-store.md)). |
| Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). |
Expand Down
63 changes: 15 additions & 48 deletions crates/webhook/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,14 @@ pub async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
/// tenant boundary (issue #3). `token` mode fails closed; a tenant token sees
/// only its own installation (optionally narrowed to repositories); the
/// service token — and `open` mode, for local development — see everything.
/// Every read lands in the audit trail.
/// Authenticated reads land in the durable audit trail. Rejected requests are
/// logged without touching the shared store so unauthenticated traffic cannot
/// exhaust or contend on it.
pub async fn list_tasks(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
let action = "list_tasks";
let (caller, scope) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
if let Err(e) = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await
{
error!("api audit write failed: {e:#}");
}
warn!(action, "unauthorized API request denied");
// Fail closed with a body that reveals nothing about what exists.
return (
StatusCode::UNAUTHORIZED,
Expand Down Expand Up @@ -135,18 +131,13 @@ pub async fn list_tasks(State(state): State<AppState>, headers: HeaderMap) -> im
/// GET /api/github/memory — memory activity for the tenant boundary (issue #6),
/// so a customer can inspect what memory a familiar read from and attempted to
/// write to their repositories. Same auth as `list_tasks`: `token` mode fails
/// closed, a tenant sees only its own installation, and every read is audited.
/// closed, a tenant sees only its own installation, and every authenticated
/// read is audited.
pub async fn list_memory(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
let action = "list_memory";
let (caller, scope) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
if let Err(e) = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await
{
error!("api audit write failed: {e:#}");
}
warn!(action, "unauthorized API request denied");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "unauthorized"})),
Expand Down Expand Up @@ -222,13 +213,7 @@ pub async fn revoke_memory(
let action = "revoke_memory";
let (caller, installation_id) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
if let Err(e) = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await
{
error!("api audit write failed: {e:#}");
}
warn!(action, "unauthorized API request denied");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "unauthorized"})),
Expand Down Expand Up @@ -308,13 +293,7 @@ pub async fn usage(State(state): State<AppState>, headers: HeaderMap) -> impl In
let action = "usage";
let (caller, scope) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
if let Err(e) = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await
{
error!("api audit write failed: {e:#}");
}
warn!(action, "unauthorized API request denied");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "unauthorized"})),
Expand Down Expand Up @@ -375,13 +354,7 @@ pub async fn audit(State(state): State<AppState>, headers: HeaderMap) -> impl In
let action = "audit";
let (caller, scope) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
if let Err(e) = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await
{
error!("api audit write failed: {e:#}");
}
warn!(action, "unauthorized API request denied");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "unauthorized"})),
Expand Down Expand Up @@ -444,10 +417,7 @@ pub async fn routing(
let action = "routing";
let (caller, installation_id) = match authorize_api(&state.config.api, &headers) {
ApiCaller::Denied => {
let _ = state
.store
.record_api_read("anonymous", "none", action, "denied")
.await;
warn!(action, "unauthorized API request denied");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"ok": false, "error": "unauthorized"})),
Expand Down Expand Up @@ -2376,13 +2346,10 @@ mod tenancy_tests {
}

let audit = state.store.api_audit_entries().await.expect("audit");
assert_eq!(audit.len(), 2);
for (caller, scope, action, result) in audit {
assert_eq!(caller, "anonymous");
assert_eq!(scope, "none");
assert_eq!(action, "list_tasks");
assert_eq!(result, "denied");
}
assert!(
audit.is_empty(),
"unauthenticated requests must not write to the durable store: {audit:?}"
);
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Tenant-scoped data:
- Task history, status, branch, PR, and Check Run links.
- Optional familiar memory, if enabled by the customer.

The public task API must not return cross-installation data. `/api/github/tasks` is gated by the `[api]` config section (issue #3): `mode = "open"` keeps it unauthenticated for local development and Cave polling — never expose that publicly — while `mode = "token"` fails closed and requires bearer tokens. A `service_token` grants the operator full visibility; each `[[api.tenants]]` token is scoped server-side to one installation id (optionally narrowed to specific repositories). Unauthorized calls receive a uniform `401 unauthorized` that reveals nothing about existing data, and every read — allowed or denied — is recorded in the store's `api_audit` table with caller, scope, action, and result.
The public task API must not return cross-installation data. `/api/github/tasks` is gated by the `[api]` config section (issue #3): `mode = "open"` keeps it unauthenticated for local development and Cave polling — never expose that publicly — while `mode = "token"` fails closed and requires bearer tokens. A `service_token` grants the operator full visibility; each `[[api.tenants]]` token is scoped server-side to one installation id (optionally narrowed to specific repositories). Unauthorized calls receive a uniform `401 unauthorized` that reveals nothing about existing data and are emitted to the application log without writing to the shared durable store. Authenticated reads are recorded in the store's `api_audit` table with caller, scope, action, and result.

## Model and Memory Boundaries

Expand Down
Loading