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/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/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", 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}" + ); + } +}