From 264264d4fe09d51166be6bea80bf09ad317df359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 10 Sep 2026 00:19:24 +0000 Subject: [PATCH 1/3] fix: clarify single-verb YAML input diagnostics --- docs/verbs.md | 15 +++++- src/cli_client.rs | 10 +++- src/main.rs | 4 +- tests/cli_output.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/docs/verbs.md b/docs/verbs.md index edbae6e..0427653 100644 --- a/docs/verbs.md +++ b/docs/verbs.md @@ -18,12 +18,25 @@ coverage-cell examples. A catalog may declare `platform: unix` or linting and startup. On file-backed deployments, operators add one catalog entry from a YAML file -containing exactly one verb definition: +containing exactly one verb definition as a top-level YAML mapping: ```bash guard verb add --file inspect-service.yaml ``` +For both `verb add --file` and `verb amend --file`, start the file with +`name:`. Omit the leading `-` list marker and the catalog's `verbs:` wrapper. +For example, a single-verb file for inspecting a systemd service contains: + +```yaml +name: inspect-service +binary: systemctl +args: [status, "{unit}", --no-pager] +params: + unit: { pattern: "^[a-zA-Z0-9@._-]+$", required: true } +consequence: reversible +``` + The daemon validates the candidate and the complete catalog before atomically appending it. The command fails without changing the catalog when the name already exists or the definition is invalid. Generated and reserved verb diff --git a/src/cli_client.rs b/src/cli_client.rs index 1f3fb2c..cd6ab28 100644 --- a/src/cli_client.rs +++ b/src/cli_client.rs @@ -1408,7 +1408,10 @@ pub(crate) async fn handle_verb(subcommand: VerbCommands) -> Result<()> { .with_context(|| format!("failed to read verb file {}", file.display()))?; let verb: guard::gating::verb::Verb = serde_yaml_ng::from_str(&yaml).with_context(|| { - format!("failed to parse {} as one verb definition", file.display()) + format!( + "failed to parse {} as one verb definition; use a top-level YAML mapping starting with 'name:', without a leading '-' list marker or a 'verbs:' wrapper", + file.display() + ) })?; let (client, source) = gate_client(socket, json)?; let response = client @@ -1465,7 +1468,10 @@ pub(crate) async fn handle_verb(subcommand: VerbCommands) -> Result<()> { .with_context(|| format!("failed to read verb file {}", file.display()))?; let replacement: guard::gating::verb::Verb = serde_yaml_ng::from_str(&yaml) .with_context(|| { - format!("failed to parse {} as one verb definition", file.display()) + format!( + "failed to parse {} as one verb definition; use a top-level YAML mapping starting with 'name:', without a leading '-' list marker or a 'verbs:' wrapper", + file.display() + ) })?; if replacement.name != name { anyhow::bail!( diff --git a/src/main.rs b/src/main.rs index 157dfe8..080ca29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -694,7 +694,7 @@ enum VerbCommands { }, /// Add one operator-authored verb from a YAML file. Add { - /// YAML file containing exactly one verb definition. + /// YAML file with one top-level verb mapping; no leading '-' list marker or 'verbs:' wrapper. #[arg(long, value_name = "PATH")] file: PathBuf, #[arg(long)] @@ -712,7 +712,7 @@ enum VerbCommands { /// Replace one operator-authored verb without clobbering concurrent edits. Amend { name: String, - /// YAML file containing exactly one verb definition. + /// YAML file with one top-level verb mapping; no leading '-' list marker or 'verbs:' wrapper. #[arg(long, value_name = "PATH")] file: PathBuf, #[arg(long)] diff --git a/tests/cli_output.rs b/tests/cli_output.rs index a74c519..db299d1 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -234,3 +234,113 @@ async fn execution_result_consumers_share_failure_denial_and_child_exits() { } } } + +#[cfg(unix)] +#[tokio::test] +async fn verb_file_diagnostics_preserve_mapping_parser_and_connection_order() { + let mapping = "name: fixture\nbinary: echo\nconsequence: reversible\n"; + let sequence = "- name: fixture\n binary: echo\n consequence: reversible\n"; + let malformed = "name: fixture\nbinary: echo\nargs: [\n"; + for verb in ["add", "amend"] { + for json in [false, true] { + for (yaml, diagnostic) in [ + (mapping, None), + ( + sequence, + Some("invalid type: sequence, expected struct Verb"), + ), + ( + malformed, + Some("did not find expected node content at line 4 column 1"), + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("verb.yaml"); + std::fs::write(&file, yaml).unwrap(); + let listening_socket = directory.path().join("parse-probe.sock"); + let listener = std::os::unix::net::UnixListener::bind(&listening_socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let socket = if diagnostic.is_some() { + listening_socket + } else { + directory.path().join("missing.sock") + }; + let mut command = tokio::process::Command::new(GUARD_BIN); + command + .env_clear() + .env("XDG_CONFIG_HOME", directory.path()) + .current_dir(directory.path()) + .kill_on_drop(true) + .args(["verb", verb]); + if verb == "amend" { + command.arg("fixture"); + } + command + .arg("--file") + .arg(&file) + .arg("--socket") + .arg(&socket); + if json { + command.arg("--json"); + } + let output = + tokio::time::timeout(std::time::Duration::from_secs(5), command.output()) + .await + .expect("verb file handling must not wait for a daemon") + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(125), "{verb}: {stderr}"); + assert!( + output.stdout.is_empty(), + "parse and connection errors stay on stderr, including --json" + ); + if let Some(diagnostic) = diagnostic { + assert!(stderr.contains("top-level YAML mapping"), "{stderr}"); + assert!(stderr.contains("starting with 'name:'"), "{stderr}"); + assert!( + stderr.contains("without a leading '-' list marker"), + "{stderr}" + ); + assert!(stderr.contains("'verbs:' wrapper"), "{stderr}"); + assert!( + stderr.contains(diagnostic), + "underlying YAML error must survive: {stderr}" + ); + assert!(!stderr.contains("cannot reach guard server")); + } else { + assert!(stderr.contains("cannot reach guard server"), "{stderr}"); + assert!(stderr.contains(socket.to_str().unwrap()), "{stderr}"); + assert!(!stderr.contains("failed to parse"), "{stderr}"); + } + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "invalid YAML must not contact the daemon" + ); + } + } + } +} + +#[test] +fn verb_file_help_names_the_single_mapping_format() { + for verb in ["add", "amend"] { + let output = Command::new(GUARD_BIN) + .args(["verb", verb, "--help"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let help = String::from_utf8(output.stdout) + .unwrap() + .split_whitespace() + .collect::>() + .join(" "); + assert!(help.contains("--file "), "{help}"); + assert!(help.contains("one top-level verb mapping"), "{help}"); + assert!( + help.contains("no leading '-' list marker or 'verbs:' wrapper"), + "{help}" + ); + } +} From 325e1a9f7fba27375056660ca5414bd3da7dc778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 10 Sep 2026 01:38:31 +0000 Subject: [PATCH 2/3] chore: prepare version 0.8.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- fuzz/Cargo.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02e1b11..b29a802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,7 +651,7 @@ dependencies = [ [[package]] name = "guard" -version = "0.8.6" +version = "0.8.7" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b3a6675..9a307d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "guard" -version = "0.8.6" +version = "0.8.7" edition = "2021" rust-version = "1.95" description = "LLM-evaluated command gate for AI agents" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 83f0660..50c6110 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -630,7 +630,7 @@ dependencies = [ [[package]] name = "guard" -version = "0.8.6" +version = "0.8.7" dependencies = [ "anyhow", "async-trait", From ad4219167eb673fa69cba842cb3834da2ea8c186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=B6rg=C3=A6sis?= Date: Thu, 10 Sep 2026 11:17:43 +0000 Subject: [PATCH 3/3] test: bind cleanup handoff checks to one connection --- tests/kube_proxy_integration.rs | 131 +++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/tests/kube_proxy_integration.rs b/tests/kube_proxy_integration.rs index ece9a9b..ace0b92 100644 --- a/tests/kube_proxy_integration.rs +++ b/tests/kube_proxy_integration.rs @@ -777,6 +777,17 @@ async fn spawn_transport_error_upstream() -> String { } async fn spawn_replaceable_create_upstream() -> (String, Arc, Arc) { + spawn_replaceable_create_upstream_with_delete_barrier(None).await +} + +struct ResponseHeaderBarrier { + reached: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, +} + +async fn spawn_replaceable_create_upstream_with_delete_barrier( + delete_barrier: Option>, +) -> (String, Arc, Arc) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let replacement = Arc::new(AtomicBool::new(false)); @@ -790,15 +801,21 @@ async fn spawn_replaceable_create_upstream() -> (String, Arc, Arc| { let replacement = replacement.clone(); let deletes = deletes.clone(); let provenance = provenance.clone(); + let delete_barrier = delete_barrier.clone(); async move { let (method, path) = observe_create_provenance(request, &provenance).await; if method == hyper::Method::DELETE { deletes.fetch_add(1, Ordering::SeqCst); + if let Some(barrier) = delete_barrier { + barrier.reached.add_permits(1); + barrier.release.acquire().await.unwrap().forget(); + } } let mut object = created_pod_object(); if let Some(name) = path.rsplit('/').next() { @@ -3079,68 +3096,108 @@ async fn cleanup_revocation_linearizes_at_the_final_header_handoff() { (CleanupLeasePause::BeforeLease, 403, 0), (CleanupLeasePause::AfterLease, 200, 1), ] { - let (upstream, _, deletes) = spawn_replaceable_create_upstream().await; + let response_headers = Arc::new(ResponseHeaderBarrier { + reached: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + }); + let (upstream, _, deletes) = + spawn_replaceable_create_upstream_with_delete_barrier(Some(response_headers.clone())) + .await; let sink = Arc::new(CleanupLeaseSink::new(pause)); - let (base, client) = start_proxy_with( - upstream, + let upstream = Upstream::from_kubeconfig_str(&kubeconfig_for(&upstream), None).unwrap(); + let tls = ProxyTls::generate().unwrap(); + let ca_pem = tls.ca_pem().to_string(); + let policy = ApiPolicy::from_yaml( "default: deny\nrules:\n - verbs: [create]\n resources: [pods]\n namespaces: [dev]\n action: allow\n - verbs: [delete]\n resources: [pods]\n namespaces: [dev]\n action: hold\n", - None, - Some(sink.clone()), - 0, ) - .await; + .unwrap(); + let (listener, listen) = reserve_listener().await; + let proxy = Arc::new( + ApiProxy::new(listen, tls, upstream, policy, None) + .with_listener_mode(ApiListenerMode::Policy), + ); + proxy.attach_gate(sink.clone()); + proxy.attach_session_sink(Arc::new(LiveSessionSink)); + tokio::spawn(proxy.serve_on(listener)); + // Cleanup provenance is connection-scoped. Own one TLS connection so + // pool scheduling cannot route the delete outside that authority. + let mut client = SingleConnectionClient::connect(listen, &ca_pem).await; let created = client - .post(format!("{base}/api/v1/namespaces/dev/pods")) - .body(r#"{"metadata":{"name":"lease-linearized"}}"#) - .send() - .await - .unwrap(); - assert_eq!(created.status(), 201); - created - .bytes() - .await - .expect("create response completes before cleanup handoff"); + .request( + hyper::Method::POST, + "/api/v1/namespaces/dev/pods", + Some("live-session"), + Some(r#"{"metadata":{"name":"lease-linearized"}}"#), + ) + .await; + assert_eq!(created, 201); - let cleanup = tokio::spawn(async move { + let mut cleanup = tokio::spawn(async move { client - .delete(format!( - "{base}/api/v1/namespaces/dev/pods/lease-linearized" - )) - .send() + .request( + hyper::Method::DELETE, + "/api/v1/namespaces/dev/pods/lease-linearized", + Some("live-session"), + None, + ) .await - .unwrap() }); - tokio::time::timeout(PROXY_INTEGRATION_TIMEOUT, sink.reached.acquire()) - .await - .expect("cleanup reaches the final authority handoff") - .unwrap() - .forget(); + tokio::time::timeout(PROXY_INTEGRATION_TIMEOUT, async { + tokio::select! { + reached = sink.reached.acquire() => reached.unwrap().forget(), + result = &mut cleanup => { + let status = result.unwrap_or_else(|_| panic!("cleanup task failed before {pause:?}")); + panic!( + "cleanup completed before {pause:?}: status={status}, forwarded_deletes={}", + deletes.load(Ordering::SeqCst), + ); + }, + } + }) + .await + .unwrap_or_else(|error| panic!("cleanup did not reach {pause:?}: {error}")); let coordination = sink.coordination.clone(); - let mut revoke = tokio::spawn(async move { + let mut revoke = Box::pin(async move { *coordination.write().await = false; }); if matches!(pause, CleanupLeasePause::AfterLease) { - assert!(sink.coordination.try_write().is_err()); + assert!(futures::poll!(&mut revoke).is_pending()); } else { tokio::time::timeout(Duration::from_secs(2), &mut revoke) .await - .expect("pre-handoff revocation completes before cleanup send") - .unwrap(); + .expect("pre-handoff revocation completes before cleanup send"); } sink.release.add_permits(1); + if matches!(pause, CleanupLeasePause::AfterLease) { + tokio::time::timeout(PROXY_INTEGRATION_TIMEOUT, async { + tokio::select! { + reached = response_headers.reached.acquire() => reached.unwrap().forget(), + result = &mut cleanup => { + let status = result.unwrap_or_else(|_| panic!("cleanup task failed before upstream response headers")); + panic!("cleanup completed before upstream response headers: status={status}"); + }, + } + }) + .await + .expect("cleanup reaches upstream before response headers are released"); + assert_eq!(deletes.load(Ordering::SeqCst), 1); + assert!( + futures::poll!(&mut revoke).is_pending(), + "revocation waits while upstream response headers are withheld" + ); + response_headers.release.add_permits(1); + } assert_eq!( tokio::time::timeout(PROXY_INTEGRATION_TIMEOUT, cleanup) .await .expect("cleanup completes after final authority handoff") - .unwrap() - .status(), + .unwrap(), expected_status ); if matches!(pause, CleanupLeasePause::AfterLease) { tokio::time::timeout(Duration::from_secs(2), &mut revoke) .await - .expect("post-handoff revocation completes after cleanup send") - .unwrap(); + .expect("post-handoff revocation completes after cleanup send"); } assert_eq!(deletes.load(Ordering::SeqCst), expected_deletes); } @@ -3860,7 +3917,7 @@ impl Default for BlockingUidTransitionSink { } } -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy)] enum CleanupLeasePause { BeforeLease, AfterLease,