Skip to content

Commit 8bf9036

Browse files
committed
feat(admission): add integrity-linked audit trail
1 parent d00dda6 commit 8bf9036

15 files changed

Lines changed: 1126 additions & 13 deletions

File tree

ARCHITECTURE.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ legacy reference implementation.
3939
requests only.
4040
- `github/client.rs` centralizes GitHub I/O, retry classification, rate limits, and exact-SHA branch
4141
creation.
42-
- `orchestrator/memory.rs` stores local outcomes and short-lived working context in SQLite.
42+
- `orchestrator/memory.rs` stores local outcomes, short-lived working context, and integrity-linked
43+
admission decision receipts in SQLite.
4344
- `web/` is an observability API. It does not claim to queue runs; public binds require an API key.
4445
- `mcp/` exposes tools over stdio with read-only defaults.
4546
- `cli/commands/demo.rs` exercises the production consent, admission, and evidence policy against
@@ -58,8 +59,16 @@ Every external contribution must satisfy all of these conditions:
5859
4. Paths and patch size fit the repository's declared scope and built-in protected-path rules.
5960
5. Required validation checks pass and are recorded in an expiring `EvidenceCapsule`.
6061
6. A human reviews every proposed byte and approves the exact candidate interactively.
61-
7. Evidence and live maintainer consent are revalidated at the write boundary.
62-
8. The branch is created from the attested SHA and the pull request is opened as a draft.
62+
7. The terminal decision is appended to the local audit ledger; an approval that cannot be recorded
63+
fails closed.
64+
8. Evidence and live maintainer consent are revalidated at the write boundary.
65+
9. The branch is created from the attested SHA and the pull request is opened as a draft.
66+
67+
Blocked attempts are recorded at the capability, permission, consent, base-revision, evidence, or
68+
admission boundary. Human rejection, skip, approval, and review errors are recorded separately. The
69+
ledger contains hashes and scope metadata rather than generated file contents. Its linked receipts
70+
detect accidental local mutation and ordering breaks, but are not signatures and do not protect
71+
against an attacker who can replace the entire database and every external checkpoint.
6372

6473
There is intentionally no pipeline API that pre-approves the human gate. ContribAI never signs a
6574
CLA on behalf of a person. Details are in [docs/CONSENT_PROTOCOL.md](docs/CONSENT_PROTOCOL.md) and

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Added a static-only Vercel deployment for the public onboarding site, including security response
1212
headers, deployment checks, maintainer setup documentation, and an evidence-backed Vercel OSS
1313
Program application worksheet.
14+
- Added an append-only local admission audit ledger with SHA-256-linked receipts for approved,
15+
blocked, rejected, skipped, and failed admission attempts. The ledger stores candidate hashes
16+
and scope metadata, never generated file contents.
17+
- Added `contribai admissions` with repository/decision filters, JSON output, and full-chain
18+
verification; added the protected read-only `/api/admissions` endpoint and the read-only MCP
19+
`list_admission_audit` tool.
20+
- Added Prometheus admission decision counters by terminal result.
21+
22+
### Changed
23+
- Approved submissions now fail closed if their admission decision cannot be persisted to the local
24+
audit ledger.
1425

1526
### Security
1627
- Updated `h2` to 0.4.16 for RUSTSEC-2026-0258 and replaced the yanked `chacha20` 0.10.1 lockfile

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,20 @@ failed, cross-repository, scope-mismatched, or fingerprint-mismatched capsules a
220220
maintainer consent. It remains a local audit receipt, not a substitute for CI, code review,
221221
provenance attestation, or maintainer judgment.
222222

223+
Every terminal admission attempt is also appended to the local admission audit ledger. Records
224+
contain the candidate fingerprint, permit/base SHA when available, scope, checks, decision stage,
225+
and reason—never generated file contents. Each record's SHA-256 receipt binds the preceding receipt,
226+
so accidental edits or broken ordering are detectable when the complete local chain is verified:
227+
228+
```bash
229+
contribai admissions
230+
contribai admissions --repository owner/repo --decision blocked
231+
contribai admissions --json
232+
```
233+
234+
The hash chain is a local integrity check, not a signature or remote attestation. An approved
235+
submission fails closed if its audit decision cannot be persisted.
236+
223237
## Capabilities
224238

225239
- Tree-sitter analysis for 13 languages, with additional fallback language mappings
@@ -231,6 +245,8 @@ provenance attestation, or maintainer judgment.
231245
- Ratatui interface, read-only-by-default MCP server, and authenticated web dashboard
232246
- Draft PR lifecycle and explicit patrol response capability
233247
- Offline admission/evidence demo with a protected-path fail-closed probe
248+
- Integrity-linked local admission audit through CLI, loopback/API-key-governed web API, MCP, and
249+
Prometheus
234250

235251
The Python implementation under `python/` is legacy reference code. Rust under
236252
`crates/contribai-rs/` is the maintained implementation.
@@ -247,6 +263,7 @@ The Python implementation under `python/` is legacy reference code. Rust under
247263
| `solve <url>` | Analyze issues | `--submit` |
248264
| `watchlist` | Assess configured repositories | `--submit` |
249265
| `patrol` | Read review state | `--respond` |
266+
| `admissions [--json]` | Verify and inspect local admission decisions | None |
250267
| `mcp-server` | Advertise read-only tools | `--allow-writes` (never PR creation or CLA signing) |
251268

252269
Run `contribai <command> --help` for the complete interface.
@@ -276,6 +293,7 @@ The main Rust modules are:
276293

277294
- `core/admission.rs` — consent, permits, scope enforcement, evidence capsules
278295
- `orchestrator/pipeline.rs` — read and write capability orchestration
296+
- `orchestrator/memory.rs` — outcomes, context, and append-only admission audit receipts
279297
- `analysis/` — AST intelligence, triage, repository context, progressive skills
280298
- `generator/` — candidate generation, validation, risk, scoring, self-review
281299
- `github/` — resilient GitHub REST and GraphQL client
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Inspect the local append-only admission decision ledger.
2+
3+
use anyhow::{bail, Context, Result};
4+
use colored::Colorize;
5+
use serde::Serialize;
6+
7+
use crate::cli::{create_memory, load_config};
8+
use contribai::core::admission::{
9+
AdmissionAuditDecision, AdmissionAuditRecord, AdmissionAuditVerification,
10+
};
11+
12+
#[derive(Serialize)]
13+
struct AdmissionAuditOutput {
14+
schema_version: u8,
15+
chain: AdmissionAuditVerification,
16+
records: Vec<AdmissionAuditRecord>,
17+
}
18+
19+
/// List and verify locally recorded admission decisions. This command performs no network access.
20+
pub fn run_admissions(
21+
config_path: Option<&str>,
22+
repository: Option<&str>,
23+
decision: Option<&str>,
24+
limit: usize,
25+
json: bool,
26+
) -> Result<()> {
27+
if !(1..=1000).contains(&limit) {
28+
bail!("admission audit limit must be between 1 and 1000");
29+
}
30+
let normalized_decision = decision.map(|value| value.trim().to_ascii_lowercase());
31+
if let Some(value) = normalized_decision.as_deref() {
32+
if !AdmissionAuditDecision::is_valid_filter(value) {
33+
bail!(
34+
"invalid admission decision {value:?}; expected approved, blocked, rejected, skipped, or error"
35+
);
36+
}
37+
}
38+
let normalized_repository = repository.map(str::trim).filter(|value| !value.is_empty());
39+
let config = load_config(config_path)?;
40+
let memory = create_memory(&config)?;
41+
let chain = memory
42+
.verify_admission_audit_chain()
43+
.context("verifying the admission audit chain")?;
44+
let records = memory
45+
.get_admission_audits(normalized_repository, normalized_decision.as_deref(), limit)
46+
.context("reading the admission audit ledger")?;
47+
let output = AdmissionAuditOutput {
48+
schema_version: 1,
49+
chain,
50+
records,
51+
};
52+
53+
if json {
54+
println!("{}", serde_json::to_string_pretty(&output)?);
55+
} else {
56+
print_pretty(
57+
&output,
58+
normalized_repository,
59+
normalized_decision.as_deref(),
60+
);
61+
}
62+
63+
if !output.chain.valid {
64+
bail!("local admission audit chain verification failed");
65+
}
66+
Ok(())
67+
}
68+
69+
fn print_pretty(output: &AdmissionAuditOutput, repository: Option<&str>, decision: Option<&str>) {
70+
println!("{}", "ContribAI Admission Audit".cyan().bold());
71+
println!("{}", "━".repeat(68).dimmed());
72+
let chain_status = if output.chain.valid {
73+
"VALID".green().bold()
74+
} else {
75+
"INVALID".red().bold()
76+
};
77+
println!(
78+
" Chain: {} ({} records checked)",
79+
chain_status, output.chain.records_checked
80+
);
81+
if let Some(value) = repository {
82+
println!(" Repository filter: {}", value.cyan());
83+
}
84+
if let Some(value) = decision {
85+
println!(" Decision filter: {}", value.cyan());
86+
}
87+
println!();
88+
89+
if output.records.is_empty() {
90+
println!(
91+
" {}",
92+
"No admission decisions match the current filter.".dimmed()
93+
);
94+
return;
95+
}
96+
97+
for record in &output.records {
98+
let decision = match record.decision {
99+
AdmissionAuditDecision::Approved => record.decision.as_str().green().bold(),
100+
AdmissionAuditDecision::Blocked => record.decision.as_str().yellow().bold(),
101+
AdmissionAuditDecision::Rejected | AdmissionAuditDecision::Error => {
102+
record.decision.as_str().red().bold()
103+
}
104+
AdmissionAuditDecision::Skipped => record.decision.as_str().dimmed().bold(),
105+
};
106+
println!(
107+
" {} {} {} [{}]",
108+
record.recorded_at.format("%Y-%m-%d %H:%M"),
109+
record.repository.cyan(),
110+
record.stage.as_str().dimmed(),
111+
decision
112+
);
113+
println!(
114+
" Receipt {} Scope {} files / {} changed lines",
115+
short_receipt(&record.receipt).dimmed(),
116+
record.file_count,
117+
record.changed_lines
118+
);
119+
if let Some(permit) = &record.permit_id {
120+
println!(" Permit {}", permit.dimmed());
121+
}
122+
println!(" {}", record.reason);
123+
println!();
124+
}
125+
}
126+
127+
fn short_receipt(receipt: &str) -> &str {
128+
receipt.get(..12).unwrap_or(receipt)
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
135+
#[test]
136+
fn decision_filter_is_strict_and_case_normalizable() {
137+
assert!(AdmissionAuditDecision::is_valid_filter("blocked"));
138+
assert!(!AdmissionAuditDecision::is_valid_filter("allow"));
139+
assert_eq!("APPROVED".to_ascii_lowercase(), "approved");
140+
}
141+
142+
#[test]
143+
fn receipt_preview_is_safe_for_short_values() {
144+
assert_eq!(short_receipt("abcdef"), "abcdef");
145+
assert_eq!(short_receipt("0123456789abcdef"), "0123456789ab");
146+
}
147+
}

crates/contribai-rs/src/cli/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! CLI command handlers — each subcommand has its own module.
22
3+
pub mod admissions;
34
pub mod analyze;
45
pub mod cache_clear;
56
pub mod cache_stats;

crates/contribai-rs/src/cli/mod.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,29 @@ mod capability_tests {
9191
})
9292
));
9393
}
94+
95+
#[test]
96+
fn admissions_is_read_only_and_supports_machine_filters() {
97+
let cli = Cli::try_parse_from([
98+
"contribai",
99+
"admissions",
100+
"--repository",
101+
"owner/repo",
102+
"--decision",
103+
"blocked",
104+
"--json",
105+
])
106+
.expect("valid CLI");
107+
assert!(matches!(
108+
cli.command,
109+
Some(Commands::Admissions {
110+
repository: Some(repository),
111+
decision: Some(decision),
112+
json: true,
113+
..
114+
}) if repository == "owner/repo" && decision == "blocked"
115+
));
116+
}
94117
}
95118

96119
#[derive(Subcommand)]
@@ -272,6 +295,25 @@ enum Commands {
272295
limit: usize,
273296
},
274297

298+
/// Inspect and verify the local admission decision audit trail
299+
Admissions {
300+
/// Filter by exact owner/repository name
301+
#[arg(short, long)]
302+
repository: Option<String>,
303+
304+
/// Filter by decision: approved, blocked, rejected, skipped, or error
305+
#[arg(short, long)]
306+
decision: Option<String>,
307+
308+
/// Maximum records to display
309+
#[arg(short, long, default_value = "20")]
310+
limit: usize,
311+
312+
/// Emit a machine-readable report with full-chain verification status
313+
#[arg(long)]
314+
json: bool,
315+
},
316+
275317
/// Show current configuration
276318
Config,
277319

@@ -525,6 +567,18 @@ impl Cli {
525567
Commands::Status { filter, limit } => {
526568
commands::status::run_status(self.config.as_deref(), filter, limit).await
527569
}
570+
Commands::Admissions {
571+
repository,
572+
decision,
573+
limit,
574+
json,
575+
} => commands::admissions::run_admissions(
576+
self.config.as_deref(),
577+
repository.as_deref(),
578+
decision.as_deref(),
579+
limit,
580+
json,
581+
),
528582
Commands::Version => {
529583
print_banner();
530584
println!("contribai {} (Rust)", contribai::VERSION);

0 commit comments

Comments
 (0)