diff --git a/Cargo.lock b/Cargo.lock index d27f698..4a25162 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,8 @@ dependencies = [ "getrandom", "hex", "hmac", + "serde", + "serde_json", "sha2", ] @@ -323,6 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/README.md b/README.md index 3fd55a8..2dac8c3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # agentic-git +> Embedding agentic-git in your own orchestrator? Start with the [Embedder Contract v1](docs/embedder-contract-v1.md). + [![ci](https://github.com/suzuke/agentic-git/actions/workflows/ci.yml/badge.svg)](https://github.com/suzuke/agentic-git/actions/workflows/ci.yml) **A guarded, transparent `git` for AI coding agents.** diff --git a/README.zh-TW.md b/README.zh-TW.md index 9da6ca0..cbd27fd 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,5 +1,7 @@ # agentic-git +> 要把 agentic-git 嵌入你自己的 orchestrator?從 [Embedder Contract v1](docs/embedder-contract-v1.md) 開始。 + **給 AI coding agent 的透明防護版 `git`。** `agentic-git` 是一個偽裝成 `git`、放在 agent PATH 上的 Rust binary。agent diff --git a/crates/agentic-git-core/Cargo.toml b/crates/agentic-git-core/Cargo.toml index 2de143d..b77f3fc 100644 --- a/crates/agentic-git-core/Cargo.toml +++ b/crates/agentic-git-core/Cargo.toml @@ -9,6 +9,8 @@ repository.workspace = true [dependencies] getrandom = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" hex = "0.4" hmac = "0.13" sha2 = "0.11" diff --git a/crates/agentic-git-core/src/binding.rs b/crates/agentic-git-core/src/binding.rs new file mode 100644 index 0000000..04fc01a --- /dev/null +++ b/crates/agentic-git-core/src/binding.rs @@ -0,0 +1,189 @@ +//! #26 Embedder Contract v1: the typed, versioned binding document. +//! +//! This is the typed representation of `runtime//binding.json` shared +//! by the reference `agentic-git run` writer and the shim reader. The agend +//! daemon does NOT link this crate — its zero-daemon-change compatibility is +//! SCHEMA compatibility, pinned by the golden `binding-agend-v1.json` +//! fixture. A second orchestrator signs and writes exactly this document — +//! see `docs/embedder-contract-v1.md`. +//! +//! Version policy (v1 freeze): a document with NO `version` field is a +//! legacy v1 (agend zero-daemon-change adoption); `version: 1` is v1; any +//! OTHER version fails closed with [`BindingDecodeError::UnsupportedVersion`] +//! — a future v2 document may carry authority semantics this reader cannot +//! enforce, so "treat as unbound" is the only safe disposition. Unknown +//! FIELDS within v1 are the bounded extension surface: they are preserved +//! round-trip in [`BindingV1::extra`] and ignored by readers. + +use serde::{Deserialize, Serialize}; + +/// The binding document format version this crate reads and writes. +/// Mirrors [`crate::integrity_core::BINDING_FORMAT_VERSION`] (the original +/// constant stays for compatibility; this module is the typed owner). +pub const BINDING_FORMAT_VERSION: u32 = crate::integrity_core::BINDING_FORMAT_VERSION; + +fn default_version() -> u32 { + BINDING_FORMAT_VERSION +} + +/// The v1 binding document. All identity fields are optional at the codec +/// layer — the *bound* predicate (`task_id` present + worktree exists) is +/// reader policy, not codec policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BindingV1 { + /// Format version; absent in legacy documents (decodes as v1). + #[serde(default = "default_version")] + pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + /// The bound predicate's anchor: present ⇒ the agent is bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issued_at: Option, + /// Bounded extension surface: unknown v1 fields are preserved verbatim + /// (round-trip) and ignored by readers. A field a reader must UNDERSTAND + /// to stay safe belongs in v2, not here. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl Default for BindingV1 { + fn default() -> Self { + Self { + version: BINDING_FORMAT_VERSION, + agent: None, + task_id: None, + branch: None, + worktree: None, + source_repo: None, + issued_at: None, + extra: serde_json::Map::new(), + } + } +} + +/// Decode failure. Every variant must be treated fail-closed (unbound) by +/// binding readers; `UnsupportedVersion` deserves a LOUD diagnostic (scheme +/// skew between signer and reader builds), mirroring the HMAC +/// `VerifyError::UnsupportedScheme` posture. +#[derive(Debug)] +pub enum BindingDecodeError { + /// The document declares a version this crate does not implement. + /// `found` is 0 when the field is present but not a positive integer. + UnsupportedVersion { found: u64 }, + Parse(serde_json::Error), +} + +impl std::fmt::Display for BindingDecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BindingDecodeError::UnsupportedVersion { found } => write!( + f, + "binding format version {found} is not supported (this build implements v{BINDING_FORMAT_VERSION})" + ), + BindingDecodeError::Parse(e) => write!(f, "binding parse error: {e}"), + } + } +} + +impl std::error::Error for BindingDecodeError {} + +/// Decode a binding document. The version gate runs FIRST on the raw value so +/// an unsupported version fails closed even when other fields are malformed. +pub fn decode(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(BindingDecodeError::Parse)?; + if let Some(version) = value.get("version") { + let found = version.as_u64().unwrap_or(0); + if found != u64::from(BINDING_FORMAT_VERSION) { + return Err(BindingDecodeError::UnsupportedVersion { found }); + } + } + serde_json::from_value(value).map_err(BindingDecodeError::Parse) +} + +/// Encode a binding document (pretty-printed — the on-disk shape the +/// reference `agentic-git run` writer signs; schema-compatible with what the +/// agend daemon writes independently). +pub fn encode(binding: &BindingV1) -> Result { + serde_json::to_string_pretty(binding) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_v1_roundtrips_identity_fields() { + let doc = BindingV1 { + agent: Some("ag".into()), + task_id: Some("t-1".into()), + branch: Some("feat/x".into()), + worktree: Some("/tmp/wt".into()), + source_repo: Some("/tmp/src".into()), + issued_at: Some("2026-07-19T00:00:00Z".into()), + ..Default::default() + }; + let json = encode(&doc).unwrap(); + let back = decode(&json).unwrap(); + assert_eq!(back, doc); + assert_eq!(back.version, BINDING_FORMAT_VERSION); + } + + #[test] + fn decode_missing_version_is_legacy_v1() { + let b = decode(r#"{"task_id":"t-legacy","branch":"feat/l"}"#).unwrap(); + assert_eq!(b.version, BINDING_FORMAT_VERSION); + assert_eq!(b.task_id.as_deref(), Some("t-legacy")); + } + + #[test] + fn decode_unsupported_version_fails_closed() { + let err = decode(r#"{"version":2,"task_id":"t-f"}"#).unwrap_err(); + match err { + BindingDecodeError::UnsupportedVersion { found } => assert_eq!(found, 2), + other => panic!("expected UnsupportedVersion, got {other}"), + } + } + + #[test] + fn decode_non_numeric_version_fails_closed() { + let err = decode(r#"{"version":"x","task_id":"t-f"}"#).unwrap_err(); + assert!(matches!( + err, + BindingDecodeError::UnsupportedVersion { found: 0 } + )); + } + + #[test] + fn unknown_fields_are_bounded_extensions_preserved_roundtrip() { + let json = r#"{"version":1,"task_id":"t-1","custom_lease":"abc","nested":{"k":1}}"#; + let doc = decode(json).unwrap(); + assert_eq!(doc.extra.get("custom_lease").and_then(|v| v.as_str()), Some("abc")); + let re = encode(&doc).unwrap(); + let back = decode(&re).unwrap(); + assert_eq!(back, doc, "extension fields must survive a round-trip"); + } + + #[test] + fn golden_fixtures_decode() { + for (rel, task_id) in [ + ("../agentic-git/tests/fixtures/binding-agend-v1.json", "t-20260719-golden-agend"), + ("../agentic-git/tests/fixtures/binding-run-v1.json", "run-session-1789000000"), + ] { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + let content = std::fs::read_to_string(&p) + .unwrap_or_else(|e| panic!("golden fixture {rel}: {e}")); + let doc = decode(&content).unwrap_or_else(|e| panic!("golden {rel}: {e}")); + assert_eq!(doc.version, 1, "golden {rel}"); + assert_eq!(doc.task_id.as_deref(), Some(task_id), "golden {rel}"); + } + } +} diff --git a/crates/agentic-git-core/src/lib.rs b/crates/agentic-git-core/src/lib.rs index b0e3f39..bbd3099 100644 --- a/crates/agentic-git-core/src/lib.rs +++ b/crates/agentic-git-core/src/lib.rs @@ -4,5 +4,6 @@ //! links the EXACT same verifier/predicate source as the shim and no //! algorithm or ref-set drift is possible. +pub mod binding; pub mod integrity_core; pub mod protected_refs; diff --git a/crates/agentic-git/src/cli.rs b/crates/agentic-git/src/cli.rs index 6857b99..4b3f92e 100644 --- a/crates/agentic-git/src/cli.rs +++ b/crates/agentic-git/src/cli.rs @@ -13,7 +13,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; -use agentic_git_core::integrity_core; +use agentic_git_core::{binding, integrity_core}; pub fn cli_main() -> ! { let args: Vec = std::env::args().skip(1).collect(); @@ -935,23 +935,25 @@ fn run_cmd(raw_args: &[String]) -> ! { format!("agent={agent}\nleased_at={issued_at}\n"), ); - // Step 5: binding (schema v1) + HMAC sidecar. - let binding = serde_json::json!({ - "version": 1, - "agent": agent, - "task_id": format!( + // Step 5: binding (schema v1) + HMAC sidecar — built through the + // core-owned typed codec (#26), so the reference writer and the shim + // reader share one representation by construction. + let binding_doc = binding::BindingV1 { + agent: Some(agent.clone()), + task_id: Some(format!( "run-session-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) - ), - "branch": branch, - "issued_at": issued_at, - "worktree": wt_path.to_string_lossy(), - "source_repo": source_repo.to_string_lossy(), - }); - let content = match serde_json::to_string_pretty(&binding) { + )), + branch: Some(branch.clone()), + issued_at: Some(issued_at.clone()), + worktree: Some(wt_path.to_string_lossy().into_owned()), + source_repo: Some(source_repo.to_string_lossy().into_owned()), + ..Default::default() + }; + let content = match binding::encode(&binding_doc) { Ok(c) => c, Err(e) => { eprintln!("agentic-git: run: serialize binding: {e}"); diff --git a/crates/agentic-git/src/lib.rs b/crates/agentic-git/src/lib.rs index 5e20c40..95b9495 100644 --- a/crates/agentic-git/src/lib.rs +++ b/crates/agentic-git/src/lib.rs @@ -21,7 +21,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; // #2550 W4) live in the `agentic-git-core` crate so any embedding system — // this binary, a daemon, tests — links the EXACT same verifier/predicate // source and no signer/verifier or ref-set drift is possible. -use agentic_git_core::{integrity_core, protected_refs}; +use agentic_git_core::{binding, integrity_core, protected_refs}; /// #1504 L3: max times the shim may re-enter before hard-failing. Healthy /// operation never exceeds 1 (real git ≠ shim → no re-entry), so a small cap @@ -577,14 +577,27 @@ fn read_binding(home: &str, agent: &str) -> Binding { if !verify_sidecar(home, content.as_bytes(), &tag) { return Binding::default(); } - let v: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, + // #26: decode through the core-owned typed v1 codec — the same + // representation the reference `run` writer (and the agend daemon) sign. + // An UNSUPPORTED version is surfaced LOUD (mirrors the HMAC scheme-skew + // posture) and fails closed to unbound: a v2 document may carry authority + // semantics this shim cannot enforce. A plain parse failure stays the + // silent unbound fail-safe it always was. + let doc = match binding::decode(&content) { + Ok(doc) => doc, + Err(binding::BindingDecodeError::UnsupportedVersion { found }) => { + eprintln!( + "agentic-git: binding format version {found} is not supported by this shim (implements v{}) — signer and shim were built from different contract versions; rebuild-together / rebind.", + binding::BINDING_FORMAT_VERSION + ); + return Binding::default(); + } Err(_) => return Binding::default(), // parse failure = unbound (fail-safe) }; let b = Binding { - task_id: v["task_id"].as_str().map(String::from), - branch: v["branch"].as_str().map(String::from), - worktree: v["worktree"].as_str().map(String::from), + task_id: doc.task_id, + branch: doc.branch, + worktree: doc.worktree, }; // P0-1.6: orphan binding defense. // If binding points to a worktree path that no longer exists (e.g. operator @@ -1919,27 +1932,15 @@ fn log_nonagent_canonical_checkout(home: &str, agent: &str, args: &[String]) { .unwrap_or_default(); let ppid = parent_pid(); let ancestry = process_ancestry(8); - let event = serde_json::json!({ - "kind": "git_event", - "event": "canonical_passthrough_checkout", - "agent": agent, - "subcommand": subcmd, - "target_branch": target_branch, - "argv": args, - "cwd": cwd, - "ppid": ppid, - "process_ancestry": ancestry, - "timestamp": chrono::Utc::now().to_rfc3339(), - }); - let events_path = PathBuf::from(home).join("fleet_events.jsonl"); - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(events_path) - { - use std::io::Write; - let _ = writeln!(f, "{event}"); - } + // #26: canonical disposition-bearing shape + the shared appender. + let mut extra = serde_json::Map::new(); + extra.insert("target_branch".into(), serde_json::json!(target_branch)); + extra.insert("argv".into(), serde_json::json!(args)); + extra.insert("cwd".into(), serde_json::json!(cwd)); + extra.insert("ppid".into(), serde_json::json!(ppid)); + extra.insert("process_ancestry".into(), serde_json::json!(ancestry)); + let event = build_git_event("canonical_passthrough_checkout", agent, subcmd, extra); + append_git_event(home, &event); eprintln!( "[agentic-git #2234] non-agent canonical-cwd {subcmd} passthrough (HEAD-touching): target={target_branch} ppid={ppid} cwd={cwd} ancestry={ancestry:?}" ); @@ -1957,18 +1958,14 @@ fn build_bypass_audit_event( ancestry: &[String], bypass_layer: &str, ) -> serde_json::Value { - serde_json::json!({ - "kind": "git_event", - "event": "bypass_mutating_op", - "agent": agent, - "subcommand": subcmd, - "argv": args, - "cwd": cwd, - "ppid": ppid, - "process_ancestry": ancestry, - "bypass_layer": bypass_layer, - "timestamp": chrono::Utc::now().to_rfc3339(), - }) + // #26: canonical disposition-bearing shape (shared builder). + let mut extra = serde_json::Map::new(); + extra.insert("argv".into(), serde_json::json!(args)); + extra.insert("cwd".into(), serde_json::json!(cwd)); + extra.insert("ppid".into(), serde_json::json!(ppid)); + extra.insert("process_ancestry".into(), serde_json::json!(ancestry)); + extra.insert("bypass_layer".into(), serde_json::json!(bypass_layer)); + build_git_event("bypass_mutating_op", agent, subcmd, extra) } /// #2158: audit a SUB-AGENT's own `AGENTIC_GIT_BYPASS=1 git ` op — the @@ -1994,15 +1991,7 @@ fn log_bypass_mutating_op(home: &str, agent: &str, args: &[String]) { &ancestry, active_bypass_layer(), ); - let events_path = PathBuf::from(home).join("fleet_events.jsonl"); - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(events_path) - { - use std::io::Write; - let _ = writeln!(f, "{event}"); - } + append_git_event(home, &event); eprintln!( "[agentic-git #2158] AGENTIC_GIT_BYPASS mutating {subcmd} (stray-worktree vector): ppid={ppid} cwd={cwd} ancestry={ancestry:?}" ); @@ -2033,28 +2022,16 @@ fn log_init_heartbeat_forensics(home: &str, agent: &str, args: &[String]) { let ancestry = process_ancestry(8); let email = effective_git_email(&cwd).unwrap_or_default(); let has_allow_empty = args.iter().any(|a| a == "--allow-empty"); - let event = serde_json::json!({ - "kind": "git_event", - "event": "init_heartbeat_forensics", - "agent": agent, - "subcommand": "commit", - "argv": args, - "allow_empty": has_allow_empty, - "cwd": cwd, - "ppid": ppid, - "process_ancestry": ancestry, - "git_user_email": email, - "timestamp": chrono::Utc::now().to_rfc3339(), - }); - let events_path = PathBuf::from(home).join("fleet_events.jsonl"); - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(events_path) - { - use std::io::Write; - let _ = writeln!(f, "{event}"); - } + // #26: canonical disposition-bearing shape + the shared appender. + let mut extra = serde_json::Map::new(); + extra.insert("argv".into(), serde_json::json!(args)); + extra.insert("allow_empty".into(), serde_json::json!(has_allow_empty)); + extra.insert("cwd".into(), serde_json::json!(cwd)); + extra.insert("ppid".into(), serde_json::json!(ppid)); + extra.insert("process_ancestry".into(), serde_json::json!(ancestry)); + extra.insert("git_user_email".into(), serde_json::json!(email)); + let event = build_git_event("init_heartbeat_forensics", agent, "commit", extra); + append_git_event(home, &event); eprintln!( "[agentic-git #1463] init-heartbeat commit intercepted: agent={agent} email={email} ppid={ppid} cwd={cwd} ancestry={ancestry:?}" ); @@ -3256,12 +3233,60 @@ fn disposition_for(event_type: &str) -> Disposition { // #4: a snapshot failure is advisory, never terminal — the op still // ran (fail-open is the whole point); the agent should heed the // warning but is not blocked. - "cwd_worktree_drift" | "git_conflict" | "snapshot_failed" => Disposition::Warn, - "post_merge_cleanup_exempt" => Disposition::Info, + // #26: audited-bypass mutations and unattributed canonical HEAD-touches + // are advisory-noteworthy instrumentation, never terminal denials. + "cwd_worktree_drift" | "git_conflict" | "snapshot_failed" | "bypass_mutating_op" + | "canonical_passthrough_checkout" => Disposition::Warn, + // #26: heartbeat-pile forensics are routine instrumentation. + "post_merge_cleanup_exempt" | "init_heartbeat_forensics" => Disposition::Info, _ => Disposition::Deny, } } +/// #26: the canonical event-record builder — EVERY `fleet_events.jsonl` +/// record carries `kind`/`event`/`disposition`/`agent`/`subcommand`/ +/// `timestamp` (disposition via the single-source [`disposition_for`]); +/// callers contribute event-specific fields through `extra`. Pure, so each +/// writer's json SHAPE stays unit-testable without touching the live process. +fn build_git_event( + event_type: &str, + agent: &str, + subcmd: &str, + extra: serde_json::Map, +) -> serde_json::Value { + // Canonical fields are AUTHORITATIVE: extras land first, the canonical + // envelope is written last so a caller-supplied key can never overwrite + // the routing fields (esp. `disposition` — the stop-vs-continue axis). + let mut map = extra; + map.insert("kind".into(), serde_json::json!("git_event")); + map.insert("event".into(), serde_json::json!(event_type)); + map.insert( + "disposition".into(), + serde_json::json!(disposition_for(event_type).as_str()), + ); + map.insert("agent".into(), serde_json::json!(agent)); + map.insert("subcommand".into(), serde_json::json!(subcmd)); + map.insert( + "timestamp".into(), + serde_json::json!(chrono::Utc::now().to_rfc3339()), + ); + serde_json::Value::Object(map) +} + +/// #26: the single best-effort `fleet_events.jsonl` appender (never blocks; +/// callers `exec` real git immediately after). +fn append_git_event(home: &str, event: &serde_json::Value) { + let events_path = PathBuf::from(home).join("fleet_events.jsonl"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(events_path) + { + use std::io::Write; + let _ = writeln!(f, "{event}"); + } +} + /// Sprint 57 Wave 2 Track D: structured audit-event writer with an /// explicit event-type discriminator. Replaces the previous untyped /// `write_git_event` that hardcoded `event="deny"`. `event_type` is @@ -3281,27 +3306,12 @@ fn write_git_event_typed( target_branch: Option<&str>, detail: Option<&str>, ) { - let events_path = PathBuf::from(home).join("fleet_events.jsonl"); - let event = serde_json::json!({ - "kind": "git_event", - "event": event_type, - // #2379 ②: deny|warn|info — the agent's stop-vs-continue routing axis. - "disposition": disposition_for(event_type).as_str(), - "agent": agent, - "subcommand": subcmd, - "target_branch": target_branch, - "reason": detail, - "timestamp": chrono::Utc::now().to_rfc3339(), - }); - // Best-effort append (don't block on failure). - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(events_path) - { - use std::io::Write; - let _ = writeln!(f, "{}", event); - } + // #2379 ② / #26: disposition + shape come from the canonical builder. + let mut extra = serde_json::Map::new(); + extra.insert("target_branch".into(), serde_json::json!(target_branch)); + extra.insert("reason".into(), serde_json::json!(detail)); + let event = build_git_event(event_type, agent, subcmd, extra); + append_git_event(home, &event); } fn is_conflict_capable(subcmd: &str) -> bool { diff --git a/crates/agentic-git/src/tests.rs b/crates/agentic-git/src/tests.rs index f521dde..65e713f 100644 --- a/crates/agentic-git/src/tests.rs +++ b/crates/agentic-git/src/tests.rs @@ -817,6 +817,18 @@ fn disposition_for_covers_all_emitted_event_types_2379() { disposition_for("post_merge_cleanup_exempt"), Disposition::Info ); + // #26: the forensic/audit instrumentation events (explicit, not the + // fail-closed default) — advisory bypass/canonical-touch, routine + // heartbeat forensics. + assert_eq!(disposition_for("bypass_mutating_op"), Disposition::Warn); + assert_eq!( + disposition_for("canonical_passthrough_checkout"), + Disposition::Warn + ); + assert_eq!( + disposition_for("init_heartbeat_forensics"), + Disposition::Info + ); // Fail-closed default: an unrecognized event_type reads as terminal, not advisory. assert_eq!( disposition_for("some_future_unmapped_event"), @@ -4115,3 +4127,310 @@ fn submodule_unknown_flag_fail_closed_34() { ); } } + +// ── #26 Embedder Contract v1 — RED guards ──────────────────────────────── +// +// Frozen by decision d-20260719210556476178-40: core-owned typed versioned +// binding codec shared by the reference `run` writer and the shim reader; +// explicit unsupported-version fail-closed behavior; every emitted event +// routed through the canonical disposition-bearing writer; published +// embedder-contract doc whose event table matches code. Source scans read +// via CARGO_MANIFEST_DIR so a missing target fails the assert, never the +// compile. + +fn workspace_file_26(rel: &str) -> String { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(rel); + std::fs::read_to_string(&p).unwrap_or_default() +} + +/// #26 RED 1: an UNSUPPORTED binding format version must fail closed to +/// unbound even when the HMAC sidecar is valid — a v2-signed document may +/// carry authority semantics (e.g. `sealed_by`) this shim cannot enforce. +#[test] +fn binding_unsupported_version_fails_closed_26() { + let home = home_1651("unsupported-v2"); + let body = include_str!("../tests/fixtures/binding-unsupported-v2.json"); + write_binding_1651(&home, "ag", body, true); + let b = read_binding(home.to_str().unwrap(), "ag"); + assert!( + !is_bound(&b), + "#26: a validly-signed binding with version=2 must read as UNBOUND \ + (unsupported-version fail-closed); task_id={:?}", + b.task_id + ); + std::fs::remove_dir_all(home).ok(); +} + +/// #26 control: a legacy binding with NO version field stays compatible — +/// it decodes as v1 and binds (agend zero-daemon-change adoption). +#[test] +fn binding_missing_version_stays_compatible_26() { + let home = home_1651("legacy-noversion"); + let body = r#"{"task_id":"T-legacy","branch":"feat/legacy"}"#; + write_binding_1651(&home, "ag", body, true); + let b = read_binding(home.to_str().unwrap(), "ag"); + assert!( + is_bound(&b), + "#26: a signed legacy (version-less) binding must stay bound" + ); + assert_eq!(b.branch.as_deref(), Some("feat/legacy")); + std::fs::remove_dir_all(home).ok(); +} + +/// #26 control: golden agend-daemon and current-run binding shapes decode +/// and bind with their exact identity fields (each fixture's worktree is +/// repointed at a real dir so the orphan guard passes). +#[test] +fn binding_golden_fixtures_decode_26() { + let agend = include_str!("../tests/fixtures/binding-agend-v1.json"); + let run = include_str!("../tests/fixtures/binding-run-v1.json"); + let cases = [ + ("agend", agend, "/tmp/golden/worktree", "t-20260719-golden-agend", "feat/26-golden-agend"), + ("run", run, "/tmp/golden/run-worktree", "run-session-1789000000", "feat/26-golden-run"), + ]; + for (tag, fixture, wt_placeholder, task_id, branch) in cases { + let home = home_1651(&format!("golden-{tag}")); + let wt = home.join("wt"); + std::fs::create_dir_all(&wt).unwrap(); + let body = fixture.replace(wt_placeholder, wt.to_str().unwrap()); + write_binding_1651(&home, "ag", &body, true); + let b = read_binding(home.to_str().unwrap(), "ag"); + assert!( + is_bound(&b), + "#26 golden {tag}: must bind; task_id={:?}", + b.task_id + ); + assert_eq!(b.task_id.as_deref(), Some(task_id), "#26 golden {tag}"); + assert_eq!(b.branch.as_deref(), Some(branch), "#26 golden {tag}"); + std::fs::remove_dir_all(home).ok(); + } +} + +/// #26 RED 2: the typed v1 binding codec must be CORE-owned — `agentic-git-core` +/// exports a `binding` module with the typed document + decode/encode, so a +/// second orchestrator consumes the same representation as the shim. +#[test] +fn core_owns_typed_binding_codec_26() { + let core_lib = workspace_file_26("crates/agentic-git-core/src/lib.rs"); + assert!( + core_lib.contains("pub mod binding"), + "#26: agentic-git-core must export `pub mod binding` (typed v1 codec)" + ); + let module = workspace_file_26("crates/agentic-git-core/src/binding.rs"); + let needles = [ + "pub struct BindingV1", + "pub fn decode", + "pub fn encode", + "UnsupportedVersion", + ]; + for needle in needles { + assert!( + module.contains(needle), + "#26: core binding codec must define `{needle}`" + ); + } +} + +/// #26 RED 3: the shim reader must consume the core codec — no private +/// unversioned `serde_json::Value` field-picking in `read_binding`. +#[test] +fn shim_reader_uses_core_codec_26() { + let src = include_str!("lib.rs"); + let start = src.find("fn read_binding(").expect("read_binding exists"); + let end = src[start..] + .find("\nfn ") + .map(|o| start + o) + .unwrap_or(src.len()); + let body = &src[start..end]; + assert!( + body.contains("binding::decode"), + "#26: read_binding must decode through agentic-git-core's typed \ + binding codec (binding::decode), not ad-hoc Value field-picking" + ); +} + +/// #26 RED 4: the reference `run` writer must build + encode the SAME typed +/// document (BindingV1 + binding::encode) it expects the shim to read. +#[test] +fn run_writer_uses_core_codec_26() { + let src = include_str!("cli.rs"); + for needle in ["BindingV1", "binding::encode"] { + assert!( + src.contains(needle), + "#26: the `run` binding writer must use the core typed codec (`{needle}`)" + ); + } +} + +/// #26 RED 5 (strengthened per root preflight — the original +/// `contains("disposition")` check was satisfiable by comments alone): the +/// bespoke audit-event writers must route through the canonical builders by +/// ACTUAL CALL WIRING, proven on comment-stripped source so prose can never +/// satisfy the guard. +#[test] +fn bespoke_events_route_canonical_disposition_26() { + let src = include_str!("lib.rs"); + let code_of = |func: &str| -> String { + let start = src.find(func).unwrap_or_else(|| panic!("{func} exists")); + let end = src[start..] + .find("\nfn ") + .map(|o| start + o) + .unwrap_or(src.len()); + src[start..end] + .lines() + .filter(|l| { + let t = l.trim_start(); + !(t.starts_with("//") || t.starts_with("///")) + }) + .collect::>() + .join("\n") + }; + // Two-hop wiring for the bypass pair: the pure shape builder delegates to + // build_git_event; the logger appends through the shared appender. + let cases: [(&str, &[&str]); 4] = [ + ("fn build_bypass_audit_event(", &["build_git_event("]), + ("fn log_bypass_mutating_op(", &["append_git_event("]), + ( + "fn log_nonagent_canonical_checkout(", + &["build_git_event(", "append_git_event("], + ), + ( + "fn log_init_heartbeat_forensics(", + &["build_git_event(", "append_git_event("], + ), + ]; + for (func, needles) in cases { + let code = code_of(func); + for needle in needles { + assert!( + code.contains(needle), + "#26: `{func}` must call `{needle}` (comment-stripped source) — \ + canonical envelope + shared appender wiring, not prose" + ); + } + } +} + +/// #26 (root preflight): the canonical envelope is AUTHORITATIVE — a caller +/// extra that collides with a reserved routing field must never win. +#[test] +fn canonical_event_fields_win_over_extras_26() { + let mut extra = serde_json::Map::new(); + extra.insert("kind".into(), serde_json::json!("spoofed_kind")); + extra.insert("event".into(), serde_json::json!("spoofed_event")); + extra.insert("disposition".into(), serde_json::json!("info")); + extra.insert("agent".into(), serde_json::json!("spoofed_agent")); + extra.insert("subcommand".into(), serde_json::json!("spoofed_sub")); + extra.insert("timestamp".into(), serde_json::json!("1970-01-01T00:00:00Z")); + extra.insert("argv".into(), serde_json::json!(["push"])); + let ev = build_git_event("deny", "real-agent", "push", extra); + assert_eq!(ev["kind"], "git_event", "kind is canonical"); + assert_eq!(ev["event"], "deny", "event is canonical"); + assert_eq!( + ev["disposition"], "deny", + "disposition is canonical — the stop-vs-continue axis must be unspoofable" + ); + assert_eq!(ev["agent"], "real-agent", "agent is canonical"); + assert_eq!(ev["subcommand"], "push", "subcommand is canonical"); + assert_ne!( + ev["timestamp"], "1970-01-01T00:00:00Z", + "timestamp is canonical (now), not the injected value" + ); + assert_eq!(ev["argv"][0], "push", "non-reserved extras still pass through"); +} + +/// #26 RED 6: the forensic/audit event types get EXPLICIT dispositions — +/// they are instrument-only records, never terminal denials, so the +/// fail-closed Deny default must not be their steady state. +#[test] +fn disposition_for_maps_forensic_events_26() { + assert_eq!( + disposition_for("bypass_mutating_op"), + Disposition::Warn, + "#26: an audited bypass mutation is advisory-noteworthy, not a denial" + ); + assert_eq!( + disposition_for("canonical_passthrough_checkout"), + Disposition::Warn, + "#26: an unattributed canonical HEAD-touch is the #2234 blind spot" + ); + assert_eq!( + disposition_for("init_heartbeat_forensics"), + Disposition::Info, + "#26: heartbeat-pile forensics are routine instrumentation" + ); +} + +/// #26 RED 7: the Embedder Contract v1 doc must exist, be linked from the +/// README, and carry the required contract sections. +#[test] +fn embedder_contract_doc_exists_and_linked_26() { + let doc = workspace_file_26("docs/embedder-contract-v1.md"); + assert!( + !doc.is_empty(), + "#26: docs/embedder-contract-v1.md must exist at the workspace root" + ); + let headings = [ + "## Env", + "## Binding", + "## Events", + "## Hooks", + "## Trailers", + "## Orchestrator responsibility checklist", + "## Core crate boundary", + "## Minimal generic embed recipe", + ]; + for heading in headings { + assert!( + doc.contains(heading), + "#26: embedder-contract-v1.md must contain the `{heading}` section" + ); + } + for env_pair in ["AGENTIC_GIT_HOME", "AGEND_HOME", "AGENTIC_GIT_REAL_GIT"] { + assert!( + doc.contains(env_pair), + "#26: the Env table must cover `{env_pair}` (primary/legacy aliases)" + ); + } + let readme = workspace_file_26("README.md"); + assert!( + readme.contains("docs/embedder-contract-v1.md"), + "#26: README.md must link the embedder contract doc" + ); +} + +/// #26 RED 8: the doc's event-to-disposition table must MATCH the code's +/// single-source `disposition_for` for every emitted event type (the +/// issue's "matches code (or is generated/tested)" acceptance). +#[test] +fn doc_event_disposition_table_matches_code_26() { + let doc = workspace_file_26("docs/embedder-contract-v1.md"); + assert!(!doc.is_empty(), "#26: contract doc must exist (see RED 7)"); + let emitted = [ + "deny", + "deny_trust_root", + "deny_protected_ref", + "deny_snapshot_ref_push", + "cwd_worktree_drift", + "git_conflict", + "snapshot_failed", + "post_merge_cleanup_exempt", + "bypass_mutating_op", + "canonical_passthrough_checkout", + "init_heartbeat_forensics", + ]; + for event in emitted { + let needle = format!("`{event}`"); + let row = doc + .lines() + .find(|l| l.starts_with('|') && l.contains(&needle)) + .unwrap_or_else(|| panic!("#26: doc event table must list `{event}`")); + let disposition = disposition_for(event).as_str(); + assert!( + row.contains(disposition), + "#26: doc row for `{event}` must carry disposition `{disposition}`; row: {row}" + ); + } +} diff --git a/crates/agentic-git/tests/fixtures/binding-agend-v1.json b/crates/agentic-git/tests/fixtures/binding-agend-v1.json new file mode 100644 index 0000000..bdb7216 --- /dev/null +++ b/crates/agentic-git/tests/fixtures/binding-agend-v1.json @@ -0,0 +1,9 @@ +{ + "agent": "claude-golden", + "branch": "feat/26-golden-agend", + "issued_at": "2026-07-19T21:13:39.964685+00:00", + "source_repo": "/tmp/golden/source-repo", + "task_id": "t-20260719-golden-agend", + "version": 1, + "worktree": "/tmp/golden/worktree" +} diff --git a/crates/agentic-git/tests/fixtures/binding-run-v1.json b/crates/agentic-git/tests/fixtures/binding-run-v1.json new file mode 100644 index 0000000..d9322b0 --- /dev/null +++ b/crates/agentic-git/tests/fixtures/binding-run-v1.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "agent": "run-golden", + "task_id": "run-session-1789000000", + "branch": "feat/26-golden-run", + "issued_at": "2026-07-19T12:00:00Z", + "worktree": "/tmp/golden/run-worktree", + "source_repo": "/tmp/golden/run-source" +} diff --git a/crates/agentic-git/tests/fixtures/binding-unsupported-v2.json b/crates/agentic-git/tests/fixtures/binding-unsupported-v2.json new file mode 100644 index 0000000..e894d0e --- /dev/null +++ b/crates/agentic-git/tests/fixtures/binding-unsupported-v2.json @@ -0,0 +1,8 @@ +{ + "version": 2, + "agent": "future-golden", + "task_id": "t-future-1", + "branch": "feat/future", + "sealed_by": "some-v2-only-authority", + "issued_at": "2027-01-01T00:00:00Z" +} diff --git a/docs/embedder-contract-v1.md b/docs/embedder-contract-v1.md new file mode 100644 index 0000000..8acc271 --- /dev/null +++ b/docs/embedder-contract-v1.md @@ -0,0 +1,158 @@ +# Embedder Contract v1 + +This document is the **versioned integration contract** between `agentic-git` +(the shim + the `agentic-git-core` contract crate) and any orchestrator that +embeds it — agend (the co-evolved reference deployment), the built-in +`agentic-git run` reference orchestrator, or a thin wrapper around any agent +CLI. It is enough to sign a binding, install hooks, put the shim on `PATH`, +and get the same seatbelt behavior agend gets — without reading `main.rs`. + +Contract-testing: the binding schema is pinned by golden fixtures +(`crates/agentic-git/tests/fixtures/binding-*.json`) and the event table +below is asserted against the code's single-source `disposition_for` mapping +by `doc_event_disposition_table_matches_code_26`. + +## Env + +Primary names are `AGENTIC_GIT_*`; every one has a legacy `AGEND_*` twin +(`AGEND_GIT_*` for the bypass family) accepted for zero-change agend +adoption. When both are set the primary wins. + +| Primary | Legacy | Required when | Notes | +|---|---|---|---| +| `AGENTIC_GIT_HOME` | `AGEND_HOME` | shim + orchestrator | state root: `runtime/`, key, `fleet_events.jsonl` | +| `AGENTIC_GIT_AGENT` | `AGEND_INSTANCE_NAME` | agent execution path | agent identity; selects `runtime//binding.json` | +| `AGENTIC_GIT_REAL_GIT` | `AGEND_REAL_GIT` | always recommended | absolute path to real `git`; the shim rejects a self-referencing value | +| `AGENTIC_GIT_BYPASS` | `AGEND_GIT_BYPASS` | optional, audited | `1` = pass mutating ops through (audited — see the Events table) | +| `AGENTIC_GIT_BYPASS_AGENT` | `AGEND_GIT_BYPASS_AGENT` | optional | scope a bypass to one agent | +| `AGENTIC_GIT_BYPASS_UNTIL` | `AGEND_GIT_BYPASS_UNTIL` | optional | unix-seconds expiry for a bypass window | +| `AGENTIC_GIT_ALLOW_CANONICAL_MUTATE` | `AGEND_GIT_ALLOW_CANONICAL_MUTATE` | optional, audited | permit mutating ops in the canonical checkout | +| `AGENTIC_GIT_SNAPSHOTS` | `AGEND_GIT_SNAPSHOTS` | optional | pre-destructive-op snapshot refs; default off raw / on under `run` | +| `AGENTIC_GIT_SHIM_DEPTH` | `AGEND_GIT_SHIM_DEPTH` | internal | recursion guard — do not set manually | + +## Binding + +- Path: `$AGENTIC_GIT_HOME/runtime//binding.json` + `binding.json.sig` +- Bound predicate: `task_id` present **and** the `worktree` path exists +- HMAC over the exact file bytes with the key at + `$AGENTIC_GIT_HOME/.config-integrity-key` (`agentic_git_core::integrity_core`: + `ensure_key` / `sign_binding` / `verify`). Bare-hex tags are the default; + envelope-scheme tags are accepted (core P1a). Missing/invalid/foreign-scheme + signatures all fail **closed** to unbound. +- Typed codec: `agentic_git_core::binding` — `BindingV1`, `decode`, `encode`. + The reference `run` writer and the shim reader share this representation; + a second orchestrator should too (or write byte-equivalent JSON). + +### Schema (v1) + +| Field | Type | Notes | +|---|---|---| +| `version` | int | `1`; **absent = legacy v1** (agend zero-daemon-change); any other value → decode fails closed (`UnsupportedVersion`) and the shim treats the agent as unbound, loudly | +| `agent` | string? | agent identity | +| `task_id` | string? | the bound anchor — present ⇒ bound | +| `branch` | string? | the branch this binding authorizes work/push on | +| `worktree` | string? | absolute worktree path; must exist or the binding reads unbound (orphan guard) | +| `source_repo` | string? | canonical repository the worktree belongs to | +| `issued_at` | string? | RFC 3339 issue timestamp | +| *(unknown fields)* | any | bounded extension surface: preserved round-trip, ignored by readers; anything a reader must understand for safety belongs in v2 | + +Golden fixtures: `crates/agentic-git/tests/fixtures/binding-agend-v1.json` +(agend daemon shape), `binding-run-v1.json` (reference `run` shape), +`binding-unsupported-v2.json` (must fail closed). + +## Events + +- Path: `$AGENTIC_GIT_HOME/fleet_events.jsonl` (the name is historical — + treat it as the shim's append-only audit log) +- Every record carries: `kind` (`git_event`), `event`, `disposition`, + `agent`, `subcommand`, `timestamp`, plus event-specific fields + (`reason`, `target_branch`, `argv`, `cwd`, `ppid`, `process_ancestry`, + `bypass_layer`, `allow_empty`, `git_user_email`, …) +- `disposition` is the agent's stop-vs-continue routing axis. An event type + absent from this table fails **closed** to `deny` in code — treat unknown + events as "stop and check". + +| Event | Disposition | Meaning | +|---|---|---| +| `deny` | deny | mutating op refused (unbound / policy) | +| `deny_trust_root` | deny | write into the trust root refused | +| `deny_protected_ref` | deny | push/update of a protected ref refused | +| `deny_snapshot_ref_push` | deny | push of internal snapshot refs refused | +| `cwd_worktree_drift` | warn | op ran, but cwd disagreed with the bound worktree | +| `git_conflict` | warn | conflict state detected after a conflict-capable op | +| `snapshot_failed` | warn | pre-op snapshot failed; the op itself still ran (fail-open) | +| `post_merge_cleanup_exempt` | info | recognized `gh pr merge` cleanup checkout exempted | +| `bypass_mutating_op` | warn | audited bypassed mutating op (instrument-only) | +| `canonical_passthrough_checkout` | warn | unattributed canonical-cwd HEAD-touching checkout (instrument-only) | +| `init_heartbeat_forensics` | info | backend heartbeat `commit --allow-empty` forensics (instrument-only) | + +## Hooks + +Orchestrators must install (or let `agentic-git run` install) into the +**worktree-scoped** hooks dir and point `core.hooksPath` at it — never the +repository-global hooks, so an operator's own checkout is untouched: + +- `prepare-commit-msg` (+ `prepare-commit-msg.ps1` on Windows): stamps the + provenance trailers below onto every commit made in a bound worktree. +- `reference-transaction`: the ref-update seatbelt backstop. + +## Trailers + +Stable provenance surface stamped by the hooks — parse these, do not scrape +commit bodies: + +- `Agentic-Agent` +- `Agentic-Task` +- `Agentic-Branch` +- `Agentic-Issued-At` + +## Orchestrator responsibility checklist + +| Responsibility | Owner | +|---|---| +| Provision the worktree (branch checkout) | orchestrator | +| Write + HMAC-sign `binding.json` (typed codec) | orchestrator (daemon or `run`) | +| Provision the integrity key (`ensure_key`) | orchestrator, once per home | +| Put the shim first on `PATH` as `git` | orchestrator | +| Set `AGENTIC_GIT_REAL_GIT` | orchestrator | +| Install hooks + worktree `core.hooksPath` | orchestrator (or `run`) | +| Grant/expire bypass windows | orchestrator/operator only | +| Enforce deny/warn routing from events | agent runtime / orchestrator | + +`agentic-git run` is the **reference implementation** of the orchestrator +side, not the only one. + +## Core crate boundary + +- **In `agentic-git-core`:** key provision + HMAC sign/verify + (`integrity_core`), the typed binding codec (`binding`), and the + protected-ref predicate (`protected_refs`). +- **Not in core (deliberately):** the full subcommand classification matrix + and push denylist — policy stays in the shim so embedders are not forced + onto one binary policy blob. + +## Minimal generic embed recipe + +```sh +HOME_DIR=/tmp/agentic-home; AGENT=my-agent +WT=/tmp/agentic-home/worktrees/my-agent # any provisioned git worktree + +# 1. one-time: state root + integrity key (or call core's ensure_key) +mkdir -p "$HOME_DIR/runtime/$AGENT" + +# 2. sign a binding for the worktree (the reference writer does all of this): +agentic-git run --agent "$AGENT" --branch feat/x -- your-agent-command +# …or write binding.json yourself with core's BindingV1 + sign_binding, +# install hooks, then launch: +# 3. launch anything under the seatbelt: +env AGENTIC_GIT_HOME="$HOME_DIR" \ + AGENTIC_GIT_AGENT="$AGENT" \ + AGENTIC_GIT_REAL_GIT="$(command -v git)" \ + PATH="/path/to/shim-dir:$PATH" \ + your-agent-command +``` + +Version policy: this document freezes v1. Breaking changes to any surface +above (binding fields readers must understand, event record shape, hook or +trailer names, env semantics) require a v2 contract, a new doc, and an +explicit `version: 2` binding that v1 readers reject closed.