Skip to content
Merged
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.5"
version = "0.8.6"
edition = "2021"
rust-version = "1.95"
description = "LLM-evaluated command gate for AI agents"
Expand Down
8 changes: 6 additions & 2 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,13 @@ admin envelope accepts only the current operation and field grammar, so removed
or malformed authority operations fail closed instead of selecting a
compatibility path.

The state database uses schema version 14. Startup migrates an older database in
The state database uses schema version 15. Startup migrates an older database in
place. Treat the installed binary, configuration, API-revert body tree, and
complete SQLite file set as one rollback unit. Before the first schema-14
complete SQLite file set as one rollback unit. Schema 15 adds nullable execution-failure
details to session history; rows without these details carry no launch-stage or
start-state evidence. An older reader refuses the migrated database. Rollback
requires the matching stopped binary and its consistent pre-upgrade snapshot,
not an older binary pointed at the migrated database. Before the first schema-15
startup, resolve armed provisionals where practical, stop the service, verify
that it is inactive, and create a consistent SQLite backup with the SQLite
backup API. Copying only `state.db` while a process can write it can omit
Expand Down
20 changes: 16 additions & 4 deletions docs/agent-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ propagates the executed child's exit status untranslated:

| Code | Meaning |
| ----- | ---------------------------------------------------------------- |
| 125 | Guard operational error (daemon unreachable, protocol failure) |
| 125 | Guard operational error, including execution failure |
| 126 | Denied by policy |
| 127 | Held for operator approval |
| 2 | Invalid guard CLI usage (argument parsing) |
Expand All @@ -135,9 +135,21 @@ The reserved range collides with codes a child can produce on its own: `sh -c`
exits 127 when the named command is missing, and `git bisect skip` uses 125. An
exit code of 125-127 therefore suggests, but cannot prove, a guard-origin
outcome. An agent that needs certainty runs with `--json` and reads the
`allowed` and `status` fields; the exit code is a convenience for shell
pipelines, not the authoritative decision channel. `guard run` prints this
contract in its own help output (`guard help run`).
`policy`, `execution_failure`, `allowed`, and `status` fields; the exit code is
a convenience for shell pipelines, not the authoritative decision channel.
`guard run` prints this contract in its own help output (`guard help run`).

An approved command that fails during setup or execution reports
`EXECUTION FAILED` and exits 125. The optional `policy` object preserves the
admission result and reason, and `decision_source` identifies that admission.
The optional `execution_failure` object carries `started`, `stage`, `errno`,
and a sanitized `message`. Stages are `identity`, `capabilities`, `cwd`,
`exec`, or `unknown`; `unknown` means no precise failing stage is established.
The legacy `allowed` field remains false for an execution failure. Policy
approval does not establish that the command started or completed. `started` is
`true` or `false` only for an observed start outcome. A `null` or absent value
means execution may have started, including an interrupted approval recovered
after restart. Such an outcome must not be retried automatically.

## MCP

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.

55 changes: 55 additions & 0 deletions src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ impl std::fmt::Display for AuditKind {
/// which preserves insertion order for the stderr projection.
#[derive(Debug, Clone, Deserialize)]
pub struct AuditEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<crate::wire::PolicyDecision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_failure: Option<crate::wire::ExecutionFailure>,
pub kind: AuditKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
Expand All @@ -200,6 +204,10 @@ pub struct AuditEvent {

#[derive(Serialize)]
struct AuditEventSerializationView {
#[serde(skip_serializing_if = "Option::is_none")]
policy: Option<crate::wire::PolicyDecision>,
#[serde(skip_serializing_if = "Option::is_none")]
execution_failure: Option<crate::wire::ExecutionFailure>,
kind: AuditKind,
#[serde(skip_serializing_if = "Option::is_none")]
handle: Option<String>,
Expand All @@ -223,6 +231,8 @@ impl AuditEvent {
fn serialization_view(&self) -> AuditEventSerializationView {
let projected = redact_secret_exposure(self);
AuditEventSerializationView {
policy: projected.policy,
execution_failure: projected.execution_failure,
kind: projected.kind,
handle: projected.handle,
caller: projected.caller,
Expand Down Expand Up @@ -252,6 +262,14 @@ impl AuditEvent {
*value = crate::redact::redact_exact_and_registered_secrets(value, secrets);
}
}
if let Some(policy) = self.policy.as_mut() {
policy.reason =
crate::redact::redact_exact_and_registered_secrets(&policy.reason, secrets);
}
if let Some(failure) = self.execution_failure.as_mut() {
failure.message =
crate::redact::redact_exact_and_registered_secrets(&failure.message, secrets);
}
redact(&mut self.handle, secrets);
redact(&mut self.caller, secrets);
redact(&mut self.session_fingerprint, secrets);
Expand All @@ -268,6 +286,8 @@ impl AuditEvent {

pub fn new(kind: AuditKind) -> Self {
Self {
policy: None,
execution_failure: None,
kind,
handle: None,
caller: None,
Expand All @@ -280,6 +300,16 @@ impl AuditEvent {
}
}

pub fn execution(
mut self,
policy: crate::wire::PolicyDecision,
failure: Option<crate::wire::ExecutionFailure>,
) -> Self {
self.policy = Some(policy);
self.execution_failure = failure.map(crate::wire::ExecutionFailure::sanitized);
self
}

pub fn handle(mut self, handle: impl Into<String>) -> Self {
self.handle = Some(handle.into());
self
Expand Down Expand Up @@ -354,6 +384,23 @@ impl AuditEvent {
if let Some(source) = &self.decision_source {
push_field(&mut line, "decision_source", source, false);
}
if let Some(policy) = &self.policy {
push_field(
&mut line,
"policy_allowed",
&policy.allowed.to_string(),
false,
);
push_field(&mut line, "policy_reason", &policy.reason, true);
}
if let Some(failure) = &self.execution_failure {
push_field(
&mut line,
"execution_failure",
&serde_json::to_string(failure).expect("execution failure serializes"),
true,
);
}
for (key, value) in &self.fields {
push_field(&mut line, key, value, value_needs_quoting(value));
}
Expand All @@ -363,7 +410,15 @@ impl AuditEvent {

fn redact_secret_exposure(event: &AuditEvent) -> AuditEvent {
let mut redacted = event.clone();
if let Some(policy) = redacted.policy.as_mut() {
policy.reason = crate::gating::sanitize_gate_text(&policy.reason);
}
redacted.execution_failure = redacted
.execution_failure
.map(crate::wire::ExecutionFailure::sanitized);
if event.kind == AuditKind::SecretExposed {
redacted.policy = None;
redacted.execution_failure = None;
redacted.cmd = redacted.cmd.map(|_| "[redacted]".to_string());
redacted.reason = redacted.reason.map(|_| "[redacted]".to_string());
redacted.fields = redacted
Expand Down
Loading
Loading