diff --git a/.gitignore b/.gitignore index 202aafe..dbb1ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ Cargo.lock.bak # local state /.codex-runs/ +/.urd/ /.gommage *.ed25519 *.sqlite diff --git a/CHANGELOG.md b/CHANGELOG.md index 80bf589..f313f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/capabilities/bash.yaml b/capabilities/bash.yaml index 2fd4532..4d75e81 100644 --- a/capabilities/bash.yaml +++ b/capabilities/bash.yaml @@ -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[\\w.-]+)\\s+\\+?:(?P[^\\s<>&|;]+)" + emit: + - "git.push:refs/heads/${ref}" + - "git.push.delete:${ref}" + - "net.out:github.com" + - name: bash-git-push tool: Bash match_input: diff --git a/crates/gommage-stdlib/capabilities/bash.yaml b/crates/gommage-stdlib/capabilities/bash.yaml index 2fd4532..4d75e81 100644 --- a/crates/gommage-stdlib/capabilities/bash.yaml +++ b/crates/gommage-stdlib/capabilities/bash.yaml @@ -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[\\w.-]+)\\s+\\+?:(?P[^\\s<>&|;]+)" + emit: + - "git.push:refs/heads/${ref}" + - "git.push.delete:${ref}" + - "net.out:github.com" + - name: bash-git-push tool: Bash match_input: diff --git a/crates/gommage-stdlib/policies/20-git.yaml b/crates/gommage-stdlib/policies/20-git.yaml index 8749567..9ce544c 100644 --- a/crates/gommage-stdlib/policies/20-git.yaml +++ b/crates/gommage-stdlib/policies/20-git.yaml @@ -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" diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 03adbba..d8baf23 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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 ` 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 ` 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 ` is the host-agent integration check. Use diff --git a/docs/pictos.md b/docs/pictos.md index 7b49e2e..61e77b5 100644 --- a/docs/pictos.md +++ b/docs/pictos.md @@ -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 diff --git a/examples/policy-fixtures.yaml b/examples/policy-fixtures.yaml index 9bc271a..449597a 100644 --- a/examples/policy-fixtures.yaml +++ b/examples/policy-fixtures.yaml @@ -46,6 +46,26 @@ cases: required_scope: git.push.force matched_rule: no-force-push + - name: gate_delete_protected_branch_by_refspec + description: An empty-source refspec deletes the remote branch. This used to be allowed because the ref capture emitted refs/heads/:main, which matched no branch gate, so deleting main slipped through while pushing to it did not. + tool: Bash + input: + command: "git push origin :main" + expect: + decision: ask_picto + required_scope: git.push.delete + matched_rule: gate-remote-branch-delete + + - name: gate_delete_feature_branch_by_refspec + description: Deleting any remote branch is gated, not only protected ones, because removing a colleague's branch destroys shared work even where pushing to it is allowed. + tool: Bash + input: + command: "git push origin :feat/some-work" + expect: + decision: ask_picto + required_scope: git.push.delete + matched_rule: gate-remote-branch-delete + - name: ask_web_fetch description: Agent-native web fetches cross the local trust boundary and should require approval. tool: WebFetch diff --git a/policies/20-git.yaml b/policies/20-git.yaml index 8749567..9ce544c 100644 --- a/policies/20-git.yaml +++ b/policies/20-git.yaml @@ -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"