Skip to content
Merged
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 14 additions & 1 deletion docs/verbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions src/cli_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!(
Expand Down
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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)]
Expand Down
110 changes: 110 additions & 0 deletions tests/cli_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join(" ");
assert!(help.contains("--file <PATH>"), "{help}");
assert!(help.contains("one top-level verb mapping"), "{help}");
assert!(
help.contains("no leading '-' list marker or 'verbs:' wrapper"),
"{help}"
);
}
}
Loading