Skip to content
Open
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
14 changes: 7 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ Versioning: [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) —
`time`, and `uuid`; this class of change requires a green determinism suite
before merge.
- `gommage tui` now uses a focused Overview, Approvals, and Inspect workflow
instead of an eight-tab information dump. The approval workbench keeps the
selected request, scope, Picto boundary, draft, confirmation, and result in
view; terminals below 80x24 safely fall back to a compact guide.
- Snapshot and watch inspection no longer initialize Gommage homes or migrate
Picto databases. Read-only operator views load a captured inspection model,
while commands that need runtime state retain the explicit initialization
path.
instead of an eight-tab information dump. It captures state before rendering,
keeps the selected request, scope, input-binding boundary, draft,
confirmation, and result in view, and falls back safely below 80x24.
- Snapshot and bounded watch inspection now use non-initializing policy reads
and strict read-only Picto SQLite access. They cannot create a home, generate
a key, migrate a legacy database, or leave WAL/SHM sidecars; commands that
need runtime state retain the explicit initialization path.

### Added

Expand Down
17 changes: 17 additions & 0 deletions capabilities/bash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@
# refspec position and slip a push past the branch gate. The mapper also
# strips redirections from the per-segment candidate (see collect_candidates);
# this charset is the defense-in-depth backstop on the raw command.
# A refspec with an empty source side deletes the destination branch:
# `git push origin :main` is the classic way to delete a remote branch. The
# generic mapper below captures the whole refspec, so that command emitted
# `git.push:refs/heads/:main`, which matches no branch gate. Deleting a
# protected branch therefore slipped through while a plain push to the same
# branch was gated. This mapper emits the destination branch so the existing
# branch gates apply, plus a dedicated capability so policy can gate deletion
# of any remote branch independently of the branch name.
- name: bash-git-push-delete-refspec
tool: Bash
match_input:
command: "^\\s*git\\s+push(?:\\s+[-\\w]+)*\\s+(?P<remote>[\\w.-]+)\\s+\\+?:(?P<ref>[^\\s<>&|;]+)"
emit:
- "git.push:refs/heads/${ref}"
- "git.push.delete:${ref}"
- "net.out:github.com"

- name: bash-git-push
tool: Bash
match_input:
Expand Down
11 changes: 3 additions & 8 deletions crates/gommage-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use gommage_audit::{AuditEvent, AuditWriter};
use gommage_core::{
Decision, PictoConsume, PictoLookup, ToolCall, evaluate,
Decision, PictoConsume, PictoLookup, ToolCall, approval_reason, evaluate,
runtime::{Expedition, HomeLayout, Runtime},
};
use std::{path::PathBuf, process::ExitCode};
Expand Down Expand Up @@ -830,9 +830,10 @@ pub(crate) fn decide_with_pictos(
reason: request.reason.clone(),
policy_version: request.policy_version.clone(),
});
let reason = approval_reason(&reason, &request.id, &required_scope, bind_input);
eval.decision = Decision::AskPicto {
required_scope,
reason: approval_reason(&reason, &request.id),
reason,
bind_input,
};
}
Expand Down Expand Up @@ -880,12 +881,6 @@ pub(crate) fn decide_with_pictos(
Ok((eval, events))
}

fn approval_reason(reason: &str, request_id: &str) -> String {
format!(
"{reason}; approval request {request_id} pending; run `gommage approval approve {request_id}`"
)
}

fn cmd_expedition(sub: ExpeditionCmd, layout: HomeLayout) -> Result<ExitCode> {
layout.ensure()?;
match sub {
Expand Down
10 changes: 8 additions & 2 deletions crates/gommage-cli/tests/cli_approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ fn ask_picto_creates_approval_and_approval_mints_consumable_picto() {
.and_then(|value| value.as_str()),
Some("ask")
);
assert!(reason.contains("approval request apr_"));
assert!(reason.contains("request apr_"), "{reason}");
// An unbound scope must hand the agent the path it can walk itself, or the
// ask becomes a handoff to a human. See gommage_core::approval_reason.
assert!(reason.contains("gommage grant --scope"), "{reason}");

let output = gommage(&home)
.args(["approval", "list", "--json"])
Expand Down Expand Up @@ -791,7 +794,10 @@ fn resolved_approval_can_be_requested_again() {
.pointer("/hookSpecificOutput/permissionDecisionReason")
.and_then(|value| value.as_str())
.unwrap();
assert!(reason.contains("approval request apr_"));
assert!(reason.contains("request apr_"), "{reason}");
// An unbound scope must hand the agent the path it can walk itself, or the
// ask becomes a handoff to a human. See gommage_core::approval_reason.
assert!(reason.contains("gommage grant --scope"), "{reason}");

let output = gommage(&home)
.args(["approval", "list", "--status", "pending", "--json"])
Expand Down
23 changes: 14 additions & 9 deletions crates/gommage-cli/tests/cli_beta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,22 @@ fn beta_check_accepts_public_fixture_library() {
report.get("status").and_then(|value| value.as_str()),
Some("warn")
);
assert!(report["checks"].as_array().unwrap().iter().any(|check| {
check["name"]
.as_str()
.unwrap()
.starts_with("policy fixture")
&& check["status"].as_str() == Some("pass")
&& check["message"]
// Assert the outcome, not the census. This asserted "8 passed" until the
// public fixture library grew to ten cases, and then failed on every branch
// for anyone who had not touched it — a broken check that reports the wrong
// author. What has to hold is that the library runs and nothing fails.
assert!(
report["checks"].as_array().unwrap().iter().any(|check| {
check["name"]
.as_str()
.unwrap()
.contains("8 passed, 0 failed")
}));
.starts_with("policy fixture")
&& check["status"].as_str() == Some("pass")
&& check["message"].as_str().unwrap().contains("0 failed")
}),
"{}",
serde_json::to_string_pretty(&report["checks"]).unwrap()
);
}

#[test]
Expand Down
56 changes: 56 additions & 0 deletions crates/gommage-core/src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,68 @@ mod approval_time {
}
}

/// Tell the blocked caller how *it* can clear the gate, not only how the
/// operator can.
///
/// An agent reads this string at the moment it is blocked, and acts on it in
/// preference to any doctrine it was given earlier. Naming only the operator
/// path turned every unbound ask into a handoff: measured over
/// 2026-07-27..2026-08-04 on one host, 118 asks produced 5 pictos and left 83
/// requests unresolved, because the agent was told to run a command reserved
/// for the operator and then asked a human to run it.
///
/// A scope with no input binding is self-serviceable by design — the picto is a
/// signed, audited declaration of intent, not a second password. Say so. When
/// the scope *is* bound to the exact call, only the operator can clear it, and
/// then the operator command is the whole answer.
///
/// Lives here because the CLI, the daemon and the MCP server each answer the
/// same gate and must answer it identically; it was duplicated in all three.
pub fn approval_reason(reason: &str, request_id: &str, scope: &str, bind_input: bool) -> String {
if bind_input {
return format!(
"{reason}; this picto is bound to the exact call, so only the operator can clear it: \
`gommage approval approve {request_id}` (request {request_id} pending)"
);
}
format!(
"{reason}; to proceed yourself: `gommage grant --scope {scope} --reason \"<why>\"` then \
retry the same call. Operator alternative: `gommage approval approve {request_id}` \
(request {request_id} pending)"
)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{Decision, EvalResult};
use tempfile::tempdir;

#[test]
fn unbound_scope_names_the_self_service_path_first() {
let msg = approval_reason("because", "apr_1", "net.out.post", false);
assert!(
msg.contains("gommage grant --scope net.out.post"),
"an unbound scope must tell the agent how to clear it itself: {msg}"
);
let self_service = msg.find("gommage grant").expect("self-service path");
let operator = msg.find("gommage approval approve").expect("operator path");
assert!(
self_service < operator,
"the actionable path must come first, or the agent acts on the operator one: {msg}"
);
}

#[test]
fn input_bound_scope_offers_only_the_operator_path() {
let msg = approval_reason("because", "apr_2", "deploy.vercel:prod", true);
assert!(
!msg.contains("gommage grant"),
"a picto bound to the exact call cannot be self-granted: {msg}"
);
assert!(msg.contains("gommage approval approve apr_2"), "{msg}");
}

fn eval() -> EvalResult {
EvalResult {
decision: Decision::AskPicto {
Expand Down
1 change: 1 addition & 0 deletions crates/gommage-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod webhook_signature;

pub use approval::{
ApprovalRequest, ApprovalResolution, ApprovalState, ApprovalStatus, ApprovalStore,
approval_reason,
};
pub use approval_webhook::{
ApprovalWebhookDeadLetter, ApprovalWebhookDeadLetterStore, ApprovalWebhookDeliveryKind,
Expand Down
11 changes: 3 additions & 8 deletions crates/gommage-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use ed25519_dalek::VerifyingKey;
use gommage_audit::{AuditEvent, AuditWriter, recent_stream_items};
use gommage_core::{
ApprovalRequest, ApprovalWebhookDeliveryKind, ApprovalWebhookDeliverySettings,
ApprovalWebhookSource, Decision, PictoConsume, PictoLookup, ToolCall,
ApprovalWebhookSource, Decision, PictoConsume, PictoLookup, ToolCall, approval_reason,
approval_webhook_generic_payload, deliver_prepared_approval_webhook, evaluate,
prepare_approval_webhook,
runtime::{HomeLayout, Runtime},
Expand Down Expand Up @@ -322,9 +322,10 @@ fn decide_and_audit(s: &mut State, call: &ToolCall) -> Result<gommage_core::Eval
policy_version: request.policy_version.clone(),
})?;
notify_approval_webhook_best_effort(&mut s.writer, &request);
let reason = approval_reason(&reason, &request.id, &required_scope, bind_input);
eval.decision = Decision::AskPicto {
required_scope,
reason: approval_reason(&reason, &request.id),
reason,
bind_input,
};
}
Expand Down Expand Up @@ -378,12 +379,6 @@ fn decide_and_audit(s: &mut State, call: &ToolCall) -> Result<gommage_core::Eval
Ok(eval)
}

fn approval_reason(reason: &str, request_id: &str) -> String {
format!(
"{reason}; approval request {request_id} pending; run `gommage approval approve {request_id}`"
)
}

fn notify_approval_webhook_best_effort(writer: &mut AuditWriter, request: &ApprovalRequest) {
let Ok(url) = env::var("GOMMAGE_APPROVAL_WEBHOOK_URL") else {
return;
Expand Down
13 changes: 4 additions & 9 deletions crates/gommage-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use gommage_audit::{AuditEvent, AuditWriter};
use gommage_core::{
ApprovalRequest, ApprovalWebhookDeliveryKind, ApprovalWebhookDeliverySettings,
ApprovalWebhookSource, Capability, CapabilityMapper, Decision, EvalResult, PictoConsume,
PictoLookup, ToolCall, approval_webhook_generic_payload, deliver_prepared_approval_webhook,
evaluate, evaluate_bypass, prepare_approval_webhook,
PictoLookup, ToolCall, approval_reason, approval_webhook_generic_payload,
deliver_prepared_approval_webhook, evaluate, evaluate_bypass, prepare_approval_webhook,
runtime::{HomeLayout, Runtime},
webhook_signature::WebhookSignatureReport,
};
Expand Down Expand Up @@ -804,9 +804,10 @@ fn decide_in_process_and_audit(
for event in notify_approval_webhook_best_effort(&request) {
events.push(event);
}
let reason = approval_reason(&reason, &request.id, &required_scope, bind_input);
eval.decision = Decision::AskPicto {
required_scope,
reason: approval_reason(&reason, &request.id),
reason,
bind_input,
};
}
Expand Down Expand Up @@ -856,12 +857,6 @@ fn decide_in_process_and_audit(
Ok(eval)
}

fn approval_reason(reason: &str, request_id: &str) -> String {
format!(
"{reason}; approval request {request_id} pending; run `gommage approval approve {request_id}`"
)
}

fn notify_approval_webhook_best_effort(request: &ApprovalRequest) -> Vec<AuditEvent> {
let Ok(url) = env::var("GOMMAGE_APPROVAL_WEBHOOK_URL") else {
return Vec::new();
Expand Down
17 changes: 17 additions & 0 deletions crates/gommage-stdlib/capabilities/bash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@
# refspec position and slip a push past the branch gate. The mapper also
# strips redirections from the per-segment candidate (see collect_candidates);
# this charset is the defense-in-depth backstop on the raw command.
# A refspec with an empty source side deletes the destination branch:
# `git push origin :main` is the classic way to delete a remote branch. The
# generic mapper below captures the whole refspec, so that command emitted
# `git.push:refs/heads/:main`, which matches no branch gate. Deleting a
# protected branch therefore slipped through while a plain push to the same
# branch was gated. This mapper emits the destination branch so the existing
# branch gates apply, plus a dedicated capability so policy can gate deletion
# of any remote branch independently of the branch name.
- name: bash-git-push-delete-refspec
tool: Bash
match_input:
command: "^\\s*git\\s+push(?:\\s+[-\\w]+)*\\s+(?P<remote>[\\w.-]+)\\s+\\+?:(?P<ref>[^\\s<>&|;]+)"
emit:
- "git.push:refs/heads/${ref}"
- "git.push.delete:${ref}"
- "net.out:github.com"

- name: bash-git-push
tool: Bash
match_input:
Expand Down
12 changes: 12 additions & 0 deletions crates/gommage-stdlib/policies/20-git.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@
- "git.stage:bulk"
reason: "Do not stage the whole tree. Stage explicit paths instead, for example `git add path/to/file.rs path/to/test.rs`."

# Deleting a remote branch destroys shared work and is not recoverable from
# the local clone alone. Gate it for every branch, not only the protected ones:
# an agent that deletes a colleague's feature branch has done real damage even
# though pushing to that same branch is allowed.
- name: gate-remote-branch-delete
decision: ask_picto
required_scope: "git.push.delete"
match:
any_capability:
- "git.push.delete:*"
reason: "deleting a remote branch destroys shared history; require a signed picto (scope git.push.delete)"

- name: gate-main-push
decision: ask_picto
required_scope: "git.push:main"
Expand Down
33 changes: 19 additions & 14 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,29 @@ to capture a human-readable report without ANSI control sequences. Automation
should still parse the JSON commands below instead of the TUI.

`gommage tui --view dashboard|approvals|policies|audit|capabilities|recovery|onboarding|metrics|all`
selects operator views. `--view all` is the most useful issue-report snapshot:
it includes readiness, pending approvals, policy inventory, signed audit
summary, mapper inventory, recovery shortcuts, and local metrics. `gommage tui
--watch` prints the same report repeatedly as plain text; use
`--watch-ticks <n>` to bound demos, CI artifacts, and issue-report captures.
selects operator views. A single named snapshot prints only that view; use
`--view all` for the full issue-report capture with readiness, pending
approvals, policy inventory, signed audit summary, mapper inventory, recovery
shortcuts, and local metrics. `gommage tui --watch` repeats the selected
plain-text view; use `--watch-ticks <n>` to bound demos, CI artifacts, and
issue-report captures.
`gommage tui --stream` prints a compact live decision/event feed using daemon
IPC when the daemon is reachable and the signed audit log otherwise. Stream and
snapshot output include daemon reachability, active picto counts, pending
approval counters, webhook DLQ counts, decision counters, and audit anomaly
counts when verification is available. Interactive mode uses `1` for
Overview, `2` for Approvals, and `3` for Inspect. Keys `3`-`8` jump
directly to an inspection section, while `[` and `]` move between those
sections. In the approvals view, `t/T` changes the TTL preset, `u/U` changes
the use-count preset, `i` reveals technical request context, and `A` /
`D` stage an approve/deny action for the selected request. The preview shows
the tool, scope, Picto binding, reason, and proposed grant; technical context
adds the request ID, input hash, policy version, and matched rule. `y` is
required only from a visible confirmation dialog before mutating state.
counts when verification is available. Interactive mode keeps three primary
areas: `1` Overview, `2` Approvals, and `3` Inspect. Keys `3`-`8` jump
directly to policy, audit, capability, recovery, onboarding, and metrics
inspection; `[` / `]` cycle those sections. In Approvals, the preview shows
the tool, scope, input-binding mode, reason, and proposed TTL/use grant before
confirmation. `i` reveals the request ID, input hash, policy version, and
matched rule; `t/T` changes TTL, `u/U` changes uses, and `A` / `D` stage the
action. `y` is required only from the visible confirmation dialog before any
state changes.
Sanitized demo assets live at `docs/assets/tui-dashboard.gif` and
`docs/assets/tui-dashboard.svg`; update both whenever the TUI's primary
sections or vocabulary change.

`gommage doctor` is the lower-level operator installation health check. Use the default text output for humans and `gommage doctor --json` when you need only filesystem/runtime diagnostics.

`gommage agent status <claude|codex>` is the host-agent integration check. Use
Expand Down
13 changes: 8 additions & 5 deletions docs/pictos.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ gommage tui --watch --watch-ticks 3 --view approvals
gommage tui --stream --stream-ticks 5
```

Interactive TUI approval is intentionally two-step. Operators can use `t/T` to
cycle TTL presets and `u/U` to cycle use-count presets, then `A` or `D` stages
the selected pending request. `y` is required before Gommage mints a picto or
records a denial. Snapshot and bounded watch modes are read-only and include
selected-request detail plus replay/evidence commands for support.
Interactive TUI approval is intentionally two-step. The approval workbench
shows the tool, scope, scope-only versus exact-input boundary, policy reason,
and chosen TTL/use grant before any forensic detail. Operators can use `t/T`
to cycle TTL presets, `u/U` to cycle use-count presets, `i` to reveal
technical request context, then `A` or `D` to stage the selected pending
request. `y` is required before Gommage mints a picto or records a denial.
Snapshot and bounded watch modes are read-only and include selected-request
detail plus replay/evidence commands for support.

Replay and evidence commands are for debugging and support. Replay evaluates the
stored request capabilities against the current policy, so an operator can see
Expand Down
Loading
Loading