From a1b92f3c90b6d2d73a4da5335fc5c3129ea03355 Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Sun, 28 Jun 2026 20:30:25 +0800 Subject: [PATCH 1/2] Overhaul policy model, sessions, skills, and policy TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sequence of related changes to the policy engine, session handling, the operator skill, and the policy TUI. They were developed together and the per-file edits interleave across themes, so they land as one commit. Sessions - `run` now requires an explicit session (--session or AGENTSSH_SESSION); with neither it errors (exit 2, ErrNoSession). Removed the 30-min idle-window resume and the on-disk session pointer file, so logically distinct tasks can no longer merge by time. Added `agentssh session new`. Skills - Replaced the two task playbooks (restart-service, investigate-cpu) with a single `agentssh-usage` operating manual: trust boundary, one-session- per-task discipline, policy, bounded output, audit — best practices for using AgentSSH, not a server runbook. Policy engine - Rebuilt as a two-tier, priority-ordered, default-deny model. Evaluate: host tier (host: + matched group overrides, pooled, priority desc, ties by file order) then global tier, else a hard-coded default-deny. Rules carry allow/deny + priority; action is required. Seed policy.yaml is a default-deny scaffold with no active rules. Rule groups + policy TUI - Added rule_groups: reusable authoring presets the engine never evaluates. Stamping copies a group's rules onto a host with a `group` provenance tag (snapshot, not a live link); removing a group filters host rules by that tag. Engine ignores rule_groups and Rule.Group. - Entry-level Policy tab: card grid (Global card + one per rule group) with drill-in rule CRUD. Host detail Policy pane is a borderless unified list (host + global in one list, SCOPE/PRIORITY/ACTION/COMMAND/GROUP columns, host tier first then priority desc, global rows read-only). Help bar - Slimmed every footer to a few essential keys; the ? overlay carries the full, grouped key list (FullHelp is now preserved, not flattened). Host rules ownership - Deleting a host cascades to remove its own host: rules; group overrides and rule groups are left intact. - Renamed the "binding" terminology to "host rules": HostBinding -> HostRuleSet, HostBindingGroup -> HostRulesKey, LookupHostBinding -> LookupHostRules, SetHostBinding -> SetHostRules, RemoveHostBinding -> ClearHostRules, ErrHostUnbound -> ErrNoHostRules (HostOverride / host_overrides unchanged). Review fixes (from an adversarial pass) - removeSelectedHostGroup now removes the group from the selected row's own source override, not blindly from the host's binding (wrong-rule delete). - RemoveHostGroup rejects an empty group name (would have matched every ungrouped rule via Group == ""). go build ./... && go vet ./... && go test ./... all green; default-deny, host>global, snapshot provenance, and the host-delete cascade were exercised end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 85 +- cmd/agentssh/main.go | 1204 ++++++++++++++++++++- cmd/agentssh/main_test.go | 651 +++++++++-- docs/DESIGN.md | 37 +- docs/architecture/inventory-tui.md | 9 +- docs/architecture/overview.md | 77 +- docs/architecture/unified-tui.md | 8 +- docs/plans/mvp.md | 8 +- docs/prds/agentssh.md | 14 +- internal/audit/audit_test.go | 44 + internal/audit/record.go | 115 ++ internal/config/config.go | 45 +- internal/config/config_test.go | 9 +- internal/hostform/hostform.go | 58 +- internal/inventory/store.go | 15 + internal/inventory/store_test.go | 21 + internal/policy/policy.go | 300 ++++-- internal/policy/policy_test.go | 259 ++++- internal/policy/store.go | 495 +++++++++ internal/policy/store_test.go | 310 ++++++ internal/session/session.go | 169 +-- internal/session/session_test.go | 93 +- internal/tui/app.go | 171 ++- internal/tui/app_test.go | 33 +- internal/tui/doc.go | 8 +- internal/tui/harness_test.go | 24 +- internal/tui/hosts.go | 446 +++++++- internal/tui/hosts_test.go | 135 +++ internal/tui/model.go | 105 +- internal/tui/policy_section.go | 1485 +++++++++++++++++++++++--- internal/tui/policy_test.go | 246 ++++- internal/tui/ux_test.go | 391 +++++++ scripts/e2e_tui_audit_run_summary.sh | 7 +- skills/agentssh-usage/SKILL.md | 64 ++ skills/investigate-cpu/SKILL.md | 57 - skills/restart-service/SKILL.md | 54 - 36 files changed, 6239 insertions(+), 1013 deletions(-) create mode 100644 internal/policy/store.go create mode 100644 internal/policy/store_test.go create mode 100644 internal/tui/ux_test.go create mode 100644 skills/agentssh-usage/SKILL.md delete mode 100644 skills/investigate-cpu/SKILL.md delete mode 100644 skills/restart-service/SKILL.md diff --git a/README.md b/README.md index cda6f16..7646a32 100644 --- a/README.md +++ b/README.md @@ -19,49 +19,49 @@ curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.5.1/agentss # 2. Open the console — this is your main entry point: agentssh tui # On first run it creates ~/.agentssh/ with a starter inventory.yaml and a -# policy.yaml (allow by default, catastrophic commands denied). Nothing to set up. +# policy.yaml scaffold. Out of the box every command is denied until you add +# allow rules. # In the Hosts tab: # d discover the SSH hosts you can already reach (from ~/.ssh/config + known_hosts), # select with space, p to probe, enter to import # a add a host by hand (addr/user, optional identity_file, optional password) # t test connectivity to the selected host -# Switch tabs with 1-4 or tab: Hosts · Audit · Policy · Sessions. +# Switch entry tabs with 1/2 or tab: Hosts · Policy. -# 3. Tune the deny rules (a hard, unoverridable boundary; policy is a file for now). -# The seeded ~/.agentssh/policy.yaml already contains: -# rules: -# - name: catastrophic -# match: { cmd_regex: '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel)' } -# action: deny -# Edit that file to add your own rules. +# 3. Add an explicit allow rule before running anything: +agentssh policy rule add readonly --cmd-regex '^(systemctl status|journalctl|uptime)\b' --action allow --priority 10 +# Optional: add higher-priority deny rules for commands that must never run. +agentssh policy rule add catastrophic --cmd-regex '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel)' --action deny --priority 100 # 4. The agent calls agentssh — every command is policy-checked and audited: agentssh hosts # discover targets (no credentials shown) +export AGENTSSH_SESSION=$(agentssh session new) # one session per task -> grouped in audit agentssh run web-1 -- systemctl status nginx # allowed -> executed over SSH agentssh run web-1 -- rm -rf / # denied by policy -> exit 6, never runs # 5. Review everything back in the console: -agentssh tui # Audit tab (grouped by session) · Policy tab to test rules +agentssh tui # Hosts tab for inventory · Policy tab for global/group rules ``` That is the whole loop: you own hosts, policy, and the audit trail through `agentssh tui`; the agent only ever calls `agentssh`. ## The console (`agentssh tui`) -`agentssh tui` is the primary operator interface — one full-screen app with four tabs (switch with `1`-`4` or `tab`/`shift+tab`, quit with `q`): +`agentssh tui` is the primary operator interface — one full-screen app with top-level Hosts and Policy tabs (switch with `1`/`2` or `tab`, quit with `q`): | Tab | What you do | | --- | --- | -| **Hosts** | onboard, inspect, test, and remove hosts; manage credentials | -| **Audit** | browse the hash-chained audit log, grouped by session | -| **Policy** | type `host:command` and see allow/deny against your rules | -| **Sessions** | list sessions; `enter` jumps to that session in Audit | +| **Hosts** | onboard, inspect, edit, test, and remove hosts; manage credentials | +| **Policy** | manage the Global rule list and reusable rule groups as cards; open a card to add/edit/remove rules | -**Hosts tab keys** — `j/k move · a add · d discover · t test · r/x remove · tab switch`: +**Hosts tab keys** — `j/k move · a add · e edit · d discover · t test · r/x remove · tab switch`: - **`d` Discover** — opens an overlay of hosts you can likely already reach, gathered from `~/.ssh/config` and `~/.ssh/known_hosts`, annotated with key/known-hosts/in-inventory status. `space` selects, `p` probes (a real connection test), `enter`/`i` imports the connectable, not-yet-known ones into your inventory. `esc`/`q` closes. - **`a` Add** — a form with `name / addr / user / port / tags / ssh_config_alias / identity_file / password`. `identity_file` points at a private key for that host. `password` is optional and **masked**; it is stored encrypted, never in `inventory.yaml`. Setting a password in the TUI requires `AGENTSSH_MASTER_PASSWORD` to be set (bubbletea owns the terminal, so there is no separate master prompt) — otherwise use `agentssh secret set`. +- **`e` Edit** — opens the same host form with the selected host prefilled. The host name stays fixed; edit addr/user/port/tags/ssh_config_alias/identity_file. - **`t` Test** — runs a real connectivity check against the selected host, updates its detected OS metadata, and shows `OK` or an actionable hint (missing credentials, unknown host key, unreachable, …). +- **Policy tab** — Global and each reusable rule group render as cards with rule counts. `enter` opens the selected card; `a/e/r` add, edit, and remove rules; `n` creates a group; `d` deletes a group. Rule groups are presets: stamping one onto a host copies its current rules and records the group name as provenance. +- **Host detail Policy pane** — press `enter`/`i` on a host, then `3` for Policy. The pane shows one unified, borderless rule list with host-tier rows first and global rows below as read-only context. `a` adds a manual host rule (`allow|deny [priority] `), `p` stamps a rule group, `j/k` selects rows, `r` removes editable host rows, `R` removes all rows stamped from the selected group, and `x` clears that host's rules. The remote side is always your responsibility — AgentSSH never touches a server's `authorized_keys`; it only connects with the credentials you give it and tells you what to fix when a connection fails. @@ -133,12 +133,27 @@ Example `policy.yaml`: ```yaml version: 1 -defaults: - policy: allow rules: + - name: readonly + priority: 10 + match: { cmd_regex: '^(systemctl status|journalctl|uptime)\b' } + action: allow - name: catastrophic + priority: 100 match: { cmd_regex: '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel)' } action: deny +host_overrides: + host:web-1: + rules: + - priority: 20 + match: { cmd_regex: '^systemctl status\b' } + action: allow +rule_groups: + readonly: + rules: + - priority: 10 + match: { cmd_regex: '^(uptime|whoami)\b' } + action: allow output: max_bytes: 16384 redact: @@ -169,11 +184,14 @@ These are the only commands an agent needs. They go through inventory resolution ```bash agentssh hosts # list targets (name + tags only; no credentials) agentssh hosts --json +export AGENTSSH_SESSION=$(agentssh session new) # declare one session per task (required by run) agentssh run web-1 -- systemctl status nginx agentssh run web-1 --json -- uptime agentssh status ``` +`run` requires a declared session — `--session ` or `$AGENTSSH_SESSION` — so each task maps to one auditable session; without one it exits 2. Mint a fresh id per task with `agentssh session new`. + On a connection failure, `run` prints a credential-free hint and exits 9. ## CLI for humans @@ -184,6 +202,8 @@ Everything the console does is also scriptable. Manage hosts and credentials: agentssh inventory discover [--probe] [--json] [--import] # find reachable hosts; --probe really connects agentssh inventory add web-1 --addr 10.0.0.11 --user deploy --identity-file ~/.ssh/web-1 [--password] agentssh inventory add # interactive form (TUI) +agentssh inventory update web-1 --addr 10.0.0.12 --tags web,prod +agentssh inventory rm web-1 # writes a tamper-evident delete audit record agentssh inventory ls agentssh inventory test web-1 # connectivity check + hint agentssh secret set|ls|rm @@ -193,14 +213,34 @@ Inspect and review: ```bash agentssh policy show +agentssh policy rule ls +agentssh policy rule add readonly --cmd-regex '^(systemctl status|journalctl|uptime)\b' --action allow --priority 10 +agentssh policy rule add no-reboot --cmd-regex '^(sudo )?reboot\b' --action deny --priority 100 +agentssh policy rule update no-reboot --cmd-regex '^(sudo )?(reboot|shutdown)\b' --priority 100 +agentssh policy rule rm no-reboot +agentssh policy group ls +agentssh policy group add readonly +agentssh policy group rule add readonly --cmd-regex '^(uptime|whoami)\b' --action allow --priority 10 +agentssh policy group rule ls readonly +agentssh policy group rule rm readonly 0 +agentssh policy group rm readonly +agentssh policy host ls +agentssh policy host rule add web-1 --cmd-regex '^systemctl status\b' --action allow --priority 20 +agentssh policy host rule add web-1 --from-group readonly +agentssh policy host rule ls web-1 +agentssh policy host rule rm web-1 0 +agentssh policy host group rm web-1 readonly +agentssh policy host rm web-1 agentssh policy test --host web-1 'rm -rf /' agentssh audit ls agentssh audit show agentssh audit verify # confirm the tamper-evident hash chain is intact +agentssh audit repair --truncate-broken # remove a broken audit tail after backing it up agentssh session ls +agentssh session new # mint a fresh session id for a task ``` -`inventory edit` / `policy edit` are still placeholders — edit `~/.agentssh/*.yaml` directly for now. +`inventory edit` / `policy edit` are still placeholders for opening the raw YAML. Use `inventory add/update/rm`, `policy rule ...`, and `policy host ...` for structured CRUD. ## Transport @@ -212,12 +252,11 @@ Before stdout/stderr return to the agent, AgentSSH applies `policy.output.redact ## Skills -Example Anthropic Agent Skill-style playbooks live under `skills/`: +An Anthropic Agent Skill-style operating manual lives under `skills/`: -- `skills/restart-service/SKILL.md` — safe systemd service diagnosis and restart. -- `skills/investigate-cpu/SKILL.md` — mostly read-only high-CPU investigation. +- `skills/agentssh-usage/SKILL.md` — best practices and command reference for driving servers through AgentSSH: the trust boundary, one-session-per-task discipline, policy, bounded output, and audit review. -These are procedural knowledge for agents, not RPC tools: they instruct the agent how to call `agentssh run ...` while the CLI enforces policy and audit. AgentSSH does not record or trust playbook names. +This is procedural knowledge for agents, not an RPC tool: it teaches the agent *how to use `agentssh` well* for whatever the operator asks, while the CLI enforces policy and audit. It is a soft control — it shapes what the agent attempts, but the CLI, not the manual, is the security boundary. See the project documents for the product and implementation contract: diff --git a/cmd/agentssh/main.go b/cmd/agentssh/main.go index 2a68b6d..fafae0e 100644 --- a/cmd/agentssh/main.go +++ b/cmd/agentssh/main.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -281,6 +279,40 @@ func newInventoryCommand() *cobra.Command { addCmd.Flags().BoolVar(&add.Password, "password", false, "prompt for and store an encrypted SSH password") addCmd.Flags().StringVar(&add.Tags, "tags", "", "comma-separated tags") + var update inventoryUpdateOptions + updateCmd := &cobra.Command{ + Use: "update [--addr ] [--user ] [--port ] [--alias ] [--identity-file ] [--tags ]", + Short: "Update an existing host in inventory.yaml.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + update.Name = args[0] + flags := cmd.Flags() + update.AddrSet = flags.Changed("addr") + update.UserSet = flags.Changed("user") + update.PortSet = flags.Changed("port") + update.AliasSet = flags.Changed("alias") + update.IdentityFileSet = flags.Changed("identity-file") + update.TagsSet = flags.Changed("tags") + return runInventoryUpdate(cmd, update) + }, + } + updateCmd.Flags().StringVar(&update.Addr, "addr", "", "host address") + updateCmd.Flags().StringVar(&update.User, "user", "", "SSH user") + updateCmd.Flags().IntVar(&update.Port, "port", 0, "SSH port") + updateCmd.Flags().StringVar(&update.Alias, "alias", "", "ssh_config host alias") + updateCmd.Flags().StringVar(&update.IdentityFile, "identity-file", "", "identity file path") + updateCmd.Flags().StringVar(&update.Tags, "tags", "", "comma-separated tags; empty clears tags") + + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a host from inventory.yaml.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runInventoryRM(cmd, args[0]) + }, + } + var discover inventoryDiscoverOptions discoverCmd := &cobra.Command{ Use: "discover [--probe] [--json] [--import]", @@ -306,6 +338,8 @@ func newInventoryCommand() *cobra.Command { cmd.AddCommand( lsCmd, addCmd, + updateCmd, + rmCmd, discoverCmd, testCmd, leafNoArgs("edit", "Edit inventory.yaml.", "edit ~/.agentssh/inventory.yaml directly"), @@ -359,6 +393,7 @@ func newPolicyCommand() *cobra.Command { Use: "policy", Short: "Manage command policy.", } + cmd.AddCommand(newPolicyRuleCommand(), newPolicyGroupCommand(), newPolicyHostCommand()) var testHost string testCmd := &cobra.Command{ Use: "test ", @@ -384,6 +419,260 @@ func newPolicyCommand() *cobra.Command { return cmd } +func newPolicyHostCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "host", + Short: "Manage per-host policy rules.", + } + lsCmd := &cobra.Command{ + Use: "ls", + Short: "List per-host policy rules.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPolicyHostLS(cmd) + }, + } + + ruleCmd := newPolicyHostRuleCommand() + groupCmd := newPolicyHostGroupCommand() + + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Clear per-host policy rules.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyHostRM(cmd, args[0]) + }, + } + cmd.AddCommand(lsCmd, ruleCmd, groupCmd, rmCmd) + return cmd +} + +func newPolicyHostRuleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage per-host policy rules.", + } + var add policyHostRuleOptions + addCmd := &cobra.Command{ + Use: "add (--cmd-regex --action allow|deny [--priority ] | --from-group )", + Short: "Append a manual rule or stamp a rule group onto host rules.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + add.Host = args[0] + cmdRegexSet := cmd.Flags().Changed("cmd-regex") + fromGroupSet := cmd.Flags().Changed("from-group") + if cmdRegexSet && fromGroupSet { + return newUsageError("policy host rule add accepts either --cmd-regex or --from-group, not both") + } + if !cmdRegexSet && !fromGroupSet { + return newUsageError("policy host rule add requires --cmd-regex or --from-group") + } + if fromGroupSet && (cmd.Flags().Changed("action") || cmd.Flags().Changed("priority")) { + return newUsageError("policy host rule add --from-group does not accept --action or --priority") + } + if cmdRegexSet && !cmd.Flags().Changed("action") { + return newUsageError("policy host rule add requires --action allow|deny") + } + return runPolicyHostRuleAdd(cmd, add) + }, + } + addCmd.Flags().StringVar(&add.CmdRegex, "cmd-regex", "", "command regex to match") + addCmd.Flags().StringVar(&add.Action, "action", "", "policy action: allow or deny") + addCmd.Flags().IntVar(&add.Priority, "priority", 0, "rule priority; higher values evaluate first") + addCmd.Flags().StringVar(&add.FromGroup, "from-group", "", "stamp all rules from a reusable rule group") + + lsCmd := &cobra.Command{ + Use: "ls ", + Short: "List host-specific policy rules.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyHostRuleLS(cmd, args[0]) + }, + } + + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a host-specific policy rule by index.", + Args: exactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + index, err := strconv.Atoi(args[1]) + if err != nil { + return newUsageError("host rule index must be a number") + } + return runPolicyHostRuleRM(cmd, args[0], index) + }, + } + + cmd.AddCommand(addCmd, lsCmd, rmCmd) + return cmd +} + +func newPolicyHostGroupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "group", + Short: "Manage rule-group snapshots stamped onto hosts.", + } + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove all host rules stamped from a group.", + Args: exactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyHostGroupRM(cmd, args[0], args[1]) + }, + } + cmd.AddCommand(rmCmd) + return cmd +} + +func newPolicyGroupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "group", + Short: "Manage reusable policy rule groups.", + } + lsCmd := &cobra.Command{ + Use: "ls", + Short: "List policy rule groups.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPolicyGroupLS(cmd) + }, + } + addCmd := &cobra.Command{ + Use: "add ", + Short: "Create a reusable policy rule group.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyGroupAdd(cmd, args[0]) + }, + } + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Delete a reusable policy rule group.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyGroupRM(cmd, args[0]) + }, + } + ruleCmd := newPolicyGroupRuleCommand() + cmd.AddCommand(lsCmd, addCmd, rmCmd, ruleCmd) + return cmd +} + +func newPolicyGroupRuleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage rules inside reusable policy groups.", + } + var add policyGroupRuleOptions + addCmd := &cobra.Command{ + Use: "add --cmd-regex --action allow|deny [--priority ]", + Short: "Append a rule to a policy rule group.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + add.Group = args[0] + if !cmd.Flags().Changed("action") { + return newUsageError("policy group rule add requires --action allow|deny") + } + return runPolicyGroupRuleAdd(cmd, add) + }, + } + addCmd.Flags().StringVar(&add.CmdRegex, "cmd-regex", "", "command regex to match") + addCmd.Flags().StringVar(&add.Action, "action", "", "policy action: allow or deny") + addCmd.Flags().IntVar(&add.Priority, "priority", 0, "rule priority; higher values evaluate first") + + lsCmd := &cobra.Command{ + Use: "ls ", + Short: "List rules inside a policy rule group.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyGroupRuleLS(cmd, args[0]) + }, + } + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a rule from a policy rule group by index.", + Args: exactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + index, err := strconv.Atoi(args[1]) + if err != nil { + return newUsageError("group rule index must be a number") + } + return runPolicyGroupRuleRM(cmd, args[0], index) + }, + } + cmd.AddCommand(addCmd, lsCmd, rmCmd) + return cmd +} + +func newPolicyRuleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage global policy rules.", + } + lsCmd := &cobra.Command{ + Use: "ls", + Short: "List global policy rules.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPolicyRuleLS(cmd) + }, + } + var add policyRuleOptions + addCmd := &cobra.Command{ + Use: "add --cmd-regex --action allow|deny [--priority ]", + Short: "Add a global policy rule.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + add.Name = args[0] + if !cmd.Flags().Changed("action") { + return newUsageError("policy rule add requires --action allow|deny") + } + return runPolicyRuleAdd(cmd, add) + }, + } + addCmd.Flags().StringVar(&add.CmdRegex, "cmd-regex", "", "command regex to match") + addCmd.Flags().StringVar(&add.Action, "action", "", "policy action: allow or deny") + addCmd.Flags().IntVar(&add.Priority, "priority", 0, "rule priority; higher values evaluate first") + + var update policyRuleOptions + updateCmd := &cobra.Command{ + Use: "update [--name ] [--cmd-regex ] [--action allow|deny] [--priority ]", + Short: "Update a global policy rule.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + update.Name = args[0] + flags := cmd.Flags() + update.NewNameSet = flags.Changed("name") + update.CmdRegexSet = flags.Changed("cmd-regex") + update.ActionSet = flags.Changed("action") + update.PrioritySet = flags.Changed("priority") + return runPolicyRuleUpdate(cmd, update) + }, + } + updateCmd.Flags().StringVar(&update.NewName, "name", "", "new rule name") + updateCmd.Flags().StringVar(&update.CmdRegex, "cmd-regex", "", "command regex to match") + updateCmd.Flags().StringVar(&update.Action, "action", "", "policy action: allow or deny") + updateCmd.Flags().IntVar(&update.Priority, "priority", 0, "rule priority; higher values evaluate first") + + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a global policy rule.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyRuleRM(cmd, args[0]) + }, + } + cmd.AddCommand(lsCmd, addCmd, updateCmd, rmCmd) + return cmd +} + func newAuditCommand() *cobra.Command { cmd := &cobra.Command{ Use: "audit", @@ -419,10 +708,28 @@ func newAuditCommand() *cobra.Command { return runAuditVerify(cmd) }, }, + newAuditRepairCommand(), ) return cmd } +func newAuditRepairCommand() *cobra.Command { + var truncateBroken bool + cmd := &cobra.Command{ + Use: "repair --truncate-broken", + Short: "Repair a broken audit log by truncating the unverifiable tail.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !truncateBroken { + return newUsageError("audit repair requires --truncate-broken") + } + return runAuditRepair(cmd) + }, + } + cmd.Flags().BoolVar(&truncateBroken, "truncate-broken", false, "remove the first broken audit record and every later record") + return cmd +} + func newSessionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "session", @@ -436,6 +743,21 @@ func newSessionCommand() *cobra.Command { return runSessionLS(cmd) }, }) + cmd.AddCommand(&cobra.Command{ + Use: "new", + Short: "Mint a fresh session id for a task.", + Long: "Print a fresh session id. Bind a task's runs to it so audit groups them:\n" + + " AGENTSSH_SESSION=$(agentssh session new)", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + id, err := session.NewID() + if err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), id) + return nil + }, + }) return cmd } @@ -575,12 +897,55 @@ type inventoryAddOptions struct { Tags string } +type inventoryUpdateOptions struct { + Name string + Addr string + User string + Port int + Alias string + IdentityFile string + Tags string + AddrSet bool + UserSet bool + PortSet bool + AliasSet bool + IdentityFileSet bool + TagsSet bool +} + type inventoryDiscoverOptions struct { Probe bool JSON bool Import bool } +type policyRuleOptions struct { + Name string + NewName string + CmdRegex string + Action string + Priority int + NewNameSet bool + CmdRegexSet bool + ActionSet bool + PrioritySet bool +} + +type policyHostRuleOptions struct { + Host string + CmdRegex string + Action string + Priority int + FromGroup string +} + +type policyGroupRuleOptions struct { + Group string + CmdRegex string + Action string + Priority int +} + func runInventoryAdd(opts inventoryAddOptions) error { home, err := config.ResolveHome() if err != nil { @@ -650,7 +1015,10 @@ func removeInventoryHostByName(paths config.Paths, name string) error { if err != nil { return err } - return writeInventoryAtomic(paths.Home, paths.InventoryFile, next) + if err := writeInventoryAtomic(paths.Home, paths.InventoryFile, next); err != nil { + return err + } + return clearHostRulesForDeletedHost(paths, name) } func resultFromFlags(opts hostform.Options) (hostform.Result, error) { @@ -704,11 +1072,145 @@ func addInventoryHost(paths config.Paths, inv inventory.Inventory, result hostfo return writeInventoryAtomic(paths.Home, paths.InventoryFile, next) } +func runInventoryUpdate(cmd *cobra.Command, opts inventoryUpdateOptions) error { + if !opts.AddrSet && !opts.UserSet && !opts.PortSet && !opts.AliasSet && !opts.IdentityFileSet && !opts.TagsSet { + return newUsageError("inventory update requires at least one field flag") + } + paths, err := resolvePathsForWrite() + if err != nil { + return err + } + base, err := loadInventoryForWrite(paths.InventoryFile) + if err != nil { + return err + } + existing, ok := base.Hosts[opts.Name] + if !ok { + return newUsageError("inventory host %q not found", opts.Name) + } + nextHost := existing + if opts.AddrSet { + nextHost.Addr = strings.TrimSpace(opts.Addr) + } + if opts.UserSet { + nextHost.User = strings.TrimSpace(opts.User) + } + if opts.PortSet { + nextHost.Port = opts.Port + } + if opts.AliasSet { + nextHost.SSHConfigAlias = strings.TrimSpace(opts.Alias) + } + if opts.IdentityFileSet { + nextHost.IdentityFile = strings.TrimSpace(opts.IdentityFile) + } + if opts.TagsSet { + nextHost.Tags = hostform.SplitTags(opts.Tags) + } + if nextHost.Addr == "" && nextHost.SSHConfigAlias == "" { + return newUsageError("inventory host requires --addr or --alias") + } + if opts.PortSet && (nextHost.Port < 1 || nextHost.Port > 65535) { + return newUsageError("port must be a number from 1 to 65535") + } + next, err := inventory.UpdateHost(base, opts.Name, nextHost) + if err != nil { + return err + } + if err := writeInventoryAtomic(paths.Home, paths.InventoryFile, next); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "updated host %s\n", opts.Name) + return nil +} + +func runInventoryRM(cmd *cobra.Command, name string) error { + paths, err := resolvePathsForWrite() + if err != nil { + return err + } + base, err := loadInventoryForWrite(paths.InventoryFile) + if err != nil { + if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { + return auditErr + } + return err + } + next, err := inventory.RemoveHost(base, name) + if err != nil { + if errors.Is(err, inventory.ErrHostNotFound) { + if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitUsage); auditErr != nil { + return auditErr + } + return newUsageError("inventory host %q not found", name) + } + if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { + return auditErr + } + return err + } + if err := writeInventoryAtomic(paths.Home, paths.InventoryFile, next); err != nil { + if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { + return auditErr + } + return err + } + if err := clearHostRulesForDeletedHost(paths, name); err != nil { + if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { + return auditErr + } + return err + } + if err := appendInventoryAudit(paths, audit.EventCompleted, name, "inventory rm "+name, "", exitOK); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed host %s\n", name) + return nil +} + +func clearHostRulesForDeletedHost(paths config.Paths, name string) error { + cfg, err := policy.Load(paths.PolicyFile) + if err != nil { + return config.ParseError{File: paths.PolicyFile, Err: err} + } + next, err := policy.ClearHostRules(policy.Bundle{Policy: cfg}, name) + if errors.Is(err, policy.ErrNoHostRules) { + return nil + } + if err != nil { + return err + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + return policy.Save(paths.PolicyFile, next.Policy) +} + func writeInventoryAtomic(home string, path string, inv inventory.Inventory) error { _ = home return inventory.Save(path, inv) } +func appendInventoryAudit(paths config.Paths, event audit.Event, host string, command string, errText string, exitCode int) error { + reqID, err := audit.NewReqID() + if err != nil { + return err + } + exit := exitCode + record := audit.Record{ + ReqID: reqID, + Event: event, + Host: host, + Cmd: command, + Error: errText, + ExitCode: &exit, + SessionID: "", + SessionLabel: "", + } + _, err = audit.NewStore(paths.AuditFile).Append(record) + return err +} + func refreshInventoryHostOS(paths config.Paths, hostName string, osName string) { _ = updateInventoryHostOS(paths, hostName, osName) } @@ -1166,7 +1668,7 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if err != nil { return newUsageError("invalid output filter in policy.yaml: %v\n fix the regex under output.redact in ~/.agentssh/policy.yaml", err) } - sessionResolver := session.Resolver{Path: cfg.Paths.SessionFile} + sessionResolver := session.Resolver{} store := audit.NewStore(cfg.Paths.AuditFile) ssh := newExecutor(cfg) exitCode := exitOK @@ -1177,14 +1679,18 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag return err } // Bind the session to this target host. A group run therefore records one - // session per host, never a single session spanning several hosts. + // session per host, never a single session spanning several hosts. A session + // must be declared (--session / AGENTSSH_SESSION) — one session per task keeps + // the audit trail grouped by task rather than by an arbitrary time window. sessionCtx, err := sessionResolver.Resolve(target.Name, flags.session, flags.sessionLabel) if err != nil { + if errors.Is(err, session.ErrNoSession) { + return newUsageError("a session must be declared for run\n" + + " pass --session or set AGENTSSH_SESSION (e.g. AGENTSSH_SESSION=$(agentssh session new))\n" + + " one session per task keeps the audit trail grouped by task") + } return fmt.Errorf("resolve session: %w", err) } - if err := sessionResolver.Update(target.Name, sessionCtx.ID, time.Now().UTC()); err != nil { - return err - } decision, err := engine.Evaluate(target.Name, remoteCommand) if err != nil { return fmt.Errorf("evaluate policy for %s: %w", target.Name, err) @@ -1193,9 +1699,6 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, audit.EventDenied, target.Name, remoteCommand, decision, nil, "", 0)); err != nil { return err } - if err := sessionResolver.Update(target.Name, sessionCtx.ID, time.Now().UTC()); err != nil { - return err - } response := runResponse{ ReqID: reqID, SessionID: sessionCtx.ID, @@ -1235,9 +1738,6 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered)); err != nil { return err } - if err := sessionResolver.Update(target.Name, sessionCtx.ID, time.Now().UTC()); err != nil { - return err - } if !isSSHErrorResult(result) { refreshInventoryHostOS(cfg.Paths, target.Name, result.OS) } @@ -1261,9 +1761,6 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered)); err != nil { return err } - if err := sessionResolver.Update(target.Name, sessionCtx.ID, time.Now().UTC()); err != nil { - return err - } if !isSSHErrorResult(result) { refreshInventoryHostOS(cfg.Paths, target.Name, result.OS) } @@ -1401,7 +1898,7 @@ func printSSHErrorHint(cmd *cobra.Command, result executor.Result) { func printDenyHuman(cmd *cobra.Command, host string, command string, decision policy.Decision) { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "✗ denied by policy · %s · 命中规则 %q\n", host, decision.Rule) - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s 属于不可执行的危险命令或未被 allowlist 放行;此拦截无法临场放行。\n", command) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s 命中了 deny 规则或没有命中 allow 规则;此拦截无法临场放行。\n", command) _, _ = fmt.Fprintln(cmd.ErrOrStderr(), " 如确需放宽,请人类修改 ~/.agentssh/policy.yaml。") } @@ -1471,45 +1968,642 @@ func runPolicyShow(cmd *cobra.Command) error { return writeJSON(cmd, cfg.Policy) } -func runPolicyTest(cmd *cobra.Command, host string, command string) error { +func runPolicyRuleLS(cmd *cobra.Command) error { cfg, err := config.Load() if err != nil { return classifyConfigError(err) } - engine, err := policy.NewEngine(cfg.Policy, cfg.Inventory) - if err != nil { - return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) - } - decision, err := engine.Evaluate(host, command) - if err != nil { - return err - } - if host != "" { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s · host=%s\n", decision.Action, decision.Rule, host) + out := cmd.OutOrStdout() + if len(cfg.Policy.Rules) == 0 { + _, _ = fmt.Fprintln(out, "(none)") return nil } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s\n", decision.Action, decision.Rule) + for i, rule := range cfg.Policy.Rules { + _, _ = fmt.Fprintf(out, "%d name=%s priority=%d action=%s cmd_regex=%s\n", i, rule.Name, rule.Priority, rule.Action, strconv.Quote(rule.Match.CmdRegex)) + } return nil } -func runAuditLS(cmd *cobra.Command, filters audit.Filters) error { - cfg, err := config.Load() +func runPolicyRuleAdd(cmd *cobra.Command, opts policyRuleOptions) error { + paths, cfg, err := loadPolicyForWrite() if err != nil { - return classifyConfigError(err) + return err } - records, err := audit.NewStore(cfg.Paths.AuditFile).ReadAll() + rule, err := policyRuleFromOptions(opts.Name, opts.CmdRegex, opts.Action, opts.Priority) if err != nil { return err } - records = audit.FilterRecords(records, filters) - for _, record := range records { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), formatAuditListRecord(record)) + next, err := policy.AddRule(cfg, rule) + if errors.Is(err, policy.ErrRuleExists) { + return newUsageError("policy rule %q already exists", opts.Name) } - return nil -} - -func formatAuditListRecord(record audit.Record) string { - parts := []string{ + if err != nil { + return err + } + if err := validatePolicyConfig(next); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy rule %s\n", rule.Name) + return nil +} + +func runPolicyRuleUpdate(cmd *cobra.Command, opts policyRuleOptions) error { + if !opts.NewNameSet && !opts.CmdRegexSet && !opts.ActionSet && !opts.PrioritySet { + return newUsageError("policy rule update requires at least one field flag") + } + paths, cfg, err := loadPolicyForWrite() + if err != nil { + return err + } + current, err := policyRuleByName(cfg, opts.Name) + if err != nil { + return policyRuleUsageError(opts.Name, err) + } + nextRule := current + if opts.NewNameSet { + nextRule.Name = strings.TrimSpace(opts.NewName) + } + if opts.CmdRegexSet { + nextRule.Match.CmdRegex = strings.TrimSpace(opts.CmdRegex) + } + if opts.ActionSet { + action, err := parsePolicyAction(opts.Action) + if err != nil { + return err + } + nextRule.Action = action + } + if opts.PrioritySet { + nextRule.Priority = opts.Priority + } + if err := validatePolicyRule(nextRule); err != nil { + return err + } + next, err := policy.UpdateRule(cfg, opts.Name, nextRule) + if errors.Is(err, policy.ErrRuleExists) { + return newUsageError("policy rule %q already exists", nextRule.Name) + } + if err != nil { + return policyRuleUsageError(opts.Name, err) + } + if err := validatePolicyConfig(next); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "updated policy rule %s\n", nextRule.Name) + return nil +} + +func runPolicyRuleRM(cmd *cobra.Command, name string) error { + paths, cfg, err := loadPolicyForWrite() + if err != nil { + return err + } + next, err := policy.RemoveRule(cfg, name) + if err != nil { + return policyRuleUsageError(name, err) + } + if err := validatePolicyConfig(next); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy rule %s\n", name) + return nil +} + +func runPolicyGroupLS(cmd *cobra.Command) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + names := sortedPolicyGroupNames(cfg.Policy.RuleGroups) + out := cmd.OutOrStdout() + if len(names) == 0 { + _, _ = fmt.Fprintln(out, "(none)") + return nil + } + for _, name := range names { + group := cfg.Policy.RuleGroups[name] + allow, deny := ruleActionCounts(group.Rules) + _, _ = fmt.Fprintf(out, "%s rules=%d allow=%d deny=%d\n", name, len(group.Rules), allow, deny) + } + return nil +} + +func runPolicyGroupAdd(cmd *cobra.Command, name string) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.CreateGroup(bundle, name) + if errors.Is(err, policy.ErrGroupExists) { + return newUsageError("policy group %q already exists", strings.TrimSpace(name)) + } + if err != nil { + return policyGroupUsageError(name, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy group %s\n", strings.TrimSpace(name)) + return nil +} + +func runPolicyGroupRM(cmd *cobra.Command, name string) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.DeleteGroup(bundle, name) + if err != nil { + return policyGroupUsageError(name, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy group %s\n", strings.TrimSpace(name)) + return nil +} + +func runPolicyGroupRuleAdd(cmd *cobra.Command, opts policyGroupRuleOptions) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + rule, err := policyGroupRuleFromOptions(opts) + if err != nil { + return err + } + next, err := policy.AddGroupRule(bundle, opts.Group, rule) + if err != nil { + return policyGroupUsageError(opts.Group, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + group := next.Policy.RuleGroups[strings.TrimSpace(opts.Group)] + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy group rule %s[%d]\n", strings.TrimSpace(opts.Group), len(group.Rules)-1) + return nil +} + +func runPolicyGroupRuleLS(cmd *cobra.Command, groupName string) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + group, ok := cfg.Policy.RuleGroups[strings.TrimSpace(groupName)] + if !ok { + return policyGroupUsageError(groupName, policy.ErrGroupNotFound) + } + out := cmd.OutOrStdout() + if len(group.Rules) == 0 { + _, _ = fmt.Fprintln(out, "(none)") + return nil + } + for i, rule := range group.Rules { + _, _ = fmt.Fprintln(out, formatIndexedRule(i, rule, false)) + } + return nil +} + +func runPolicyGroupRuleRM(cmd *cobra.Command, groupName string, index int) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.RemoveGroupRule(bundle, groupName, index) + if err != nil { + return policyGroupUsageError(groupName, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy group rule %s[%d]\n", strings.TrimSpace(groupName), index) + return nil +} + +func runPolicyHostLS(cmd *cobra.Command) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + ruleSets := policy.HostRuleSets(policy.Bundle{Policy: cfg.Policy, Inventory: cfg.Inventory}) + out := cmd.OutOrStdout() + if len(ruleSets) == 0 { + _, _ = fmt.Fprintln(out, "(none)") + return nil + } + for _, ruleSet := range ruleSets { + status := "effective" + if !ruleSet.Effective { + status = "missing-host" + } + _, _ = fmt.Fprintf(out, "host=%s rules=%d status=%s key=%s\n", ruleSet.Host, len(ruleSet.Override.Rules), status, ruleSet.Key) + for i, rule := range ruleSet.Override.Rules { + _, _ = fmt.Fprintf(out, " rule[%d] %s\n", i, formatRuleFields(rule, true)) + } + } + return nil +} + +func runPolicyHostRuleAdd(cmd *cobra.Command, opts policyHostRuleOptions) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + if strings.TrimSpace(opts.FromGroup) != "" { + next, err := policy.StampGroupOntoHost(bundle, opts.Host, opts.FromGroup) + if err != nil { + return policyGroupOrHostUsageError(opts.Host, opts.FromGroup, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + group := next.Policy.RuleGroups[strings.TrimSpace(opts.FromGroup)] + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "stamped policy group %s onto host %s (%d rule(s))\n", strings.TrimSpace(opts.FromGroup), strings.TrimSpace(opts.Host), len(group.Rules)) + return nil + } + rule, err := policyHostRuleFromOptions(opts) + if err != nil { + return err + } + next, err := policy.AddHostRule(bundle, opts.Host, rule) + if errors.Is(err, policy.ErrHostNotFound) { + return newUsageError("inventory host %q not found", opts.Host) + } + if err != nil { + return policyHostUsageError(opts.Host, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + ruleSet, _ := policy.LookupHostRules(next, opts.Host) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy host rule %s[%d]\n", strings.TrimSpace(opts.Host), len(ruleSet.Override.Rules)-1) + return nil +} + +func runPolicyHostRuleLS(cmd *cobra.Command, host string) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + ruleSet, ok := policy.LookupHostRules(policy.Bundle{Policy: cfg.Policy, Inventory: cfg.Inventory}, host) + if !ok { + return policyHostUsageError(host, policy.ErrNoHostRules) + } + out := cmd.OutOrStdout() + if len(ruleSet.Override.Rules) == 0 { + _, _ = fmt.Fprintln(out, "(none)") + return nil + } + for i, rule := range ruleSet.Override.Rules { + _, _ = fmt.Fprintln(out, formatIndexedRule(i, rule, true)) + } + return nil +} + +func runPolicyHostRuleRM(cmd *cobra.Command, host string, index int) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.RemoveHostRule(bundle, host, index) + if err != nil { + return policyHostUsageError(host, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host rule %s[%d]\n", strings.TrimSpace(host), index) + return nil +} + +func runPolicyHostGroupRM(cmd *cobra.Command, host string, groupName string) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.RemoveHostGroup(bundle, host, groupName) + if err != nil { + return policyGroupOrHostUsageError(host, groupName, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host group %s[%s]\n", strings.TrimSpace(host), strings.TrimSpace(groupName)) + return nil +} + +func runPolicyHostRM(cmd *cobra.Command, host string) error { + paths, bundle, err := loadPolicyBundleForWrite() + if err != nil { + return err + } + next, err := policy.ClearHostRules(bundle, host) + if err != nil { + return policyHostUsageError(host, err) + } + if err := validatePolicyConfig(next.Policy); err != nil { + return err + } + if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host %s\n", strings.TrimSpace(host)) + return nil +} + +func loadPolicyForWrite() (config.Paths, policy.Config, error) { + home, err := config.ResolveHome() + if err != nil { + return config.Paths{}, policy.Config{}, err + } + paths := config.NewPaths(home) + cfg, err := policy.Load(paths.PolicyFile) + if err != nil { + return paths, cfg, config.ParseError{File: paths.PolicyFile, Err: err} + } + return paths, cfg, nil +} + +func loadPolicyBundleForWrite() (config.Paths, policy.Bundle, error) { + paths, cfg, err := loadPolicyForWrite() + if err != nil { + return paths, policy.Bundle{}, err + } + inv, err := loadInventoryForWrite(paths.InventoryFile) + if err != nil { + return paths, policy.Bundle{}, err + } + return paths, policy.Bundle{Policy: cfg, Inventory: inv}, nil +} + +func policyRuleFromOptions(name string, cmdRegex string, actionValue string, priority int) (policy.Rule, error) { + action, err := parsePolicyAction(actionValue) + if err != nil { + return policy.Rule{}, err + } + rule := policy.Rule{ + Name: strings.TrimSpace(name), + Priority: priority, + Match: policy.Match{ + CmdRegex: strings.TrimSpace(cmdRegex), + }, + Action: action, + } + if err := validatePolicyRule(rule); err != nil { + return policy.Rule{}, err + } + return rule, nil +} + +func policyHostRuleFromOptions(opts policyHostRuleOptions) (policy.Rule, error) { + action, err := parsePolicyAction(opts.Action) + if err != nil { + return policy.Rule{}, err + } + rule := policy.Rule{ + Priority: opts.Priority, + Match: policy.Match{ + CmdRegex: strings.TrimSpace(opts.CmdRegex), + }, + Action: action, + } + if err := validatePolicyRuleShape(rule, "policy host rule --cmd-regex"); err != nil { + return policy.Rule{}, err + } + return rule, nil +} + +func policyGroupRuleFromOptions(opts policyGroupRuleOptions) (policy.Rule, error) { + action, err := parsePolicyAction(opts.Action) + if err != nil { + return policy.Rule{}, err + } + rule := policy.Rule{ + Priority: opts.Priority, + Match: policy.Match{ + CmdRegex: strings.TrimSpace(opts.CmdRegex), + }, + Action: action, + } + if err := validatePolicyRuleShape(rule, "policy group rule --cmd-regex"); err != nil { + return policy.Rule{}, err + } + return rule, nil +} + +func parsePolicyAction(value string) (policy.Action, error) { + switch policy.Action(strings.TrimSpace(value)) { + case policy.ActionAllow: + return policy.ActionAllow, nil + case policy.ActionDeny: + return policy.ActionDeny, nil + default: + return "", newUsageError("invalid policy action %q; expected allow or deny", value) + } +} + +func validatePolicyRule(rule policy.Rule) error { + if strings.TrimSpace(rule.Name) == "" { + return newUsageError("policy rule name is required") + } + if strings.IndexFunc(rule.Name, func(r rune) bool { return r == '\t' || r == '\n' || r == '\r' }) >= 0 { + return newUsageError("policy rule name must not contain control whitespace") + } + return validatePolicyRuleShape(rule, "policy rule --cmd-regex") +} + +func validatePolicyRuleShape(rule policy.Rule, source string) error { + if strings.TrimSpace(rule.Match.CmdRegex) == "" { + return newUsageError("%s is required", source) + } + if _, err := parsePolicyAction(string(rule.Action)); err != nil { + return err + } + _, err := policy.NewEngine(policy.Config{Rules: []policy.Rule{{ + Name: "validate", + Match: rule.Match, + Action: rule.Action, + Priority: rule.Priority, + }}}, inventory.Inventory{}) + if err != nil { + return newUsageError("%v", err) + } + return nil +} + +func validatePolicyConfig(cfg policy.Config) error { + if _, err := policy.NewEngine(cfg, inventory.Inventory{}); err != nil { + return newUsageError("policy.yaml would be invalid: %v", err) + } + return nil +} + +func policyRuleByName(cfg policy.Config, name string) (policy.Rule, error) { + found := -1 + for i, rule := range cfg.Rules { + if rule.Name != name { + continue + } + if found >= 0 { + return policy.Rule{}, policy.ErrRuleAmbiguous + } + found = i + } + if found < 0 { + return policy.Rule{}, policy.ErrRuleNotFound + } + return cfg.Rules[found], nil +} + +func policyRuleUsageError(name string, err error) error { + switch { + case errors.Is(err, policy.ErrRuleNotFound): + return newUsageError("policy rule %q not found", name) + case errors.Is(err, policy.ErrRuleAmbiguous): + return newUsageError("policy rule %q is ambiguous; edit policy.yaml directly", name) + default: + return err + } +} + +func policyHostUsageError(host string, err error) error { + switch { + case errors.Is(err, policy.ErrHostNotFound): + return newUsageError("inventory host %q not found", host) + case errors.Is(err, policy.ErrNoHostRules): + return newUsageError("host %q has no policy rules", host) + default: + return err + } +} + +func policyGroupUsageError(group string, err error) error { + switch { + case errors.Is(err, policy.ErrGroupNotFound): + return newUsageError("policy group %q not found", strings.TrimSpace(group)) + case errors.Is(err, policy.ErrGroupExists): + return newUsageError("policy group %q already exists", strings.TrimSpace(group)) + default: + return err + } +} + +func policyGroupOrHostUsageError(host string, group string, err error) error { + if errors.Is(err, policy.ErrGroupNotFound) { + return policyGroupUsageError(group, err) + } + return policyHostUsageError(host, err) +} + +func sortedPolicyGroupNames(groups map[string]policy.RuleGroup) []string { + names := make([]string, 0, len(groups)) + for name := range groups { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func ruleActionCounts(rules []policy.Rule) (allow, deny int) { + for _, rule := range rules { + switch rule.Action { + case policy.ActionAllow: + allow++ + case policy.ActionDeny: + deny++ + } + } + return allow, deny +} + +func formatIndexedRule(index int, rule policy.Rule, includeGroup bool) string { + return strconv.Itoa(index) + " " + formatRuleFields(rule, includeGroup) +} + +func formatRuleFields(rule policy.Rule, includeGroup bool) string { + parts := []string{ + fmt.Sprintf("priority=%d", rule.Priority), + fmt.Sprintf("action=%s", rule.Action), + fmt.Sprintf("cmd_regex=%s", strconv.Quote(rule.Match.CmdRegex)), + } + if includeGroup && strings.TrimSpace(rule.Group) != "" { + parts = append(parts, "group="+strconv.Quote(rule.Group)) + } + return strings.Join(parts, " ") +} + +func runPolicyTest(cmd *cobra.Command, host string, command string) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + engine, err := policy.NewEngine(cfg.Policy, cfg.Inventory) + if err != nil { + return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) + } + decision, err := engine.Evaluate(host, command) + if err != nil { + return err + } + if host != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s · host=%s\n", decision.Action, decision.Rule, host) + return nil + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s\n", decision.Action, decision.Rule) + return nil +} + +func runAuditLS(cmd *cobra.Command, filters audit.Filters) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + records, err := audit.NewStore(cfg.Paths.AuditFile).ReadAll() + if err != nil { + return err + } + records = audit.FilterRecords(records, filters) + for _, record := range records { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), formatAuditListRecord(record)) + } + return nil +} + +func formatAuditListRecord(record audit.Record) string { + parts := []string{ fmt.Sprintf("%d", record.Seq), record.TS, record.ReqID, @@ -1525,6 +2619,9 @@ func formatAuditListRecord(record audit.Record) string { if output := auditListOutput(record); output != "" { parts = append(parts, "out="+output) } + if record.Error != "" { + parts = append(parts, "err="+strconv.Quote(truncateRunes(record.Error, 96))) + } if record.Cmd != "" { parts = append(parts, "cmd="+strconv.Quote(truncateRunes(record.Cmd, 96))) } @@ -1658,6 +2755,23 @@ func runAuditVerify(cmd *cobra.Command) error { return commandExitError{Code: exitRemoteFailed} } +func runAuditRepair(cmd *cobra.Command) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + result, err := audit.NewStore(cfg.Paths.AuditFile).TruncateBroken() + if err != nil { + return err + } + if !result.Changed { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "audit chain already ok · records=%d\n", result.Kept) + return nil + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "audit repaired · broken_seq=%d · reason=%s · kept=%d · removed=%d · backup=%s\n", result.BrokenSeq, result.Reason, result.Kept, result.Removed, result.BackupPath) + return nil +} + func runSessionLS(cmd *cobra.Command) error { cfg, err := config.Load() if err != nil { @@ -1700,11 +2814,7 @@ func baseAuditRecord(reqID string, sessionCtx session.Context, event audit.Event } func newReqID() (string, error) { - var bytes [3]byte - if _, err := rand.Read(bytes[:]); err != nil { - return "", fmt.Errorf("generate request id: %w", err) - } - return hex.EncodeToString(bytes[:]), nil + return audit.NewReqID() } type eventValue audit.Event diff --git a/cmd/agentssh/main_test.go b/cmd/agentssh/main_test.go index f29da06..a9370e0 100644 --- a/cmd/agentssh/main_test.go +++ b/cmd/agentssh/main_test.go @@ -355,6 +355,491 @@ func TestInventoryAddNonInteractiveRequiresFields(t *testing.T) { } } +func TestInventoryUpdateFlagPathPreservesUnchangedFields(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 + user: deploy + port: 22 + identity_file: ~/.ssh/web-1 + os: linux + tags: [web, prod] +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "inventory", "update", "web-1", "--addr", "10.0.0.12", "--port", "2222", "--tags", "api,prod") + if err != nil { + t.Fatalf("inventory update err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "updated host web-1") { + t.Fatalf("update stdout = %q", stdout) + } + host := readInventoryFile(t, home).Hosts["web-1"] + if host.Addr != "10.0.0.12" || host.User != "deploy" || host.Port != 2222 || host.IdentityFile != "~/.ssh/web-1" || host.OS != "linux" { + t.Fatalf("updated host = %#v", host) + } + if got := strings.Join(host.Tags, ","); got != "api,prod" { + t.Fatalf("tags = %q", got) + } +} + +func TestInventoryUpdateRejectsInvalidFinalHost(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 +`) + t.Setenv("AGENTSSH_HOME", home) + + _, _, err := runCommandForTest(t, "inventory", "update", "web-1") + if err == nil || !isUsageError(err) { + t.Fatalf("missing update fields err = %T %[1]v, want usageError", err) + } + _, _, err = runCommandForTest(t, "inventory", "update", "web-1", "--addr", "") + if err == nil || !isUsageError(err) { + t.Fatalf("invalid update err = %T %[1]v, want usageError", err) + } +} + +func TestInventoryRemoveDeletesHostAndWritesAudit(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 + db-1: + addr: 10.0.0.12 +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "inventory", "rm", "web-1") + if err != nil { + t.Fatalf("inventory rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed host web-1") { + t.Fatalf("rm stdout = %q", stdout) + } + inv := readInventoryFile(t, home) + if _, ok := inv.Hosts["web-1"]; ok { + t.Fatalf("web-1 still present: %#v", inv.Hosts) + } + if _, ok := inv.Hosts["db-1"]; !ok { + t.Fatalf("db-1 missing after rm: %#v", inv.Hosts) + } + records := mustReadAudit(t, home) + if len(records) != 1 || records[0].Event != audit.EventCompleted || records[0].Host != "web-1" || records[0].Cmd != "inventory rm web-1" { + t.Fatalf("delete audit records = %#v", records) + } + if records[0].ExitCode == nil || *records[0].ExitCode != exitOK || records[0].Error != "" { + t.Fatalf("delete audit record = %#v", records[0]) + } + if result, err := audit.NewStore(filepath.Join(home, "audit.log")).Verify(); err != nil || !result.OK { + t.Fatalf("verify delete audit = %#v err=%v", result, err) + } +} + +func TestInventoryRemoveClearsOnlyDeletedHostRules(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 + tags: [prod] + web-2: + addr: 10.0.0.12 + tags: [prod] +groups: + prod: + tags: [prod] +`) + writePolicy(t, home, ` +version: 1 +host_overrides: + host:web-1: + rules: + - match: { cmd_regex: '^whoami$' } + action: allow + host:web-2: + rules: + - match: { cmd_regex: '^uptime$' } + action: allow + prod: + rules: + - match: { cmd_regex: '^id$' } + action: deny +rule_groups: + readonly: + rules: + - match: { cmd_regex: '^ls$' } + action: allow +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "inventory", "rm", "web-1") + if err != nil { + t.Fatalf("inventory rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + cfg := readPolicyFile(t, home) + if _, ok := cfg.HostOverrides["host:web-1"]; ok { + t.Fatalf("deleted host rules still present: %#v", cfg.HostOverrides) + } + if _, ok := cfg.HostOverrides["host:web-2"]; !ok { + t.Fatalf("other host rules removed: %#v", cfg.HostOverrides) + } + if _, ok := cfg.HostOverrides["prod"]; !ok { + t.Fatalf("group override removed: %#v", cfg.HostOverrides) + } + if _, ok := cfg.RuleGroups["readonly"]; !ok { + t.Fatalf("rule group removed: %#v", cfg.RuleGroups) + } +} + +func TestInventoryRemoveMissingWritesFailedAudit(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 +`) + t.Setenv("AGENTSSH_HOME", home) + + _, _, err := runCommandForTest(t, "inventory", "rm", "missing") + if err == nil || !isUsageError(err) { + t.Fatalf("missing rm err = %T %[1]v, want usageError", err) + } + records := mustReadAudit(t, home) + if len(records) != 1 || records[0].Event != audit.EventFailed || records[0].Host != "missing" || records[0].Cmd != "inventory rm missing" { + t.Fatalf("failed delete audit records = %#v", records) + } + if records[0].ExitCode == nil || *records[0].ExitCode != exitUsage || !strings.Contains(records[0].Error, "inventory host not found") { + t.Fatalf("failed delete audit record = %#v", records[0]) + } + stdout, stderr, err := runCommandForTest(t, "audit", "ls", "--status", "failed") + if err != nil { + t.Fatalf("audit ls failed err = %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, `err="inventory host not found"`) || !strings.Contains(stdout, `cmd="inventory rm missing"`) { + t.Fatalf("audit ls missing delete failure context:\n%s", stdout) + } +} + +func TestPolicyRuleCRUDCommands(t *testing.T) { + home := t.TempDir() + writePolicy(t, home, ` +version: 1 +rules: + - name: old + priority: 1 + match: { cmd_regex: '^ls' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "policy", "rule", "add", "danger", "--cmd-regex", "rm -rf", "--action", "deny", "--priority", "50") + if err != nil { + t.Fatalf("policy rule add err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "added policy rule danger") { + t.Fatalf("add stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "rule", "ls") + if err != nil { + t.Fatalf("policy rule ls err = %v stderr=%s", err, stderr) + } + for _, want := range []string{"name=old", "priority=1", "cmd_regex=\"^ls\"", "name=danger", "priority=50", "action=deny", "cmd_regex=\"rm -rf\""} { + if !strings.Contains(stdout, want) { + t.Fatalf("policy rule ls missing %q:\n%s", want, stdout) + } + } + + stdout, stderr, err = runCommandForTest(t, "policy", "rule", "update", "danger", "--name", "catastrophic", "--cmd-regex", "mkfs", "--action", "deny", "--priority", "99") + if err != nil { + t.Fatalf("policy rule update err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "updated policy rule catastrophic") { + t.Fatalf("update stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "rule", "rm", "old") + if err != nil { + t.Fatalf("policy rule rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy rule old") { + t.Fatalf("rm stdout = %q", stdout) + } + var cfg policy.Config + raw := readFileString(t, filepath.Join(home, "policy.yaml")) + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("decode policy: %v\n%s", err, raw) + } + if len(cfg.Rules) != 1 || cfg.Rules[0].Name != "catastrophic" || cfg.Rules[0].Match.CmdRegex != "mkfs" || cfg.Rules[0].Priority != 99 { + t.Fatalf("policy after crud = %#v", cfg.Rules) + } + if _, _, err = runCommandForTest(t, "policy", "rule", "add", "missing-action", "--cmd-regex", "^ls"); err == nil || !isUsageError(err) { + t.Fatalf("missing action err = %T %[1]v, want usageError", err) + } + if _, _, err = runCommandForTest(t, "policy", "rule", "add", "broken", "--cmd-regex", "[", "--action", "deny"); err == nil || !isUsageError(err) { + t.Fatalf("invalid regex err = %T %[1]v, want usageError", err) + } +} + +func TestPolicyHostCRUDCommands(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 + web-2: + addr: 10.0.0.12 +`) + writePolicy(t, home, ` +version: 1 +rules: + - name: allow-cat + match: { cmd_regex: '^cat\b' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--cmd-regex", "^cat\\b", "--action", "deny", "--priority", "20") + if err != nil { + t.Fatalf("policy host rule add err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "added policy host rule web-1[0]") { + t.Fatalf("add stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "host", "ls") + if err != nil { + t.Fatalf("policy host ls err = %v stderr=%s", err, stderr) + } + for _, want := range []string{"host=web-1", "rules=1", "key=host:web-1", "priority=20", "action=deny", `cmd_regex="^cat\\b"`} { + if !strings.Contains(stdout, want) { + t.Fatalf("policy host ls missing %q:\n%s", want, stdout) + } + } + + stdout, stderr, err = runCommandForTest(t, "policy", "test", "--host", "web-1", "cat", "/etc/passwd") + if err != nil { + t.Fatalf("policy test web-1 err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "deny") || !strings.Contains(stdout, "host:web-1/rules[0]") { + t.Fatalf("policy test web-1 output = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "test", "--host", "web-2", "cat", "/etc/passwd") + if err != nil { + t.Fatalf("policy test web-2 err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "allow") || strings.Contains(stdout, "host:web-1") { + t.Fatalf("policy test web-2 output = %q", stdout) + } + + stdout, stderr, err = runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--cmd-regex", "^uptime$", "--action", "allow") + if err != nil { + t.Fatalf("policy host rule add err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "added policy host rule web-1[1]") { + t.Fatalf("rule add stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "host", "rule", "rm", "web-1", "0") + if err != nil { + t.Fatalf("policy host rule rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy host rule web-1[0]") { + t.Fatalf("rule rm stdout = %q", stdout) + } + + var cfg policy.Config + raw := readFileString(t, filepath.Join(home, "policy.yaml")) + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("decode policy: %v\n%s", err, raw) + } + override, ok := cfg.HostOverrides["host:web-1"] + if !ok || len(override.Rules) != 1 || override.Rules[0].Action != policy.ActionAllow || override.Rules[0].Match.CmdRegex != "^uptime$" { + t.Fatalf("host override after crud = %#v ok=%v", override, ok) + } + + stdout, stderr, err = runCommandForTest(t, "policy", "host", "rm", "web-1") + if err != nil { + t.Fatalf("policy host rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy host web-1") { + t.Fatalf("rm stdout = %q", stdout) + } + raw = readFileString(t, filepath.Join(home, "policy.yaml")) + if strings.Contains(raw, "host:web-1") { + t.Fatalf("host rules still present:\n%s", raw) + } + if _, _, err = runCommandForTest(t, "policy", "host", "rule", "add", "missing", "--cmd-regex", "^ls", "--action", "allow"); err == nil || !isUsageError(err) { + t.Fatalf("missing host err = %T %[1]v, want usageError", err) + } + if _, _, err = runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--cmd-regex", "[", "--action", "allow"); err == nil || !isUsageError(err) { + t.Fatalf("invalid host regex err = %T %[1]v, want usageError", err) + } +} + +func TestPolicyGroupCommands(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 +`) + writePolicy(t, home, ` +version: 1 +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + + stdout, stderr, err := runCommandForTest(t, "policy", "group", "add", "readonly") + if err != nil { + t.Fatalf("policy group add err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "added policy group readonly") { + t.Fatalf("group add stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "group", "rule", "add", "readonly", "--cmd-regex", "^uptime$", "--action", "allow", "--priority", "5") + if err != nil { + t.Fatalf("policy group rule add err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "added policy group rule readonly[0]") { + t.Fatalf("group rule add stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "group", "ls") + if err != nil { + t.Fatalf("policy group ls err = %v stderr=%s", err, stderr) + } + for _, want := range []string{"readonly", "rules=1", "allow=1", "deny=0"} { + if !strings.Contains(stdout, want) { + t.Fatalf("policy group ls missing %q:\n%s", want, stdout) + } + } + stdout, stderr, err = runCommandForTest(t, "policy", "group", "rule", "ls", "readonly") + if err != nil { + t.Fatalf("policy group rule ls err = %v stderr=%s", err, stderr) + } + for _, want := range []string{`0 priority=5`, `action=allow`, `cmd_regex="^uptime$"`} { + if !strings.Contains(stdout, want) { + t.Fatalf("policy group rule ls missing %q:\n%s", want, stdout) + } + } + + stdout, stderr, err = runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--from-group", "readonly") + if err != nil { + t.Fatalf("policy host rule add --from-group err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "stamped policy group readonly onto host web-1 (1 rule(s))") { + t.Fatalf("stamp stdout = %q", stdout) + } + var cfg policy.Config + raw := readFileString(t, filepath.Join(home, "policy.yaml")) + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("decode policy: %v\n%s", err, raw) + } + rules := cfg.HostOverrides["host:web-1"].Rules + if len(rules) != 1 || rules[0].Group != "readonly" || rules[0].Match.CmdRegex != "^uptime$" { + t.Fatalf("stamped host rules = %#v", rules) + } + + stdout, stderr, err = runCommandForTest(t, "policy", "host", "rule", "ls", "web-1") + if err != nil { + t.Fatalf("policy host rule ls err = %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, `group="readonly"`) { + t.Fatalf("host rule ls missing provenance:\n%s", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "host", "group", "rm", "web-1", "readonly") + if err != nil { + t.Fatalf("policy host group rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy host group web-1[readonly]") { + t.Fatalf("host group rm stdout = %q", stdout) + } + raw = readFileString(t, filepath.Join(home, "policy.yaml")) + if strings.Contains(raw, "group: readonly") { + t.Fatalf("stamped group still present after host group rm:\n%s", raw) + } + + stdout, stderr, err = runCommandForTest(t, "policy", "group", "rule", "rm", "readonly", "0") + if err != nil { + t.Fatalf("policy group rule rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy group rule readonly[0]") { + t.Fatalf("group rule rm stdout = %q", stdout) + } + stdout, stderr, err = runCommandForTest(t, "policy", "group", "rm", "readonly") + if err != nil { + t.Fatalf("policy group rm err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "removed policy group readonly") { + t.Fatalf("group rm stdout = %q", stdout) + } + + if _, _, err = runCommandForTest(t, "policy", "group", "rule", "add", "missing", "--cmd-regex", "^ls", "--action", "allow"); err == nil || !isUsageError(err) { + t.Fatalf("missing group err = %T %[1]v, want usageError", err) + } + if _, _, err = runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--from-group", "missing"); err == nil || !isUsageError(err) { + t.Fatalf("stamp missing group err = %T %[1]v, want usageError", err) + } + if _, _, err = runCommandForTest(t, "policy", "host", "rule", "add", "web-1", "--from-group", "readonly", "--cmd-regex", "^ls", "--action", "allow"); err == nil || !isUsageError(err) { + t.Fatalf("from-group mutual exclusion err = %T %[1]v, want usageError", err) + } +} + +func TestAuditRepairTruncatesBrokenTail(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writeTestPolicy(t, home) + t.Setenv("AGENTSSH_HOME", home) + + store := audit.NewStore(filepath.Join(home, "audit.log")) + for _, record := range []audit.Record{ + {ReqID: "r1", Event: audit.EventCompleted, Host: "web-1"}, + {ReqID: "r2", Event: audit.EventCompleted, Host: "web-1"}, + {ReqID: "r3", Event: audit.EventCompleted, Host: "web-1"}, + } { + if _, err := store.Append(record); err != nil { + t.Fatalf("append audit: %v", err) + } + } + records := mustReadAudit(t, home) + records[1].Host = "evil" + writeAuditRecords(t, filepath.Join(home, "audit.log"), records) + + stdout, stderr, err := runCommandForTest(t, "audit", "repair", "--truncate-broken") + if err != nil { + t.Fatalf("audit repair err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + for _, want := range []string{"audit repaired", "broken_seq=1", "reason=hash", "kept=1", "removed=2", "backup="} { + if !strings.Contains(stdout, want) { + t.Fatalf("repair stdout missing %q:\n%s", want, stdout) + } + } + repaired := mustReadAudit(t, home) + if len(repaired) != 1 || repaired[0].ReqID != "r1" { + t.Fatalf("repaired records = %#v", repaired) + } + if result, err := store.Verify(); err != nil || !result.OK || result.Count != 1 { + t.Fatalf("verify repaired = %#v err=%v", result, err) + } + if _, _, err = runCommandForTest(t, "audit", "repair"); err == nil || !isUsageError(err) { + t.Fatalf("repair without flag err = %T %[1]v, want usageError", err) + } +} + func TestInventoryAddDoesNotOverwriteMalformedInventory(t *testing.T) { home := t.TempDir() bad := "::: not: yaml: [" @@ -422,7 +907,9 @@ func runJSONShapeDemo(t *testing.T) (string, string, string, string) { t.Helper() home := t.TempDir() writeTestInventory(t, home) + writeTestPolicy(t, home) t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") restoreExecutor := newExecutor newExecutor = func(_ *config.Config) executor.Executor { @@ -514,35 +1001,28 @@ func TestRunAllowWritesStartedAndCompleted(t *testing.T) { } } -func TestRunRefreshesSessionActivityAfterExecution(t *testing.T) { - home := t.TempDir() - writeTestInventory(t, home) - writeTestPolicy(t, home) - t.Setenv("AGENTSSH_HOME", home) - - sessionPath := filepath.Join(home, "session") - restoreExecutor := newExecutor - newExecutor = func(_ *config.Config) executor.Executor { - return staleSessionExecutor{sessionPath: sessionPath, staleAt: time.Now().UTC().Add(-31 * time.Minute)} - } - defer func() { - newExecutor = restoreExecutor - }() - - if _, _, err := runCommandForTest(t, "run", "web-1", "--", "echo", "hi"); err != nil { - t.Fatalf("first run err = %v", err) +// A run with neither --session nor AGENTSSH_SESSION is a usage error (exit 2): the +// caller must declare a session so each task maps to one auditable session. +func TestRunWithoutSessionIsUsageError(t *testing.T) { + setupHome(t) + t.Setenv("AGENTSSH_SESSION", "") // override setupHome's session + withFakeExecutor(t, fakeExecutor{}) + _, _, err := runCommandForTest(t, "run", "web-1", "--", "echo", "hi") + if got := exitCodeForError(err); got != exitUsage { + t.Fatalf("exit = %d, want %d (usage); err=%v", got, exitUsage, err) } - firstID, firstActivity := readSessionPointerForTest(t, sessionPath, "web-1") - if firstID == "" || time.Since(firstActivity) > time.Minute { - t.Fatalf("first pointer id=%q last_activity=%s", firstID, firstActivity) + if err == nil || !strings.Contains(err.Error(), "session must be declared") { + t.Fatalf("err = %v, want it to mention a missing session", err) } +} - if _, _, err := runCommandForTest(t, "run", "web-1", "--", "echo", "again"); err != nil { - t.Fatalf("second run err = %v", err) - } - secondID, _ := readSessionPointerForTest(t, sessionPath, "web-1") - if secondID != firstID { - t.Fatalf("session id changed after fresh completion update: first=%q second=%q", firstID, secondID) +// --session declared on the run satisfies the requirement without any env var. +func TestRunSessionFlagSatisfiesRequirement(t *testing.T) { + setupHome(t) + t.Setenv("AGENTSSH_SESSION", "") + withFakeExecutor(t, fakeExecutor{}) + if code, _, stderr := runExit(t, "run", "web-1", "--session", "s_flag", "--", "echo", "hi"); code != exitOK { + t.Fatalf("code = %d, want 0; stderr=%q", code, stderr) } } @@ -558,6 +1038,7 @@ hosts: `) writeTestPolicy(t, home) t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") restoreExecutor := newExecutor newExecutor = func(_ *config.Config) executor.Executor { @@ -647,7 +1128,7 @@ func TestAuditLSIncludesCommandAndOutcomeContext(t *testing.T) { "denied", "host=web-1", "session=s_test", - "policy=allow/default", + "policy=allow/rules:allow-test-commands", "policy=deny/rules:catastrophic", "exit=0", "exit=6", @@ -742,8 +1223,10 @@ func TestRunRejectsMultiLineRedactPatternAsUsage(t *testing.T) { writeTestInventory(t, home) writePolicy(t, home, ` version: 1 -defaults: - policy: allow +rules: + - name: allow-echo + match: { cmd_regex: '^echo\b' } + action: allow output: redact: - '(?s)BEGIN.*END' @@ -1058,6 +1541,19 @@ func readInventoryFile(t *testing.T, home string) inventory.Inventory { return inv } +func readPolicyFile(t *testing.T, home string) policy.Config { + t.Helper() + var cfg policy.Config + data, err := os.ReadFile(filepath.Join(home, "policy.yaml")) + if err != nil { + t.Fatalf("read policy: %v", err) + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal policy: %v\n%s", err, data) + } + return cfg +} + func readFileString(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) @@ -1071,12 +1567,15 @@ func writeTestPolicy(t *testing.T, home string) { t.Helper() writePolicy(t, home, ` version: 1 -defaults: - policy: allow rules: - name: catastrophic + priority: 100 match: { cmd_regex: 'rm\s+-rf' } action: deny + - name: allow-test-commands + priority: 0 + match: { cmd_regex: '^(echo|false|systemctl status|sudo systemctl restart|uptime|stream-secret|ok)\b' } + action: allow output: max_bytes: 24 redact: @@ -1113,6 +1612,22 @@ func mustReadAudit(t *testing.T, home string) []audit.Record { return records } +func writeAuditRecords(t *testing.T, path string, records []audit.Record) { + t.Helper() + var builder strings.Builder + for _, record := range records { + line, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal audit record: %v", err) + } + builder.Write(line) + builder.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(builder.String()), 0o600); err != nil { + t.Fatalf("write audit records: %v", err) + } +} + func formatExit(exitCode *int) string { if exitCode == nil { return "-" @@ -1191,80 +1706,6 @@ func (e osDetectingExecutor) Run(_ context.Context, request executor.Request) ex var _ executor.Executor = osDetectingExecutor{} -type staleSessionExecutor struct { - sessionPath string - staleAt time.Time -} - -func (e staleSessionExecutor) Run(_ context.Context, request executor.Request) executor.Result { - id, _ := readSessionPointerForTest(nil, e.sessionPath, request.Target.Name) - if id != "" { - writeSessionPointerForTest(nil, e.sessionPath, request.Target.Name, id, e.staleAt) - } - return executor.Result{ - Stdout: "ok\n", - ExitCode: 0, - Argv: []string{"native-ssh", request.Target.Name, request.Command}, - } -} - -var _ executor.Executor = staleSessionExecutor{} - -func readSessionPointerForTest(t *testing.T, path string, host string) (string, time.Time) { - if t != nil { - t.Helper() - } - data, err := os.ReadFile(path) - if err != nil { - if t != nil { - t.Fatalf("read session pointer: %v", err) - } - return "", time.Time{} - } - var current struct { - Hosts map[string]struct { - ID string `json:"id"` - LastActivity time.Time `json:"last_activity"` - } `json:"hosts"` - } - if err := json.Unmarshal(data, ¤t); err != nil { - if t != nil { - t.Fatalf("unmarshal session pointer: %v\n%s", err, data) - } - return "", time.Time{} - } - pointer := current.Hosts[host] - return pointer.ID, pointer.LastActivity -} - -func writeSessionPointerForTest(t *testing.T, path string, host string, id string, when time.Time) { - if t != nil { - t.Helper() - } - data, err := json.Marshal(struct { - Hosts map[string]struct { - ID string `json:"id"` - LastActivity time.Time `json:"last_activity"` - } `json:"hosts"` - }{ - Hosts: map[string]struct { - ID string `json:"id"` - LastActivity time.Time `json:"last_activity"` - }{ - host: {ID: id, LastActivity: when.UTC()}, - }, - }) - if err != nil { - if t != nil { - t.Fatalf("marshal session pointer: %v", err) - } - return - } - if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil && t != nil { - t.Fatalf("write session pointer: %v", err) - } -} - func writeInChunks(w io.Writer, data []byte, size int) { for len(data) > 0 { n := size diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 34bf1f1..f0612bf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -29,7 +29,8 @@ agentssh hosts [--json] 列出可达主机与分组(仅名字/tag,无凭据)。 agentssh run [--session ] [--session-label ] [--json] -- - 在目标上执行命令。经 policy 判定: + 在目标上执行命令。必须声明会话(--session 或 $AGENTSSH_SESSION), + 否则以 exit 2 拒绝 —— 一个任务一个会话,便于按任务审计。经 policy 判定: allow → 立即执行,返回输出与退出码 deny → 立即以 exit 6 返回,说明命中的规则 在 `--` 之后,原样作为远端单条命令。 @@ -42,11 +43,11 @@ agentssh status [--json] #### 人类面 ``` -agentssh tui 打开审计查看器(主入口) +agentssh tui 打开终端操作台(Hosts / Policy) agentssh inventory ls|edit 查看/编辑主机清单 -agentssh policy show|edit|test 查看/编辑/试判某命令(allow|deny + 命中规则) -agentssh audit ls|show |verify 浏览/查看/校验审计链 -agentssh session ls 列出近期会话(id/label/起止/命令数) +agentssh policy show|rule|group|host|edit|test 查看/管理全局规则/规则组/host 绑定/编辑/试判某命令 +agentssh audit ls|show |verify|repair 浏览/查看/校验审计链/截断损坏尾段 +agentssh session ls|new 列出近期会话 / 为新任务 mint 一个会话 id ``` ### A.3 输出格式 @@ -84,7 +85,7 @@ nginx.service - A high performance web server ``` ✗ denied by policy · web-1 · 命中规则 "catastrophic" - rm -rf 属于不可执行的危险命令;此拦截无法临场放行。 + rm -rf 命中了 deny 规则或没有命中 allow 规则;此拦截无法临场放行。 如确需放宽,请人类修改 ~/.agentssh/policy.yaml。 ``` @@ -92,7 +93,7 @@ nginx.service - A high performance web server ```json { "req_id": "f1a0", "host": "web-1", "status": "denied", - "policy_action": "deny", "policy_rule": "catastrophic", "exit_code": 6 } + "policy_action": "deny", "policy_rule": "rules:catastrophic", "exit_code": 6 } ``` > 没有「pending / 等待审批」态。Agent 不会被 AgentSSH 挂起;若 harness 在调用前要人工确认,那是 harness 的交互,AgentSSH 此时尚未被调用。 @@ -126,7 +127,17 @@ nginx.service - A high performance web server ## B. TUI 设计(人类审计查看器) -> 注:本节是 TUI 的**原始**草案(Audit 为中心)。`agentssh tui` 现已是四 Tab 操作台(Hosts/Audit/Policy/Sessions),并完成了整体 UX 重构——统一 `internal/theme` 配色 + 字形降级、持久外壳 + `bubbles/help` 底栏、实测响应式布局、Hosts 主从面板、错误/确认卡片、Toast、分组加主机表单等。设计依据、各 Tab 原型与**已实现**的分阶段路线图见 [`docs/plans/tui-redesign.md`](plans/tui-redesign.md)(下文若与该文档冲突,以该文档为准)。 +> 注:本节下方保留了 TUI 的**原始**草案(Audit 为中心)。当前 `agentssh tui` 的入口是 `Hosts / Policy` 两个顶层 Tab,Host detail 内有 Info/Sessions/Policy 三个 pane,并完成了整体 UX 重构——统一 `internal/theme` 配色 + 字形降级、持久外壳 + `bubbles/help` 底栏、实测响应式布局、Hosts 主从面板、错误/确认卡片、Toast、分组加主机表单等。设计依据、各 Tab 原型与**已实现**的分阶段路线图见 [`docs/plans/tui-redesign.md`](plans/tui-redesign.md)(下文若与该文档冲突,以该文档为准)。 + +### 当前已实现的 TUI Policy 结构 + +`agentssh tui` 的入口页是两张顶层 Tab:`1 Hosts` 与 `2 Policy`,也可用 `tab` 切换。Hosts 仍是主机卡片网格;Policy 是策略卡片网格。 + +- **Policy 顶层卡片**:`Global` 卡片代表 `Config.Rules`;每个 `rule_groups.` 也渲染为一张卡。卡片显示名称、规则数、allow/deny 计数。`enter` 打开选中卡片进入规则列表。 +- **Global / Group 规则列表**:打开卡片后,用同一套无边框表格列出规则。`a` 新增规则,`e` 编辑选中规则,`r` 删除选中规则,`esc` 回到卡片网格。规则输入格式为 `allow|deny [priority] `。 +- **规则组管理**:在 Policy 顶层按 `n` 创建 group;选中 group 卡片按 `d` 删除 group。`Global` 卡片不可删除。 +- **规则组语义**:rule group 是 authoring-only preset,执行引擎不直接读取。把 group 加到 host 时是 snapshot copy,每条复制出来的 host rule 带 `group` provenance;之后编辑 group 不会影响已复制到 host 的规则。 +- **Host detail Policy pane**:进入 host detail 后按 `3`。该 pane 不是卡片堆叠,而是一张统一无边框规则列表:先列 host tier,再列 global tier;列为 `SCOPE / PRIORITY / ACTION / COMMAND / GROUP`。host 行可编辑/删除,global 行只读上下文。`a` 新增手写 host rule,`p` 从 group picker 复制一组规则,`r` 删除选中 host rule,`R` 删除选中 provenance group 的所有 host 规则,`x` 清除该 host 的 `host:` rules。 技术:`bubbletea` + `lipgloss` + `bubbles`。**单一视图:审计流,按会话分组**(无审批界面)。`agentssh tui` 即打开它,等价于交互式的 `agentssh audit`。 @@ -137,9 +148,9 @@ nginx.service - A high performance web server │ 42 条 · 8 会话 · 链 ✓ 完整 (1..42) / 过滤 v 校验 │ ← 顶部状态条 ├───────────────────────────────────────────────────────────────────┤ │ ▾ s_91be0c "fix 502 on web-1" claude-code 08:31–08:32 4 cmd │ ← 会话头(展开) -│ 08:32:11 ✓ web-1 restart-service sudo systemctl restart nginx │ -│ allow:prod/allow_rules[1] · exit 0 · 412ms │ -│ 08:32:05 ✓ web-1 restart-service systemctl status nginx │ +│ 08:32:11 ✓ web-1 sudo systemctl restart nginx │ +│ allow:prod/rules[0] · exit 0 · 412ms │ +│ 08:32:05 ✓ web-1 systemctl status nginx │ │ allow · exit 0 · 88ms │ │ ▸ s_77a2d1 "disk cleanup db-1" claude-code 08:20–08:25 6 cmd │ ← 会话头(折叠) │ ▸ s_3c0f9a (无标签) claude-code 07:55–07:58 2 cmd │ @@ -150,7 +161,7 @@ nginx.service - A high performance web server - 顶层按**会话**倒序;会话头单行显示 `id · label · host(user@ip)`,有篡改时附一段异常提示。 - 展开后是该会话内的 run,时间倒序;状态图标:`✓` 成功 · `✗` 失败 · `⊘` 拒绝(policy deny)· `●` 执行中。 -- 每行一眼看全:**时间 · 状态 · 主机(prod 加红角标) · 手册 · 真实命令 · policy 判定 · exit/耗时**。 +- 每行一眼看全:**时间 · 状态 · 主机(prod 加红角标) · 真实命令 · policy 判定 · exit/耗时**。 - 危险/拒绝用红、prod 主机加红色 `prod` 角标、含异常的会话头标红;颜色仅作强调,信息不只靠颜色(同时有文字)。 ### B.2 详情面板 @@ -163,7 +174,7 @@ nginx.service - A high performance web server │ Session s_91be0c "fix 502" Host web-1 (deploy@10.0.0.11) │ │ Tags web, prod │ │ Command sudo systemctl restart nginx │ -│ Policy allow ← prod/allow_rules[1] │ +│ Policy allow ← prod/rules[0] │ │ Exit 0 · 412ms · truncated false · redactions 0 │ │ Output sha256 9f2b…(原文不入审计) │ │ Chain prev 00ab… hash 7d41… │ diff --git a/docs/architecture/inventory-tui.md b/docs/architecture/inventory-tui.md index 3cfeba7..aef70bc 100644 --- a/docs/architecture/inventory-tui.md +++ b/docs/architecture/inventory-tui.md @@ -1,6 +1,6 @@ # 设计 — 交互式 inventory 管理(add 表单 + ls) -> 状态:设计稿 · 2026-06-17 · 关联:`internal/inventory`、`internal/config`、`cmd/agentssh`(`newInventoryCommand` 当前是占位桩) +> 状态:设计稿 · 2026-06-17 · 关联:`internal/inventory`、`internal/config`、`cmd/agentssh` > > 目标:让人类**不必手写 YAML** 就能加主机 —— `agentssh inventory add` 在终端里弹出一个 bubbletea 表单填字段、写回 `~/.agentssh/inventory.yaml`;`agentssh inventory ls` 真实现(operator 全字段视图)。**这是人类侧配置管理,与只读审计 TUI(`agentssh tui`)分开**,放在 `inventory` 子命令下。 @@ -9,10 +9,13 @@ - `agentssh inventory add [name] [--addr --user --port --alias --tags]` - **TTY**:弹 bubbletea **加主机表单**(用任何已给的 flag/positional 预填)→ 提交后写 `inventory.yaml`。 - **非 TTY**(CI/管道):不弹表单,**用 flag 直接加**(可脚本化);字段不足(缺 name 且缺 addr/alias)→ `newUsageError`(exit 2)指引。 +- `agentssh inventory update [--addr --user --port --alias --tags]` + - **非 TTY**:脚本化更新已有主机字段,保留未传入字段;无字段 flag 时返回 usage error。 +- `agentssh inventory rm `:删除 inventory 中的主机,并为成功/失败删除写入审计记录。 - `agentssh inventory ls [--json]`:真实现,展示 **operator 全字段**(name/addr/user/port/alias/os/tags + groups,可附 transport/host_key_policy 头);`--json` 输出完整 `Inventory` 结构。**区别于** agent 面的 `hosts`(只 Name+Tags、凭据无关,走 `Public()`)。 - `inventory edit`:保留为指向手改文件的占位(或删除)。本设计不做完整编辑器。 -不做:删除/编辑主机的 TUI(可后续 `inventory rm` / 表单编辑)、group 管理、注释保留(见 §4 取舍)。 +不做:group 管理、注释保留(见 §4 取舍)。 ## 2. 加主机表单(`internal/hostform`,bubbletea v1) @@ -70,4 +73,4 @@ struct round-trip 会:丢失 YAML 注释、丢失结构体未建模的未知字 ## 7. 不做(未来) -`inventory rm` / 表单内编辑已有 host / group 管理 / YAML 注释保留(`yaml.Node`)/ 把 add 折进审计 TUI(故意不做,保持审计只读)。 +group 管理 / YAML 注释保留(`yaml.Node`)/ 把 add 折进审计 TUI(故意不做,保持审计只读)。 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8502960..8c32952 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -89,18 +89,32 @@ groups: ```yaml version: 1 -defaults: - policy: allow # 未匹配任何规则的命令默认放行(deny-list 黑名单模型) -rules: # 自上而下,首个命中生效 +rules: # 全局规则;priority 越大越先判定,同 priority 按文件顺序 - name: catastrophic + priority: 100 match: { cmd_regex: '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel|:\(\)\s*\{)' } action: deny + - name: readonly + priority: 0 + match: { cmd_regex: '^(systemctl status|journalctl|tail|cat|df|ps)\b' } + action: allow host_overrides: - prod: # 对 prod 组切 allowlist:默认拒绝,只放行白名单 - policy: deny - allow_rules: - - { cmd_regex: '^(systemctl status|journalctl|tail|cat|df|ps)\b' } - - { cmd_regex: '^sudo systemctl (status|restart|reload) [a-z0-9-]+$' } + prod: # 组规则;命中 prod tag 的 host 进入 host tier + rules: + - priority: 50 + match: { cmd_regex: '^sudo systemctl (restart|reload) [a-z0-9-]+$' } + action: allow + host:web-1: # 单台 host 规则;由 `policy host rule ...` / TUI 管理 + rules: + - priority: 10 + match: { cmd_regex: '^systemctl status\b' } + action: allow +rule_groups: # 可复用预设;仅用于编辑/复制,引擎不直接读取 + readonly: + rules: + - priority: 10 + match: { cmd_regex: '^(uptime|whoami)\b' } + action: allow output: max_bytes: 16384 redact: # 命中即替换为 «REDACTED» @@ -109,16 +123,22 @@ output: - '(?i)(password|passwd|secret|token)\s*[=:]\s*\S+' ``` -判定规则(简化为二元): -1. 取目标主机所属组的 override;若该组 `policy: deny`(allowlist 模式)→ 命中 `allow_rules` 才放行,否则 `deny`。 -2. 否则按全局 `rules` 顺序匹配:命中 `action: deny` → 拒绝。 -3. 未命中 → `defaults.policy`(MVP = `allow`)。 -4. `deny` 一律拒绝,**不弹任何「是否允许」**(连人都不能临场覆盖,防 Agent 诱导疲劳的人放行)。需放宽只能改 policy。 +`host_overrides` 支持两类 key:`` 按 inventory group/tag 匹配,`host:` 只匹配该 host。`agentssh policy host rule ...` 和 TUI 的 Host detail Policy pane 只写 `host:` host rules,不修改 inventory 的 tags/groups。 -> 设计取舍:default=`allow` 是有意识地把灰区交给 harness 交互确认,换取「general run」的灵活与零审批复杂度。**硬安全底线 = deny 黑名单**(全局)+ 可选 allowlist(按组,如 prod)。这套在 headless 下依然成立,而 harness 审批不成立 —— 这正是网关相对「直接给 ssh」的核心价值。 +`rule_groups` 是**编排/编辑层**概念,不是执行层概念。Policy Engine 完全忽略 `rule_groups`,也忽略 `Rule.group` 字段;只有当人类执行 `agentssh policy host rule add --from-group ` 或在 TUI Host detail Policy pane 里选择预设时,该组当前的 rules 会被**快照复制**到 `host_overrides["host:"].rules`。复制出的每条 rule 带 `group: ` provenance,用于 UI/CLI 展示和 `policy host group rm ` 批量移除。之后编辑 rule group 不会自动传播到已经 stamp 到 host 的副本。 + +判定规则(二层、首个命中生效): +1. 先取 host tier: `host_overrides["host:"].rules` 加上该 host 按 tag 命中的所有 group 规则,合并后按 `priority` 降序判定;同 priority 按文件顺序。host tier 永远高于 global tier,所以 host 上 `priority: 0` 的 allow 也能覆盖 global 里 `priority: 100` 的 deny。 +2. 再取 global tier:全局 `rules` 按 `priority` 降序判定;同 priority 按文件顺序。 +3. 两层都未命中 → `deny`。这是不可配置的最终兜底,不再有 `defaults.policy`。 +4. `deny` 一律拒绝,**不弹任何「是否允许」**(连人都不能临场覆盖,防 Agent 诱导疲劳的人放行)。需放宽只能添加/修改 allow 规则。 + +> 设计取舍:default=`deny` 把执行能力变成显式授权:开箱即拒绝所有命令,直到人类写入 allow 规则。硬安全底线不再只是 deny 黑名单,而是「无匹配即拒绝」;host tier 先于 global tier,便于给单台机器或某组机器写更具体的例外。 > > 局限:基于命令字符串的匹配是启发式,不是沙箱;复合命令(`sh -c '…'`、管道)可能绕过模式。缓解见 `docs/plans/mvp.md` R1。 +TUI 展示规则与引擎顺序保持一致:Host detail 的 Policy pane 是一张无边框统一列表,先列所有 host-tier 规则(`host:` 与命中的 inventory group override,按 priority 降序),再列 global 规则。列包含 `scope / priority / action / command / group`;global 行在这里只读,用于让人类看到上下文而不在 host 详情里误改全局策略。 + ## 4. 命令生命周期(`run`) ``` @@ -160,7 +180,8 @@ run web-1 -- sudo systemctl restart nginx "host": "web-1", "cmd": "sudo systemctl restart nginx", "policy_action": "allow", - "policy_rule": "prod/allow_rules[1]", + "policy_rule": "prod/rules[0]", + "error": "", "exit_code": 0, "output_sha256": "9f2b…", "output_truncated": false, @@ -171,8 +192,10 @@ run web-1 -- sudo systemctl restart nginx ``` - `event` ∈ `started | completed | failed | denied`。`deny` 记一条 `denied`(无 exit_code)。 +- `error` 是可选的人类可读异常说明,主要用于本地管理操作的失败记录;常规 `run` 记录为空。 - harness 的交互审批不由 AgentSSH 记录(在 harness 自己的日志里);AgentSSH 只记自己看到与执行的事实。 - hash 链:`hash = SHA256(prev_hash || canonical_json(record_without_hash))`。第 0 条 `prev_hash` 全零。`agentssh audit verify` 从头重算,任一行被改/删/插都会断链。 +- 修复只允许 `agentssh audit repair --truncate-broken`:从第一条断链记录开始截断到文件末尾,并先写 `audit.log.bak`。不支持任意删除单条审计记录。 设计取舍: - 记录 `output_sha256` 而非全文,避免审计膨胀并防敏感输出二次落盘;需要时可单独开「完整输出归档」选项。 @@ -184,16 +207,24 @@ run web-1 -- sudo systemctl restart nginx 会话 = `session_id`(+ 可空 `session_label`)。每条 audit 记录都带 `session_id`。 +**会话由调用方显式声明,系统不猜任务边界。** AgentSSH 无法从命令或时间推断「一个任务从哪里开始、到哪里结束」,所以不做任何基于时间窗口的自动归并——否则同一主机上几分钟内的两个不相干任务会被并进同一个会话,破坏「按任务复盘」。 + `session_id` 解析顺序(首个非空命中): -1. `agentssh run --session ` 显式指定 —— Agent/手册可自行生成一个 id 贯穿整个任务。 -2. 环境变量 `$AGENTSSH_SESSION` —— harness/人类设一次,子调用继承。 -3. 回退:读 `~/.agentssh/session`(记录「当前会话」id + 最后活动时间)。若距上次活动在空闲窗口内(默认 30m)→ 复用;否则新建 id 并写回。 +1. `agentssh run --session ` 显式指定 —— 单次 run 覆盖,优先级最高。 +2. 环境变量 `$AGENTSSH_SESSION` —— harness/skill 在任务开始时设一次,该任务内所有 `run` 继承。 +3. 两者都没有 → **报错(exit 2)**,提示声明一个会话。绝不新建或复用一个「猜」出来的 id。 + +约定:**一个任务一个会话**。skill/harness 在每个任务起始 mint 一个新 id 并导出: + +```bash +export AGENTSSH_SESSION=$(agentssh session new) # 形如 s_1a2b3c4d +``` -效果:自觉穿线的 Agent 得到精确分组;不穿线的也能按「时间窗口」自动分组。 +`agentssh session new` 只是打印一个新随机 id(`internal/session.NewID`),不落任何状态。下一个任务再 mint 一个新的,天然区分。 `--session-label "fix 502 on web-1"` 可给会话一个人类可读标签(首次出现时记入会话元数据,贯穿后续记录)。 -> 局限:回退用的 `~/.agentssh/session` 文件在多 Agent 并发下会串(MVP 假定单操作者交互式,可接受);需要严格隔离时用 `--session` / 环境变量显式指定。 +> 设计取舍:旧版有个「读 `~/.agentssh/session`、30 分钟空闲窗内复用」的回退,已移除——它在多任务/多 Agent 下会把不同任务串成一个会话,且那个指针文件并发下会写串。现在会话纯由调用方声明,无持久化状态,严格隔离是默认行为。 ## 7. 凭据处理 @@ -225,11 +256,11 @@ agentssh/ policy/ # 规则匹配、allow/deny、override、脱敏 executor/ # SSH shell-out、捕获 stdout/stderr/exit audit/ # JSONL 写入、hash 链、verify - session/ # 会话解析(--session / env / 空闲窗口)、聚合 + session/ # 会话解析(--session / env,必须声明)、id 生成、聚合 output/ # 脱敏 + 截断 tui/ # bubbletea:审计查看器 approval/ # (预留,MVP 空)未来 out-of-band 同步审批 - skills/ # 示例外部操作手册(SKILL.md),供 Agent 参考 + skills/ # AgentSSH 使用手册(SKILL.md):最佳实践,供 Agent 参考 docs/ ``` @@ -245,4 +276,4 @@ agentssh/ | 敏感输出进模型 | Output Filter | 脱敏 + 截断,审计只存 hash | | 事后无法追责/抵赖 | Audit | hash 链 append-only,`verify` 可验完整性 | | 越权访问未知主机 | Resolver | 仅允许 inventory 中枚举的主机/分组 | -| prod 上误操作 | Policy override | `prod` 组切 allowlist(default deny) | +| prod 上误操作 | Policy override | `prod` 组添加更具体的 allow/deny 规则;未匹配默认拒绝 | diff --git a/docs/architecture/unified-tui.md b/docs/architecture/unified-tui.md index fa4a97c..9e5d8cf 100644 --- a/docs/architecture/unified-tui.md +++ b/docs/architecture/unified-tui.md @@ -11,10 +11,10 @@ - **4 个分区**: - **Hosts**:列表(operator 全字段)+ 添加(嵌入 `hostform` 表单)+ 删除(确认),写回 `inventory.yaml`。 - **Audit**:现有审计查看器 model 作为分区(按会话折叠、详情、过滤、校验链)。 - - **Policy**:展示 `policy.yaml` + 交互式 test 一条命令看 allow/deny + 命中规则。 +- **Policy**:展示 `policy.yaml` + 交互式 test 一条命令看 allow/deny + 命中规则;在 host detail 的 Policy pane 支持 `host:` 绑定 CRUD(默认 allow/deny、allow regex 增删、删除绑定)。 - **Sessions**:列出会话(id/label/起止/命令数),回车跳到 Audit 分区并按该 session 过滤。 - `agentssh inventory add/ls`、`agentssh audit/policy/session/hosts` 等 CLI 命令**保持不变**(app 是额外的统一界面,与命令共享底层)。 -- 不做:run 命令(agent 侧操作,不进人类控制台)、多人/权限、policy 编辑(只读 + test)。 +- 不做:run 命令(agent 侧操作,不进人类控制台)、多人/权限、任意 YAML policy 编辑(全局 rule 仍走 CLI;TUI 只管理 host detail 的 `host:` 绑定)。 ## 2. 使能重构(app 的前置) @@ -79,7 +79,7 @@ type Options struct { Paths config.Paths } // app 自己按 Paths 加载 inven - **Hosts**(新 `hostsSection`):`inventory.Load(Paths.InventoryFile)` → 列表(name/addr/user/port/alias/tags,排序);按键 `a`=add(切到内嵌 `hostform.New(...)`,`Done()` 后 `AddHost`+`Save`+重载)、`d`/`x`=remove(确认弹窗 → `RemoveHost`+`Save`+重载)、`j/k` 导航。capturing()=表单激活时 true。 - **Audit**(复用 2c 的审计 model):capturing()=过滤态。 -- **Policy**(新 `policySection`):上半区渲染 `cfg.Policy`(defaults/rules/host_overrides/output);下半区一个 textinput 输 `host:cmd` 或 cmd,回车 `engine.Evaluate` 显示 `allow/deny · rule=…`。engine = `policy.NewEngine(cfg.Policy, cfg.Inventory)`。capturing()=test 输入聚焦时。 +- **Policy**(新 `policySection`):上半区渲染 `cfg.Policy`(defaults/rules/host_overrides/output);下半区一个 textinput 输 `host:cmd` 或 cmd,回车 `engine.Evaluate` 显示 `allow/deny · rule=…`。host detail 模式额外渲染当前 host 的 `host:` 绑定并提供 `s/a/r/x` 管理。engine = `policy.NewEngine(cfg.Policy, cfg.Inventory)`。capturing()=test/input/confirm 聚焦时。 - **Sessions**(新 `sessionsSection`):`session.Summaries(audit.NewStore(Paths.AuditFile).ReadAll())` 列表;回车 → root 切 Audit + 过滤该 session。capturing()=false。 ## 5. 非 TTY @@ -96,4 +96,4 @@ type Options struct { Paths config.Paths } // app 自己按 Paths 加载 inven ## 7. 不做(未来) -run/exec 进 TUI、policy 编辑、group 管理、多人、主题/配置持久化、鼠标。 +run/exec 进 TUI、任意 YAML policy 编辑、group 管理、多人、主题/配置持久化、鼠标。 diff --git a/docs/plans/mvp.md b/docs/plans/mvp.md index 129a9b4..efe53d6 100644 --- a/docs/plans/mvp.md +++ b/docs/plans/mvp.md @@ -43,9 +43,9 @@ - [ ] `internal/policy`:规则匹配、`allow|deny`、`defaults.policy`、`host_overrides`(支持切 allowlist)。 - [ ] `policy show` / `policy test `(打印命中规则 + allow|deny 判定)。 - [ ] `internal/audit`:JSONL 追加、hash 链、`audit ls|show|verify`;记录含 `session_id`/`session_label`。 -- [ ] 会话:解析 `--session` / `$AGENTSSH_SESSION` / `~/.agentssh/session`(空闲 30m 新建)→ 贯穿审计;`session ls`。 +- [ ] 会话:解析 `--session` / `$AGENTSSH_SESSION`(必须声明,否则 exit 2)→ 贯穿审计;`session ls` / `session new`。 - [ ] `run` 接入 policy:`allow` 执行、`deny` 拒绝(exit 6,记 denied)。 -- [ ] 单测:规则顺序/override/allowlist、链构造、篡改检测、`deny` 路径、会话解析顺序与空闲窗口。 +- [ ] 单测:规则顺序/override/allowlist、链构造、篡改检测、`deny` 路径、会话解析顺序与「未声明即报错」。 ### M3 TUI 审计查看器 - [ ] `internal/tui`:单一审计视图 —— **按会话分组/折叠** + run 详情面板 + 会话过滤 + 校验链(见 DESIGN §B)。 @@ -54,7 +54,7 @@ ### M4 输出控制 + 手册 - [ ] `internal/output`:按 policy 脱敏 + 截断;审计记 redactions/truncated。 -- [ ] `skills/restart-service/SKILL.md`、`skills/investigate-cpu/SKILL.md` 示例。 +- [ ] `skills/agentssh-usage/SKILL.md`:AgentSSH 使用手册 / 最佳实践(信任边界、会话纪律、策略、输出、审计)。 - [ ] README + 文档串联。 ### M5 加固/打磨 @@ -69,7 +69,7 @@ agentssh/ go.mod cmd/agentssh/main.go internal/{config,inventory,policy,executor,audit,output,tui}/ # approval 预留(MVP 空) - skills/ # 示例手册(M4 填充) + skills/ # AgentSSH 使用手册(M4 填充) docs/ # 已就位:prds/ architecture/ plans/ DESIGN.md .github/workflows/ci.yml README.md diff --git a/docs/prds/agentssh.md b/docs/prds/agentssh.md index 714c4c7..387aba7 100644 --- a/docs/prds/agentssh.md +++ b/docs/prds/agentssh.md @@ -27,10 +27,10 @@ AI Agent 越来越多地需要在真实服务器上做运维(查日志、重启 ## 4. 核心概念(术语) -- **操作手册 / playbook**:可用 Anthropic Agent Skills 形式的 `SKILL.md` + 配套文件表达。人类编写的*程序性知识*,教 Agent「某类运维任务怎么做」。它是外部知识,不是 AgentSSH 的 RPC、权限或审计主体。属于「软控制」—— 影响 Agent 会尝试什么,但不负责安全。 +- **操作手册 / skill**:可用 Anthropic Agent Skills 形式的 `SKILL.md` + 配套文件表达。人类编写的*程序性知识*,教 Agent「怎么用好 AgentSSH」—— 信任边界、会话纪律、策略、输出克制、审计复盘,而非某台服务器上某个应用的具体操作流程。它是外部知识,不是 AgentSSH 的 RPC、权限或审计主体。属于「软控制」—— 影响 Agent 会尝试什么,但不负责安全。 - **CLI(`agentssh`)**:Agent 实际调用的工具,也是**信任边界**。握凭据、跑 policy、做审批、写审计。属于「硬控制」—— 安全只依赖这一层。 - **Inventory**:人类维护的主机/分组清单(及其连接方式、凭据引用)。 -- **Policy**:人类配置的规则,决定一条命令是自动放行 / 需审批 / 拒绝。 +- **Policy**:人类配置的规则,决定一条命令是 `allow` 执行或 `deny` 拒绝;AgentSSH 自身不提供“需审批”中间态。 - **Audit log**:append-only、hash 链的全量操作记录。 关系:**手册告诉 Agent「怎么做」,CLI 是「拿来做」的那只受约束的手。再坏或被注入的手册也越不过 CLI 的 policy 和审计。** @@ -71,19 +71,19 @@ AI Agent 越来越多地需要在真实服务器上做运维(查日志、重启 - **FR1 Inventory**:人类可定义主机与分组及其 SSH 连接方式;Agent 可列出主机名(不含凭据)。 - **FR2 执行**:Agent 可 `agentssh run -- `;CLI 经 SSH 在目标主机执行并返回结果。 -- **FR3 Policy(allow/deny)**:每条命令被判定为 `allow`(执行)或 `deny`(硬拦截、不可临场覆盖、headless 亦生效);未匹配默认 `allow`(deny-list 模型),可按主机组改为 allowlist(default deny)。 +- **FR3 Policy(allow/deny)**:每条命令被判定为 `allow`(执行)或 `deny`(硬拦截、不可临场覆盖、headless 亦生效);未匹配任何规则一律默认 `deny`。规则带 `action` 与 `priority`,先判定 host tier(`host:` 与匹配 group 的规则合并),再判定 global tier;host tier 永远高于 global tier。 - **FR4 交互确认(委托)**:AgentSSH 不做同步审批、不阻塞等待人工裁决;灰区命令的交互式 allow/deny 由 agent harness(如 Claude Code 权限系统)负责。 - **FR5 审计**:每次请求(含命令、目标、决策、退出码、输出摘要/hash)写入 hash 链日志;提供查询与校验。 - **FR6 输出控制**:回流给 Agent 的输出按规则脱敏、按上限截断。 - **FR7 能力发现**:Agent 可 `agentssh hosts` 列主机;外部操作手册由 Agent 自身的 skill 机制发现。 - **FR8 请求查询**:Agent 可 `agentssh status ` 从审计中查询某次执行的结果(退出码/是否被 deny)。 -- **FR9 会话分组**:相关的一连串 `run` 归入同一 `session_id`(显式 `--session` / `$AGENTSSH_SESSION` / 空闲 30m 的时间窗口自动);审计可按会话浏览与复盘,会话可带 `session_label`。 +- **FR9 会话分组**:相关的一连串 `run` 归入同一 `session_id`,由调用方显式声明(`--session` 或 `$AGENTSSH_SESSION`;`agentssh session new` 可 mint 一个 id)。一个任务一个会话;未声明则 `run` 报错(exit 2),系统不按时间窗口自动归并,以免不同任务串台。审计可按会话浏览与复盘,会话可带 `session_label`。 ## 8. 关键用户故事 -- **U1(排障)**:nginx 在 web-1 返 502。Agent 加载 `restart-service` 手册 → `run web-1 -- systemctl status nginx` → 发现挂了 → `run web-1 -- sudo systemctl restart nginx`;该命令未命中 `deny`,harness 弹权限确认、人类同意 → 执行成功 → 全程入审计。人类事后用 `agentssh tui` 复盘。 +- **U1(排障)**:nginx 在 web-1 返 502。人类先为本次允许的诊断/修复命令配置 allow 规则。Agent 按 `agentssh-usage` 使用手册的最佳实践:`agentssh hosts` 确认目标 → `export AGENTSSH_SESSION=$(agentssh session new)` 为本次任务开一个会话 → 只读诊断 `run web-1 -- systemctl status nginx` → 发现挂了 → 改动前先小结 → `run web-1 -- sudo systemctl restart nginx`;命中 allow 后执行成功 → 全程入同一会话的审计。人类事后用 `agentssh tui` 复盘。 - **U2(审计回溯)**:人类周一早上 `agentssh tui` 按**会话**(每个任务一组)浏览周末 Agent 的全部操作,展开可疑会话细看,确认没有越界,并校验审计链完整。 -- **U3(收紧策略)**:人类把一批明确危险的命令加入 `deny`,并把 `prod` 组切成 allowlist(default deny),只放行白名单内的命令。 +- **U3(收紧策略)**:人类为 `prod` 组添加高优先级 deny 规则与必要 allow 规则;未命中的命令天然 default-deny,无需再把某组“切换”为 allowlist 模式。 ## 9. MVP 范围 @@ -91,7 +91,7 @@ AI Agent 越来越多地需要在真实服务器上做运维(查日志、重启 - inventory.yaml(主机/分组)、policy.yaml(allow/deny 规则 + 脱敏)、audit.log(JSONL hash 链) - 凭据走 ssh-agent / 系统 ssh(不自存私钥) - CLI 动词:`run` / `hosts` / `status`(Agent 面);`tui` / `inventory` / `policy` / `audit`(人类面) -- Policy 硬拦截(`deny`)+ 全量审计 —— **无同步审批**,交互确认交给 harness +- Policy 默认拒绝 + `deny` 硬拦截 + 全量审计 —— **无同步审批**,交互确认交给 harness - 终端 TUI:纯审计查看器(浏览 + 过滤 + 校验链) - 随仓库附 1~2 个示例手册 diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 5a43a94..83ca252 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -87,6 +87,50 @@ func TestVerifyDetectsChangedDeletedInsertedRecords(t *testing.T) { }) } +func TestTruncateBrokenRemovesBrokenTailAndBacksUp(t *testing.T) { + store := testStoreWithRecords(t) + records := mustRead(t, store) + records[1].Host = "evil" + writeRecords(t, store.Path, records) + + result, err := store.TruncateBroken() + if err != nil { + t.Fatalf("TruncateBroken: %v", err) + } + if !result.Changed || result.Kept != 1 || result.Removed != 2 || result.BrokenSeq != 1 || result.Reason != "hash" { + t.Fatalf("repair result = %#v", result) + } + repaired := mustRead(t, store) + if len(repaired) != 1 || repaired[0].ReqID != "r1" { + t.Fatalf("repaired records = %#v", repaired) + } + verify, err := store.Verify() + if err != nil { + t.Fatalf("verify repaired: %v", err) + } + if !verify.OK || verify.Count != 1 { + t.Fatalf("verify repaired = %#v", verify) + } + backup, err := os.ReadFile(result.BackupPath) + if err != nil { + t.Fatalf("read backup: %v", err) + } + if !strings.Contains(string(backup), `"host":"evil"`) { + t.Fatalf("backup missing original corrupted record:\n%s", backup) + } +} + +func TestTruncateBrokenNoopsWhenChainOK(t *testing.T) { + store := testStoreWithRecords(t) + result, err := store.TruncateBroken() + if err != nil { + t.Fatalf("TruncateBroken ok chain: %v", err) + } + if result.Changed || result.Kept != 3 || result.Removed != 0 { + t.Fatalf("repair result = %#v", result) + } +} + func testStoreWithRecords(t *testing.T) Store { t.Helper() store := NewStore(filepath.Join(t.TempDir(), "audit.log")) diff --git a/internal/audit/record.go b/internal/audit/record.go index 5f5fb20..28e5a87 100644 --- a/internal/audit/record.go +++ b/internal/audit/record.go @@ -2,6 +2,7 @@ package audit import ( "bufio" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -37,6 +38,7 @@ type Record struct { Cmd string `json:"cmd"` PolicyAction string `json:"policy_action"` PolicyRule string `json:"policy_rule"` + Error string `json:"error,omitempty"` ExitCode *int `json:"exit_code,omitempty"` OutputSHA256 string `json:"output_sha256"` OutputTruncated bool `json:"output_truncated"` @@ -58,6 +60,15 @@ func NewStore(path string) Store { return Store{Path: path} } +// NewReqID returns a short request id for auditable local operations. +func NewReqID() (string, error) { + var bytes [3]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", fmt.Errorf("generate request id: %w", err) + } + return hex.EncodeToString(bytes[:]), nil +} + // Append adds one record to the hash chain, assigning seq, prev_hash, and hash. func (s Store) Append(record Record) (Record, error) { if err := os.MkdirAll(filepath.Dir(s.Path), 0o700); err != nil { @@ -140,6 +151,61 @@ func readRecords(file *os.File) ([]Record, error) { return records, nil } +func writeRecordsAtomic(path string, records []Record) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create audit directory: %w", err) + } + file, err := os.CreateTemp(dir, "audit-*.log") + if err != nil { + return fmt.Errorf("create temporary audit log: %w", err) + } + tempName := file.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return fmt.Errorf("chmod temporary audit log: %w", err) + } + for _, record := range records { + line, err := json.Marshal(record) + if err != nil { + _ = file.Close() + return fmt.Errorf("marshal audit record: %w", err) + } + if _, err := file.Write(append(line, '\n')); err != nil { + _ = file.Close() + return fmt.Errorf("write audit record: %w", err) + } + } + if err := file.Close(); err != nil { + return fmt.Errorf("close temporary audit log: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + return fmt.Errorf("replace audit log: %w", err) + } + cleanup = false + return nil +} + +func copyFileIfExists(src string, dst string) error { + data, err := os.ReadFile(src) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read audit backup source: %w", err) + } + if err := os.WriteFile(dst, data, 0o600); err != nil { + return fmt.Errorf("write audit backup: %w", err) + } + return nil +} + // Verify recalculates the hash chain from the beginning of the log. func (s Store) Verify() (VerifyResult, error) { records, err := s.ReadAll() @@ -170,6 +236,53 @@ type VerifyResult struct { Reason string } +// RepairResult describes a destructive audit-log repair. +type RepairResult struct { + Changed bool + Kept int + Removed int + BrokenSeq uint64 + Reason string + BackupPath string +} + +// TruncateBroken removes the first broken record and every later record. +// +// This is intentionally narrower than arbitrary audit deletion: after a hash +// chain break, later records no longer have independently verifiable ancestry. +func (s Store) TruncateBroken() (RepairResult, error) { + records, err := s.ReadAll() + if err != nil { + return RepairResult{}, err + } + result, err := s.Verify() + if err != nil { + return RepairResult{}, err + } + if result.OK { + return RepairResult{Changed: false, Kept: len(records)}, nil + } + cut := int(result.BrokenSeq) + if cut < 0 || cut > len(records) { + cut = len(records) + } + backup := s.Path + ".bak" + if err := copyFileIfExists(s.Path, backup); err != nil { + return RepairResult{}, err + } + if err := writeRecordsAtomic(s.Path, records[:cut]); err != nil { + return RepairResult{}, err + } + return RepairResult{ + Changed: true, + Kept: cut, + Removed: len(records) - cut, + BrokenSeq: result.BrokenSeq, + Reason: result.Reason, + BackupPath: backup, + }, nil +} + // Filters narrows audit list results. type Filters struct { Host string @@ -223,6 +336,7 @@ type canonicalRecord struct { Cmd string `json:"cmd"` PolicyAction string `json:"policy_action"` PolicyRule string `json:"policy_rule"` + Error string `json:"error,omitempty"` ExitCode *int `json:"exit_code,omitempty"` OutputSHA256 string `json:"output_sha256"` OutputTruncated bool `json:"output_truncated"` @@ -243,6 +357,7 @@ func canonicalJSON(record Record) ([]byte, error) { Cmd: record.Cmd, PolicyAction: record.PolicyAction, PolicyRule: record.PolicyRule, + Error: record.Error, ExitCode: record.ExitCode, OutputSHA256: record.OutputSHA256, OutputTruncated: record.OutputTruncated, diff --git a/internal/config/config.go b/internal/config/config.go index 6ba8008..6d86856 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,19 +30,38 @@ hosts: {} ` // seedPolicyYAML is written on first run when policy.yaml is absent. It ships a -// safe starting point: allow by default, deny a handful of catastrophic -// commands, and redact obvious secrets from output. 'deny' is a hard boundary -// the agent cannot override. -const seedPolicyYAML = `# AgentSSH policy — allow/deny rules + output filtering. -# 'deny' is a hard boundary the agent cannot override. See docs/architecture/overview.md. +// default-deny scaffold with no active rules; operators must add explicit allow +// rules before an agent can execute commands. +const seedPolicyYAML = `# AgentSSH policy — priority-ordered allow/deny rules + output filtering. +# No command runs until an allow rule matches. See docs/architecture/overview.md. version: 1 -defaults: - policy: allow -rules: - - name: catastrophic - match: - cmd_regex: '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel)' - action: deny +# rules: +# - name: allow-readonly +# priority: 10 +# match: +# cmd_regex: '^(echo|uptime|whoami)\b' +# action: allow +# - name: deny-dangerous +# priority: 100 +# match: +# cmd_regex: '\b(rm\s+-rf|mkfs|dd|shutdown|reboot|init\s+0|userdel)' +# action: deny +# host_overrides: +# host:web-1: +# rules: +# - priority: 20 +# match: +# cmd_regex: '^systemctl status\b' +# action: allow +# rule_groups: +# readonly: +# rules: +# - priority: 10 +# match: +# cmd_regex: '^(uptime|whoami)\b' +# action: allow +# Use 'agentssh policy host rule add web-1 --from-group readonly' to copy a +# group's current rules onto a host. Later group edits do not change the host copy. output: max_bytes: 16384 redact: @@ -55,7 +74,6 @@ type Paths struct { InventoryFile string PolicyFile string AuditFile string - SessionFile string SecretsFile string } @@ -172,7 +190,6 @@ func NewPaths(home string) Paths { InventoryFile: filepath.Join(home, "inventory.yaml"), PolicyFile: filepath.Join(home, "policy.yaml"), AuditFile: filepath.Join(home, "audit.log"), - SessionFile: filepath.Join(home, "session"), SecretsFile: filepath.Join(home, "secrets.enc"), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8c3f6bc..2f27782 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,8 +5,6 @@ import ( "os" "path/filepath" "testing" - - "github.com/Praeviso/AgentSSH/internal/policy" ) func TestLoadMissingHome(t *testing.T) { @@ -87,11 +85,8 @@ func TestEnsureHomeCreatesAndSeeds(t *testing.T) { if cfg.Inventory.Transport != "native" { t.Fatalf("seeded transport = %q, want native", cfg.Inventory.Transport) } - if cfg.Policy.Defaults.Policy != policy.ActionAllow { - t.Fatalf("default policy = %q, want allow", cfg.Policy.Defaults.Policy) - } - if len(cfg.Policy.Rules) == 0 || cfg.Policy.Rules[0].Action != policy.ActionDeny { - t.Fatalf("seeded policy missing catastrophic deny rule: %+v", cfg.Policy.Rules) + if len(cfg.Policy.Rules) != 0 || len(cfg.Policy.HostOverrides) != 0 { + t.Fatalf("seeded policy should have zero active rules: %+v", cfg.Policy) } } diff --git a/internal/hostform/hostform.go b/internal/hostform/hostform.go index b3e3cf1..fe27bf0 100644 --- a/internal/hostform/hostform.go +++ b/internal/hostform/hostform.go @@ -35,6 +35,8 @@ type Options struct { Alias string IdentityFile string ExistingNames map[string]struct{} + FixedName bool + Title string } // Result is the normalized submitted form value. @@ -252,20 +254,27 @@ func newStyles(r *lipgloss.Renderer) styles { // fieldLabels are the display labels indexed by field. var fieldLabels = []string{"name", "addr", "user", "port", "tags", "ssh_config_alias", "identity_file", "password"} +// requiredFields are marked with a * so the operator sees what's mandatory before +// submitting. addr is technically required only when no ssh_config_alias is set +// (see validateValues), but it's marked because it's needed in the common case. +var requiredFields = map[field]bool{fieldName: true, fieldAddr: true} + // fieldWidths are the textinput widths indexed by field, sized for the grouped // layout (short fields pair on one row). var fieldWidths = []int{18, 44, 12, 5, 22, 20, 38, 24} // Model is the embeddable add-host form model. type Model struct { - inputs []textinput.Model - focus field - errs []string - keys keyMap - styles styles - existing map[string]struct{} - result Result - done bool + inputs []textinput.Model + focus field + errs []string + keys keyMap + styles styles + existing map[string]struct{} + fixedName bool + title string + result Result + done bool // w, h are the live render budget (the section body the shell allocates). They // drive responsive field widths and the height scroll window; 0 means "size // unknown" and the form falls back to its natural fixed widths (see @@ -302,14 +311,25 @@ func newModel(opts Options, st styles) Model { } inputs[i] = ti } - _ = inputs[fieldName].Focus() + focus := fieldName + if opts.FixedName { + focus = fieldAddr + } + _ = inputs[focus].Focus() + title := opts.Title + if title == "" { + title = "Add inventory host" + } return Model{ - inputs: inputs, - keys: defaultKeys(), - styles: st, - errs: make([]string, fieldCount), - existing: opts.ExistingNames, + inputs: inputs, + focus: focus, + keys: defaultKeys(), + styles: st, + errs: make([]string, fieldCount), + existing: opts.ExistingNames, + fixedName: opts.FixedName, + title: title, } } @@ -364,6 +384,9 @@ func (m Model) View() string { in.Width = widths[f] var b strings.Builder b.WriteString(m.styles.label.Render(fieldLabels[f])) + if requiredFields[f] { + b.WriteString(m.styles.warn.Render(" *")) + } b.WriteString("\n") b.WriteString(in.View()) if m.errs[f] != "" { @@ -388,7 +411,7 @@ func (m Model) View() string { lines = append(lines, strings.Split(block, "\n")...) } - add(m.fit(m.styles.title, "Add inventory host")) + add(m.fit(m.styles.title, m.title)) add("") add(m.groupHeader("Connection")) addRow(joinFields(fieldBlock(fieldName), fieldBlock(fieldUser), fieldBlock(fieldPort)), fieldName, fieldUser, fieldPort) @@ -405,7 +428,7 @@ func (m Model) View() string { add(m.fit(m.styles.warn, m.styles.glyphs.Warn+" AGENTSSH_MASTER_PASSWORD not set — the password won't be saved.")) } add("") - add(m.fit(m.styles.help, "tab/down next · shift+tab/up prev · enter submit · esc cancel")) + add(m.fit(m.styles.help, "tab/down next · shift+tab/up prev · enter submit · esc cancel · * required")) // Height window: when the form is taller than the budget, scroll it so the // focused field stays visible instead of letting the bottom clip off-screen @@ -615,6 +638,9 @@ func (m Model) move(delta int) (tea.Model, tea.Cmd) { m.validateField(m.focus) m.inputs[m.focus].Blur() next := (int(m.focus) + delta + int(fieldCount)) % int(fieldCount) + if m.fixedName && field(next) == fieldName { + next = (next + delta + int(fieldCount)) % int(fieldCount) + } m.focus = field(next) cmd := m.inputs[m.focus].Focus() return m, cmd diff --git a/internal/inventory/store.go b/internal/inventory/store.go index fa73a1c..990ea97 100644 --- a/internal/inventory/store.go +++ b/internal/inventory/store.go @@ -104,6 +104,21 @@ func RemoveHost(inv Inventory, name string) (Inventory, error) { return next, nil } +// UpdateHost returns a copy of inv with name replaced by host. +func UpdateHost(inv Inventory, name string, host Host) (Inventory, error) { + if _, ok := inv.Hosts[name]; !ok { + return inv, ErrHostNotFound + } + next := inv + next.Hosts = copyHosts(inv.Hosts) + next.Groups = copyGroups(inv.Groups) + if next.Version == 0 { + next.Version = 1 + } + next.Hosts[name] = host + return next, nil +} + // SetHostOS returns a copy of inv with the host OS metadata replaced. func SetHostOS(inv Inventory, name string, osName string) (Inventory, error) { host, ok := inv.Hosts[name] diff --git a/internal/inventory/store_test.go b/internal/inventory/store_test.go index 8a9e01a..6d7294c 100644 --- a/internal/inventory/store_test.go +++ b/internal/inventory/store_test.go @@ -69,6 +69,27 @@ func TestRemoveHost(t *testing.T) { } } +func TestUpdateHost(t *testing.T) { + inv := Inventory{Hosts: map[string]Host{ + "old": {Addr: "10.0.0.10", User: "deploy", OS: "linux"}, + "new": {Addr: "10.0.0.11"}, + }} + next, err := UpdateHost(inv, "old", Host{Addr: "10.0.0.12", User: "admin", OS: "linux"}) + if err != nil { + t.Fatalf("UpdateHost: %v", err) + } + if got := next.Hosts["old"]; got.Addr != "10.0.0.12" || got.User != "admin" || got.OS != "linux" { + t.Fatalf("updated host = %#v", got) + } + if inv.Hosts["old"].Addr != "10.0.0.10" { + t.Fatalf("UpdateHost mutated original = %#v", inv) + } + _, err = UpdateHost(inv, "missing", Host{Addr: "10.0.0.12"}) + if !errors.Is(err, ErrHostNotFound) { + t.Fatalf("missing err = %v, want ErrHostNotFound", err) + } +} + func TestSaveLoadRoundTripAndOmitEmptyFields(t *testing.T) { path := filepath.Join(t.TempDir(), "nested", "inventory.yaml") inv := Inventory{ diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 450ecdc..62d9f7c 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -4,8 +4,10 @@ import ( "fmt" "regexp" "sort" + "strings" "github.com/Praeviso/AgentSSH/internal/inventory" + "gopkg.in/yaml.v3" ) // Action is the binary policy action used by AgentSSH. @@ -22,25 +24,39 @@ type Decision struct { Rule string `json:"rule,omitempty"` } +// EffectiveRule is a display/editing projection of rules in the same tier order +// the engine uses: host tier first, then global tier. Index is the file-order +// index inside Source. +type EffectiveRule struct { + Scope string + Source string + Index int + Rule Rule +} + // Config is the parsed policy.yaml document. type Config struct { Version int `yaml:"version" json:"version"` - Defaults Defaults `yaml:"defaults" json:"defaults"` Rules []Rule `yaml:"rules" json:"rules"` + RuleGroups map[string]RuleGroup `yaml:"rule_groups" json:"rule_groups"` HostOverrides map[string]HostOverride `yaml:"host_overrides" json:"host_overrides"` Output Output `yaml:"output" json:"output"` + hostOrder []string } -// Defaults contains the fallback policy action. -type Defaults struct { - Policy Action `yaml:"policy" json:"policy"` +// RuleGroup is an authoring-only preset. The engine ignores it; callers stamp a +// snapshot copy onto a concrete host override when they want it to take effect. +type RuleGroup struct { + Rules []Rule `yaml:"rules" json:"rules"` } // Rule is a named first-match policy rule. type Rule struct { - Name string `yaml:"name" json:"name"` - Match Match `yaml:"match" json:"match"` - Action Action `yaml:"action" json:"action"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + Match Match `yaml:"match" json:"match"` + Action Action `yaml:"action" json:"action"` + Group string `yaml:"group,omitempty" json:"group,omitempty"` } // Match contains command matching criteria. @@ -48,10 +64,9 @@ type Match struct { CmdRegex string `yaml:"cmd_regex" json:"cmd_regex"` } -// HostOverride contains group-specific default and allowlist rules. +// HostOverride contains host- or group-scoped rules. type HostOverride struct { - Policy Action `yaml:"policy" json:"policy"` - AllowRules []Match `yaml:"allow_rules" json:"allow_rules"` + Rules []Rule `yaml:"rules" json:"rules"` } // Output contains output filtering policy. @@ -68,39 +83,57 @@ type Engine interface { // NewEngine compiles a policy config against an inventory. func NewEngine(config Config, inv inventory.Inventory) (Engine, error) { compiled := &compiledEngine{ - config: config.withDefaults(), + config: config.withMaps(), inventory: inv, + hostRules: map[string][]compiledRule{}, } for i, rule := range compiled.config.Rules { expr, err := compileRegex(rule.Match.CmdRegex, fmt.Sprintf("rules[%d]", i)) if err != nil { return nil, err } - compiled.rules = append(compiled.rules, compiledRule{ - name: rule.Name, - action: normalizeAction(rule.Action), - regex: expr, + action, err := validateAction(rule.Action, fmt.Sprintf("rules[%d]", i)) + if err != nil { + return nil, err + } + source := fmt.Sprintf("rules[%d]", i) + if rule.Name != "" { + source = "rules:" + rule.Name + } + compiled.globalRules = append(compiled.globalRules, compiledRule{ + source: source, + priority: rule.Priority, + action: action, + regex: expr, }) } + sortRules(compiled.globalRules) + for groupName, override := range compiled.config.HostOverrides { - for i, rule := range override.AllowRules { - expr, err := compileRegex(rule.CmdRegex, fmt.Sprintf("%s/allow_rules[%d]", groupName, i)) + for i, rule := range override.Rules { + source := fmt.Sprintf("%s/rules[%d]", groupName, i) + expr, err := compileRegex(rule.Match.CmdRegex, source) if err != nil { return nil, err } - compiled.allowRules = append(compiled.allowRules, compiledAllowRule{ - group: groupName, - index: i, - regex: expr, + action, err := validateAction(rule.Action, source) + if err != nil { + return nil, err + } + compiled.hostRules[groupName] = append(compiled.hostRules[groupName], compiledRule{ + source: source, + priority: rule.Priority, + action: action, + regex: expr, }) } } return compiled, nil } -func (c Config) withDefaults() Config { - if c.Defaults.Policy == "" { - c.Defaults.Policy = ActionAllow +func (c Config) withMaps() Config { + if c.RuleGroups == nil { + c.RuleGroups = map[string]RuleGroup{} } if c.HostOverrides == nil { c.HostOverrides = map[string]HostOverride{} @@ -108,83 +141,180 @@ func (c Config) withDefaults() Config { return c } -type compiledEngine struct { - config Config - inventory inventory.Inventory - rules []compiledRule - allowRules []compiledAllowRule +// UnmarshalYAML decodes policy.yaml while preserving the file order of +// host_overrides. That order is only a tie-breaker before priority sorting; rule +// groups remain authoring-only and are not part of engine evaluation. +func (c *Config) UnmarshalYAML(value *yaml.Node) error { + type configYAML struct { + Version int `yaml:"version"` + Rules []Rule `yaml:"rules"` + RuleGroups map[string]RuleGroup `yaml:"rule_groups"` + HostOverrides map[string]HostOverride `yaml:"host_overrides"` + Output Output `yaml:"output"` + } + var raw configYAML + if err := value.Decode(&raw); err != nil { + return err + } + *c = Config{ + Version: raw.Version, + Rules: raw.Rules, + RuleGroups: raw.RuleGroups, + HostOverrides: raw.HostOverrides, + Output: raw.Output, + hostOrder: hostOverrideOrder(value), + } + return nil } -type compiledRule struct { - name string - action Action - regex *regexp.Regexp +func hostOverrideOrder(value *yaml.Node) []string { + if value == nil || value.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(value.Content); i += 2 { + if value.Content[i].Value != "host_overrides" { + continue + } + node := value.Content[i+1] + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + order := make([]string, 0, len(node.Content)/2) + for j := 0; j+1 < len(node.Content); j += 2 { + order = append(order, node.Content[j].Value) + } + return order + } + return nil } -type compiledAllowRule struct { - group string - index int - regex *regexp.Regexp +type compiledEngine struct { + config Config + inventory inventory.Inventory + globalRules []compiledRule + hostRules map[string][]compiledRule +} + +type compiledRule struct { + source string + priority int + action Action + regex *regexp.Regexp } func (e *compiledEngine) Evaluate(host string, command string) (Decision, error) { - groups := e.hostGroups(host) - var allowlistRule string - for _, groupName := range groups { - override := e.config.HostOverrides[groupName] - if normalizeAction(override.Policy) != ActionDeny { - continue - } - if rule, ok := e.matchAllowRule(groupName, command); ok { - if allowlistRule == "" { - allowlistRule = rule - } - continue + for _, rule := range e.hostRulesFor(host) { + if rule.regex.MatchString(command) { + return Decision{Action: rule.action, Rule: rule.source}, nil } - return Decision{Action: ActionDeny, Rule: groupName + "/default_deny"}, nil - } - if allowlistRule != "" { - return Decision{Action: ActionAllow, Rule: allowlistRule}, nil } - for _, rule := range e.rules { + for _, rule := range e.globalRules { if rule.regex.MatchString(command) { - ruleName := rule.name - if ruleName == "" { - ruleName = rule.regex.String() - } - return Decision{Action: rule.action, Rule: "rules:" + ruleName}, nil + return Decision{Action: rule.action, Rule: rule.source}, nil } } - return Decision{Action: normalizeAction(e.config.Defaults.Policy), Rule: "default"}, nil + return Decision{Action: ActionDeny, Rule: "default-deny"}, nil } -func (e *compiledEngine) hostGroups(hostName string) []string { - host, ok := e.inventory.Hosts[hostName] - if !ok { - return nil +func (e *compiledEngine) hostRulesFor(hostName string) []compiledRule { + matches := map[string]struct{}{} + if _, ok := e.config.HostOverrides[HostRulesKey(hostName)]; ok { + matches[HostRulesKey(hostName)] = struct{}{} + } + if host, ok := e.inventory.Hosts[hostName]; ok { + for groupName, group := range e.inventory.Groups { + if _, hasOverride := e.config.HostOverrides[groupName]; hasOverride && hostHasAllTags(host.Tags, group.Tags) { + matches[groupName] = struct{}{} + } + } + } + + keys := e.config.orderedHostOverrideKeys(matches) + rules := make([]compiledRule, 0) + for _, key := range keys { + rules = append(rules, e.hostRules[key]...) } - var groups []string - for groupName, group := range e.inventory.Groups { - if hostHasAllTags(host.Tags, group.Tags) { - groups = append(groups, groupName) + sortRules(rules) + return rules +} + +// EffectiveRules returns the host/global rule projection in engine evaluation +// order. RuleGroups are intentionally ignored; stamped copies are visible only +// through HostOverrides with Rule.Group provenance. +func EffectiveRules(config Config, inv inventory.Inventory, hostName string) []EffectiveRule { + config = config.withMaps() + out := make([]EffectiveRule, 0) + if strings.TrimSpace(hostName) != "" { + matches := map[string]struct{}{} + if _, ok := config.HostOverrides[HostRulesKey(hostName)]; ok { + matches[HostRulesKey(hostName)] = struct{}{} + } + if host, ok := inv.Hosts[hostName]; ok { + for groupName, group := range inv.Groups { + if _, hasOverride := config.HostOverrides[groupName]; hasOverride && hostHasAllTags(host.Tags, group.Tags) { + matches[groupName] = struct{}{} + } + } + } + hostRows := make([]EffectiveRule, 0) + for _, source := range config.orderedHostOverrideKeys(matches) { + override := config.HostOverrides[source] + for i, rule := range override.Rules { + hostRows = append(hostRows, EffectiveRule{ + Scope: "host", + Source: source, + Index: i, + Rule: rule, + }) + } } + sortEffectiveRules(hostRows) + out = append(out, hostRows...) } - sort.Strings(groups) - return groups + + globalRows := make([]EffectiveRule, 0, len(config.Rules)) + for i, rule := range config.Rules { + globalRows = append(globalRows, EffectiveRule{ + Scope: "global", + Source: "global", + Index: i, + Rule: rule, + }) + } + sortEffectiveRules(globalRows) + out = append(out, globalRows...) + return out +} + +func sortEffectiveRules(rules []EffectiveRule) { + sort.SliceStable(rules, func(i, j int) bool { + return rules[i].Rule.Priority > rules[j].Rule.Priority + }) } -func (e *compiledEngine) matchAllowRule(groupName string, command string) (string, bool) { - for _, rule := range e.allowRules { - if rule.group != groupName { +func (c Config) orderedHostOverrideKeys(filter map[string]struct{}) []string { + seen := map[string]struct{}{} + keys := make([]string, 0, len(filter)) + for _, key := range c.hostOrder { + if _, ok := filter[key]; !ok { continue } - if rule.regex.MatchString(command) { - return fmt.Sprintf("%s/allow_rules[%d]", groupName, rule.index), true + if _, ok := seen[key]; ok { + continue } + keys = append(keys, key) + seen[key] = struct{}{} } - return "", false + var missing []string + for key := range filter { + if _, ok := seen[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + return append(keys, missing...) } func compileRegex(pattern string, source string) (*regexp.Regexp, error) { @@ -198,11 +328,21 @@ func compileRegex(pattern string, source string) (*regexp.Regexp, error) { return expr, nil } -func normalizeAction(action Action) Action { - if action == "" { - return ActionAllow +func validateAction(action Action, source string) (Action, error) { + switch action { + case ActionAllow, ActionDeny: + return action, nil + case "": + return "", fmt.Errorf("policy %s is missing action", source) + default: + return "", fmt.Errorf("policy %s has invalid action %q", source, action) } - return action +} + +func sortRules(rules []compiledRule) { + sort.SliceStable(rules, func(i, j int) bool { + return rules[i].priority > rules[j].priority + }) } func hostHasAllTags(hostTags []string, groupTags []string) bool { diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 6325666..476ea80 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -6,116 +6,239 @@ import ( "github.com/Praeviso/AgentSSH/internal/inventory" ) -func TestEvaluateRulesAndDefault(t *testing.T) { +func TestEvaluateHostTierBeforeGlobalPriority(t *testing.T) { engine := newTestEngine(t, Config{ - Defaults: Defaults{Policy: ActionAllow}, Rules: []Rule{ - {Name: "first", Match: Match{CmdRegex: "safe"}, Action: ActionAllow}, - {Name: "catastrophic", Match: Match{CmdRegex: `rm\s+-rf`}, Action: ActionDeny}, + { + Name: "global-deny-status", + Priority: 100, + Match: Match{CmdRegex: `^systemctl status\b`}, + Action: ActionDeny, + }, }, - }, inventory.Inventory{}) + HostOverrides: map[string]HostOverride{ + HostRulesKey("web-1"): { + Rules: []Rule{{ + Priority: 0, + Match: Match{CmdRegex: `^systemctl status\b`}, + Action: ActionAllow, + }}, + }, + }, + }, inventory.Inventory{ + Hosts: map[string]inventory.Host{ + "web-1": {Tags: []string{"prod"}}, + }, + }) - decision, err := engine.Evaluate("", "rm -rf /") + decision, err := engine.Evaluate("web-1", "systemctl status nginx") if err != nil { t.Fatalf("Evaluate: %v", err) } - if decision.Action != ActionDeny || decision.Rule != "rules:catastrophic" { + if decision.Action != ActionAllow || decision.Rule != "host:web-1/rules[0]" { t.Fatalf("decision = %#v", decision) } - - decision, err = engine.Evaluate("", "uptime") - if err != nil { - t.Fatalf("Evaluate default: %v", err) - } - if decision.Action != ActionAllow || decision.Rule != "default" { - t.Fatalf("default decision = %#v", decision) - } } -func TestEvaluateOverrideAllowlist(t *testing.T) { +func TestEvaluatePriorityOrderingWithinTier(t *testing.T) { engine := newTestEngine(t, Config{ - Defaults: Defaults{Policy: ActionAllow}, + Rules: []Rule{ + { + Name: "low-allow", + Priority: 0, + Match: Match{CmdRegex: `^deploy\b`}, + Action: ActionAllow, + }, + { + Name: "high-deny", + Priority: 50, + Match: Match{CmdRegex: `^deploy\b`}, + Action: ActionDeny, + }, + }, HostOverrides: map[string]HostOverride{ "prod": { - Policy: ActionDeny, - AllowRules: []Match{ - {CmdRegex: `^systemctl status\b`}, + Rules: []Rule{ + { + Priority: 1, + Match: Match{CmdRegex: `^uptime$`}, + Action: ActionAllow, + }, + { + Priority: 5, + Match: Match{CmdRegex: `^uptime$`}, + Action: ActionDeny, + }, }, }, }, }, inventory.Inventory{ Hosts: map[string]inventory.Host{ - "web-1": {Tags: []string{"web", "prod"}}, + "web-1": {Tags: []string{"prod"}}, }, Groups: map[string]inventory.Group{ "prod": {Tags: []string{"prod"}}, }, }) - decision, err := engine.Evaluate("web-1", "systemctl status nginx") + decision, err := engine.Evaluate("web-1", "uptime") if err != nil { - t.Fatalf("Evaluate allowlist allow: %v", err) + t.Fatalf("Evaluate host priority: %v", err) } - if decision.Action != ActionAllow || decision.Rule != "prod/allow_rules[0]" { - t.Fatalf("allowlist allow decision = %#v", decision) + if decision.Action != ActionDeny || decision.Rule != "prod/rules[1]" { + t.Fatalf("host priority decision = %#v", decision) } - decision, err = engine.Evaluate("web-1", "cat /etc/passwd") + decision, err = engine.Evaluate("web-2", "deploy now") if err != nil { - t.Fatalf("Evaluate allowlist deny: %v", err) + t.Fatalf("Evaluate global priority: %v", err) } - if decision.Action != ActionDeny || decision.Rule != "prod/default_deny" { - t.Fatalf("allowlist deny decision = %#v", decision) + if decision.Action != ActionDeny || decision.Rule != "rules:high-deny" { + t.Fatalf("global priority decision = %#v", decision) } } -func TestEvaluateMultiGroupDenyWins(t *testing.T) { +func TestEvaluateTieUsesFileOrder(t *testing.T) { engine := newTestEngine(t, Config{ - Defaults: Defaults{Policy: ActionAllow}, + Rules: []Rule{ + {Name: "first", Priority: 10, Match: Match{CmdRegex: `^echo\b`}, Action: ActionAllow}, + {Name: "second", Priority: 10, Match: Match{CmdRegex: `^echo\b`}, Action: ActionDeny}, + }, HostOverrides: map[string]HostOverride{ - "prod": { - Policy: ActionDeny, - AllowRules: []Match{ - {CmdRegex: `^systemctl status\b`}, + HostRulesKey("web-1"): { + Rules: []Rule{ + {Priority: 0, Match: Match{CmdRegex: `^id$`}, Action: ActionDeny}, + {Priority: 0, Match: Match{CmdRegex: `^id$`}, Action: ActionAllow}, }, }, - "web": {Policy: ActionAllow}, }, }, inventory.Inventory{ - Hosts: map[string]inventory.Host{ - "web-1": {Tags: []string{"web", "prod"}}, + Hosts: map[string]inventory.Host{"web-1": {}}, + }) + + decision, err := engine.Evaluate("web-1", "id") + if err != nil { + t.Fatalf("Evaluate host tie: %v", err) + } + if decision.Action != ActionDeny || decision.Rule != "host:web-1/rules[0]" { + t.Fatalf("host tie decision = %#v", decision) + } + + decision, err = engine.Evaluate("", "echo hi") + if err != nil { + t.Fatalf("Evaluate global tie: %v", err) + } + if decision.Action != ActionAllow || decision.Rule != "rules:first" { + t.Fatalf("global tie decision = %#v", decision) + } +} + +func TestEvaluateAllowAndDenyActions(t *testing.T) { + engine := newTestEngine(t, Config{ + Rules: []Rule{ + {Name: "allow-echo", Match: Match{CmdRegex: `^echo\b`}, Action: ActionAllow}, + {Name: "deny-rm", Match: Match{CmdRegex: `rm\s+-rf`}, Action: ActionDeny}, }, - Groups: map[string]inventory.Group{ - "prod": {Tags: []string{"prod"}}, - "web": {Tags: []string{"web"}}, + }, inventory.Inventory{}) + + decision, err := engine.Evaluate("", "echo ok") + if err != nil { + t.Fatalf("Evaluate allow: %v", err) + } + if decision.Action != ActionAllow || decision.Rule != "rules:allow-echo" { + t.Fatalf("allow decision = %#v", decision) + } + + decision, err = engine.Evaluate("", "rm -rf /tmp/x") + if err != nil { + t.Fatalf("Evaluate deny: %v", err) + } + if decision.Action != ActionDeny || decision.Rule != "rules:deny-rm" { + t.Fatalf("deny decision = %#v", decision) + } +} + +func TestEvaluateDefaultDenyWhenNothingMatches(t *testing.T) { + engine := newTestEngine(t, Config{ + Rules: []Rule{{Name: "allow-echo", Match: Match{CmdRegex: `^echo\b`}, Action: ActionAllow}}, + }, inventory.Inventory{}) + + decision, err := engine.Evaluate("", "uptime") + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if decision.Action != ActionDeny || decision.Rule != "default-deny" { + t.Fatalf("default decision = %#v", decision) + } +} + +func TestEvaluateIgnoresRuleGroups(t *testing.T) { + engine := newTestEngine(t, Config{ + RuleGroups: map[string]RuleGroup{ + "readonly": { + Rules: []Rule{{Match: Match{CmdRegex: `^uptime$`}, Action: ActionAllow}}, + }, }, - }) + }, inventory.Inventory{}) - decision, err := engine.Evaluate("web-1", "cat /var/log/nginx/error.log") + decision, err := engine.Evaluate("web-1", "uptime") if err != nil { t.Fatalf("Evaluate: %v", err) } - if decision.Action != ActionDeny || decision.Rule != "prod/default_deny" { - t.Fatalf("multi-group decision = %#v", decision) + if decision.Action != ActionDeny || decision.Rule != "default-deny" { + t.Fatalf("rule group should be ignored, decision = %#v", decision) } } -func TestEvaluateAllDenyOverridesMustAllow(t *testing.T) { +func TestEvaluateIgnoresRuleGroupProvenanceField(t *testing.T) { + engine := newTestEngine(t, Config{ + Rules: []Rule{{ + Name: "allow-uptime", + Group: "readonly", + Match: Match{CmdRegex: `^uptime$`}, + Action: ActionAllow, + }}, + HostOverrides: map[string]HostOverride{ + HostRulesKey("web-1"): { + Rules: []Rule{{ + Group: "ops", + Match: Match{CmdRegex: `^id$`}, + Action: ActionDeny, + }}, + }, + }, + }, inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}) + + decision, err := engine.Evaluate("", "uptime") + if err != nil { + t.Fatalf("Evaluate global: %v", err) + } + if decision.Action != ActionAllow || decision.Rule != "rules:allow-uptime" { + t.Fatalf("global provenance decision = %#v", decision) + } + decision, err = engine.Evaluate("web-1", "id") + if err != nil { + t.Fatalf("Evaluate host: %v", err) + } + if decision.Action != ActionDeny || decision.Rule != "host:web-1/rules[0]" { + t.Fatalf("host provenance decision = %#v", decision) + } +} + +func TestEvaluateMultiGroupHostRulePooling(t *testing.T) { engine := newTestEngine(t, Config{ - Defaults: Defaults{Policy: ActionAllow}, HostOverrides: map[string]HostOverride{ "prod": { - Policy: ActionDeny, - AllowRules: []Match{{CmdRegex: `^echo\b`}}, + Rules: []Rule{{Priority: 1, Match: Match{CmdRegex: `^journalctl\b`}, Action: ActionDeny}}, }, "web": { - Policy: ActionDeny, - AllowRules: []Match{{CmdRegex: `^systemctl status\b`}}, + Rules: []Rule{{Priority: 10, Match: Match{CmdRegex: `^journalctl\b`}, Action: ActionAllow}}, }, }, + Rules: []Rule{{Name: "global-deny", Priority: 100, Match: Match{CmdRegex: `^journalctl\b`}, Action: ActionDeny}}, }, inventory.Inventory{ Hosts: map[string]inventory.Host{ - "web-1": {Tags: []string{"web", "prod"}}, + "web-1": {Tags: []string{"prod", "web"}}, }, Groups: map[string]inventory.Group{ "prod": {Tags: []string{"prod"}}, @@ -123,22 +246,40 @@ func TestEvaluateAllDenyOverridesMustAllow(t *testing.T) { }, }) - decision, err := engine.Evaluate("web-1", "echo hi") + decision, err := engine.Evaluate("web-1", "journalctl -u nginx") + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if decision.Action != ActionAllow || decision.Rule != "web/rules[0]" { + t.Fatalf("multi-group decision = %#v", decision) + } +} + +func TestEvaluateUnnamedGlobalRuleUsesIndex(t *testing.T) { + engine := newTestEngine(t, Config{ + Rules: []Rule{{Match: Match{CmdRegex: `^true$`}, Action: ActionAllow}}, + }, inventory.Inventory{}) + + decision, err := engine.Evaluate("", "true") if err != nil { t.Fatalf("Evaluate: %v", err) } - if decision.Action != ActionDeny || decision.Rule != "web/default_deny" { - t.Fatalf("all deny overrides decision = %#v", decision) + if decision.Action != ActionAllow || decision.Rule != "rules[0]" { + t.Fatalf("unnamed decision = %#v", decision) } } -func TestInvalidRegex(t *testing.T) { - _, err := NewEngine(Config{ +func TestInvalidPolicyRules(t *testing.T) { + if _, err := NewEngine(Config{ Rules: []Rule{{Name: "bad", Match: Match{CmdRegex: "["}, Action: ActionDeny}}, - }, inventory.Inventory{}) - if err == nil { + }, inventory.Inventory{}); err == nil { t.Fatal("NewEngine invalid regex error = nil") } + if _, err := NewEngine(Config{ + Rules: []Rule{{Name: "missing-action", Match: Match{CmdRegex: "^ls"}}}, + }, inventory.Inventory{}); err == nil { + t.Fatal("NewEngine missing action error = nil") + } } func newTestEngine(t *testing.T, config Config, inv inventory.Inventory) Engine { diff --git a/internal/policy/store.go b/internal/policy/store.go new file mode 100644 index 0000000..4d8d6c4 --- /dev/null +++ b/internal/policy/store.go @@ -0,0 +1,495 @@ +package policy + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Praeviso/AgentSSH/internal/inventory" + "gopkg.in/yaml.v3" +) + +var ( + ErrRuleExists = errors.New("policy rule already exists") + ErrRuleNotFound = errors.New("policy rule not found") + ErrRuleAmbiguous = errors.New("policy rule name is ambiguous") + ErrGroupExists = errors.New("policy rule group already exists") + ErrGroupNotFound = errors.New("policy rule group not found") + ErrHostNotFound = errors.New("policy host rules host not found") + ErrNoHostRules = errors.New("policy host has no rules") +) + +// HostRuleSet bundles the policy rules owned by one concrete host. It is stored +// as host_overrides["host:"] so group overrides remain unchanged while +// host-specific overrides can be distinguished reliably. +type HostRuleSet struct { + Host string + Key string + Override HostOverride + Managed bool + Effective bool +} + +// Bundle is the pair of config documents used by host rules helpers. Only the +// policy is mutated; inventory is used to validate and report whether a rule set +// currently targets an existing host. +type Bundle struct { + Policy Config + Inventory inventory.Inventory +} + +// Load decodes policy.yaml. A missing file is treated as an empty policy. +func Load(path string) (Config, error) { + var cfg Config + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + if err != nil { + return cfg, err + } + defer func() { + _ = file.Close() + }() + if err := yaml.NewDecoder(file).Decode(&cfg); err != nil { + return cfg, err + } + return cfg, nil +} + +// Save writes policy.yaml atomically with private file permissions. +func Save(path string, cfg Config) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create policy directory: %w", err) + } + if cfg.Version == 0 { + cfg.Version = 1 + } + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshal policy: %w", err) + } + file, err := os.CreateTemp(dir, "policy-*.yaml") + if err != nil { + return fmt.Errorf("create temporary policy file: %w", err) + } + tempName := file.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return fmt.Errorf("chmod temporary policy file: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write temporary policy file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close temporary policy file: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + return fmt.Errorf("replace policy file: %w", err) + } + cleanup = false + return nil +} + +// AddRule returns a copy of cfg with rule appended. +func AddRule(cfg Config, rule Rule) (Config, error) { + if rule.Name == "" { + return cfg, fmt.Errorf("policy rule name is required") + } + if _, err := ruleIndex(cfg.Rules, rule.Name); err == nil { + return cfg, ErrRuleExists + } else if errors.Is(err, ErrRuleAmbiguous) { + return cfg, err + } + next := copyConfig(cfg) + if next.Version == 0 { + next.Version = 1 + } + next.Rules = append(next.Rules, rule) + return next, nil +} + +// UpdateRule returns a copy of cfg with the named rule replaced. +func UpdateRule(cfg Config, name string, rule Rule) (Config, error) { + index, err := ruleIndex(cfg.Rules, name) + if err != nil { + return cfg, err + } + if rule.Name == "" { + return cfg, fmt.Errorf("policy rule name is required") + } + if rule.Name != name { + if _, err := ruleIndex(cfg.Rules, rule.Name); err == nil { + return cfg, ErrRuleExists + } else if errors.Is(err, ErrRuleAmbiguous) { + return cfg, err + } + } + next := copyConfig(cfg) + next.Rules[index] = rule + return next, nil +} + +// RemoveRule returns a copy of cfg without the named rule. +func RemoveRule(cfg Config, name string) (Config, error) { + index, err := ruleIndex(cfg.Rules, name) + if err != nil { + return cfg, err + } + next := copyConfig(cfg) + next.Rules = append(next.Rules[:index], next.Rules[index+1:]...) + return next, nil +} + +// HostRulesKey returns the managed policy override/group name for a host. +func HostRulesKey(host string) string { + return "host:" + strings.TrimSpace(host) +} + +// HostRuleSets returns all known host-scoped rule sets. Rule sets produced through +// these helpers are keyed by host:. Effective is false if the host no +// longer exists in inventory. +func HostRuleSets(bundle Bundle) []HostRuleSet { + names := map[string]struct{}{} + for host := range bundle.Inventory.Hosts { + names[host] = struct{}{} + } + for group := range bundle.Policy.HostOverrides { + if host, ok := strings.CutPrefix(group, "host:"); ok && host != "" { + names[host] = struct{}{} + } + } + + sorted := make([]string, 0, len(names)) + for name := range names { + if _, ok := bundle.Policy.HostOverrides[HostRulesKey(name)]; ok { + sorted = append(sorted, name) + } + } + sort.Strings(sorted) + + ruleSets := make([]HostRuleSet, 0, len(sorted)) + for _, host := range sorted { + key := HostRulesKey(host) + override := bundle.Policy.HostOverrides[key] + _, hostOK := bundle.Inventory.Hosts[host] + ruleSets = append(ruleSets, HostRuleSet{ + Host: host, + Key: key, + Override: override, + Managed: true, + Effective: hostOK, + }) + } + return ruleSets +} + +// LookupHostRules returns the host-scoped policy rules for host. +func LookupHostRules(bundle Bundle, host string) (HostRuleSet, bool) { + host = strings.TrimSpace(host) + if host == "" { + return HostRuleSet{}, false + } + key := HostRulesKey(host) + override, ok := bundle.Policy.HostOverrides[key] + if !ok { + return HostRuleSet{}, false + } + _, hostOK := bundle.Inventory.Hosts[host] + return HostRuleSet{ + Host: host, + Key: key, + Override: override, + Managed: true, + Effective: hostOK, + }, true +} + +// SetHostRules returns a copy of bundle with host-specific policy rules. +func SetHostRules(bundle Bundle, host string, override HostOverride) (Bundle, error) { + host = strings.TrimSpace(host) + if host == "" { + return bundle, fmt.Errorf("policy host name is required") + } + if _, ok := bundle.Inventory.Hosts[host]; !ok { + return bundle, ErrHostNotFound + } + next := copyBundle(bundle) + if next.Policy.Version == 0 { + next.Policy.Version = 1 + } + if next.Policy.HostOverrides == nil { + next.Policy.HostOverrides = map[string]HostOverride{} + } + key := HostRulesKey(host) + override.Rules = append([]Rule(nil), override.Rules...) + next.Policy.HostOverrides[key] = override + return next, nil +} + +// ClearHostRules removes the host-scoped override and its AgentSSH-managed +// policy key. +func ClearHostRules(bundle Bundle, host string) (Bundle, error) { + host = strings.TrimSpace(host) + if host == "" { + return bundle, fmt.Errorf("policy host name is required") + } + key := HostRulesKey(host) + if _, ok := bundle.Policy.HostOverrides[key]; !ok { + return bundle, ErrNoHostRules + } + next := copyBundle(bundle) + delete(next.Policy.HostOverrides, key) + return next, nil +} + +// AddHostRule appends a rule to existing host rules, creating them if the host +// exists but does not yet have host-scoped rules. +func AddHostRule(bundle Bundle, host string, rule Rule) (Bundle, error) { + host = strings.TrimSpace(host) + if host == "" { + return bundle, fmt.Errorf("policy host name is required") + } + if _, ok := bundle.Inventory.Hosts[host]; !ok { + return bundle, ErrHostNotFound + } + override := HostOverride{} + if ruleSet, ok := LookupHostRules(bundle, host); ok { + override = ruleSet.Override + } + override.Rules = append(append([]Rule(nil), override.Rules...), rule) + return SetHostRules(bundle, host, override) +} + +// RemoveHostRule removes one host-scoped rule by zero-based index. +func RemoveHostRule(bundle Bundle, host string, index int) (Bundle, error) { + ruleSet, ok := LookupHostRules(bundle, host) + if !ok { + return bundle, ErrNoHostRules + } + if index < 0 || index >= len(ruleSet.Override.Rules) { + return bundle, fmt.Errorf("host rule index %d out of range", index) + } + override := ruleSet.Override + override.Rules = append([]Rule(nil), override.Rules...) + override.Rules = append(override.Rules[:index], override.Rules[index+1:]...) + return SetHostRules(bundle, host, override) +} + +// CreateGroup returns a copy of bundle with a new empty authoring rule group. +func CreateGroup(bundle Bundle, name string) (Bundle, error) { + name = strings.TrimSpace(name) + if name == "" { + return bundle, fmt.Errorf("policy rule group name is required") + } + if _, ok := bundle.Policy.RuleGroups[name]; ok { + return bundle, ErrGroupExists + } + next := copyBundle(bundle) + if next.Policy.Version == 0 { + next.Policy.Version = 1 + } + if next.Policy.RuleGroups == nil { + next.Policy.RuleGroups = map[string]RuleGroup{} + } + next.Policy.RuleGroups[name] = RuleGroup{} + return next, nil +} + +// DeleteGroup returns a copy of bundle without the named authoring rule group. +// Stamped host snapshots are intentionally left untouched. +func DeleteGroup(bundle Bundle, name string) (Bundle, error) { + name = strings.TrimSpace(name) + if name == "" { + return bundle, fmt.Errorf("policy rule group name is required") + } + if _, ok := bundle.Policy.RuleGroups[name]; !ok { + return bundle, ErrGroupNotFound + } + next := copyBundle(bundle) + delete(next.Policy.RuleGroups, name) + return next, nil +} + +// AddGroupRule appends rule to an existing authoring rule group. +func AddGroupRule(bundle Bundle, group string, rule Rule) (Bundle, error) { + group = strings.TrimSpace(group) + current, ok := bundle.Policy.RuleGroups[group] + if !ok { + return bundle, ErrGroupNotFound + } + next := copyBundle(bundle) + nextGroup := next.Policy.RuleGroups[group] + nextGroup.Rules = append(copyRules(current.Rules), rule) + next.Policy.RuleGroups[group] = nextGroup + if next.Policy.Version == 0 { + next.Policy.Version = 1 + } + return next, nil +} + +// UpdateGroupRule replaces one rule in an existing authoring rule group. +func UpdateGroupRule(bundle Bundle, group string, index int, rule Rule) (Bundle, error) { + group = strings.TrimSpace(group) + current, ok := bundle.Policy.RuleGroups[group] + if !ok { + return bundle, ErrGroupNotFound + } + if index < 0 || index >= len(current.Rules) { + return bundle, fmt.Errorf("rule group %q rule index %d out of range", group, index) + } + next := copyBundle(bundle) + nextGroup := next.Policy.RuleGroups[group] + nextGroup.Rules[index] = rule + next.Policy.RuleGroups[group] = nextGroup + return next, nil +} + +// RemoveGroupRule removes one rule from an existing authoring rule group. +func RemoveGroupRule(bundle Bundle, group string, index int) (Bundle, error) { + group = strings.TrimSpace(group) + current, ok := bundle.Policy.RuleGroups[group] + if !ok { + return bundle, ErrGroupNotFound + } + if index < 0 || index >= len(current.Rules) { + return bundle, fmt.Errorf("rule group %q rule index %d out of range", group, index) + } + next := copyBundle(bundle) + nextGroup := next.Policy.RuleGroups[group] + nextGroup.Rules = append(nextGroup.Rules[:index], nextGroup.Rules[index+1:]...) + next.Policy.RuleGroups[group] = nextGroup + return next, nil +} + +// StampGroupOntoHost snapshots every rule from groupName into the concrete +// host: override, stamping provenance on each copy. Later group edits do +// not propagate to the host snapshot. +func StampGroupOntoHost(bundle Bundle, host string, groupName string) (Bundle, error) { + host = strings.TrimSpace(host) + groupName = strings.TrimSpace(groupName) + if host == "" { + return bundle, fmt.Errorf("policy host name is required") + } + if _, ok := bundle.Inventory.Hosts[host]; !ok { + return bundle, ErrHostNotFound + } + group, ok := bundle.Policy.RuleGroups[groupName] + if !ok { + return bundle, ErrGroupNotFound + } + override := HostOverride{} + if ruleSet, ok := LookupHostRules(bundle, host); ok { + override = ruleSet.Override + } + override.Rules = copyRules(override.Rules) + for _, rule := range group.Rules { + copied := rule + copied.Group = groupName + override.Rules = append(override.Rules, copied) + } + return SetHostRules(bundle, host, override) +} + +// RemoveHostGroup removes all host snapshot rules whose provenance group matches +// groupName. The authoring rule group itself is unchanged. +func RemoveHostGroup(bundle Bundle, host string, groupName string) (Bundle, error) { + groupName = strings.TrimSpace(groupName) + // An empty group name would match every ungrouped (manually-added) rule via + // rule.Group == "", silently deleting host-specific allow/deny rules. Reject it. + if groupName == "" { + return bundle, fmt.Errorf("policy group name is required") + } + ruleSet, ok := LookupHostRules(bundle, host) + if !ok { + return bundle, ErrNoHostRules + } + override := HostOverride{Rules: make([]Rule, 0, len(ruleSet.Override.Rules))} + for _, rule := range ruleSet.Override.Rules { + if rule.Group == groupName { + continue + } + override.Rules = append(override.Rules, rule) + } + return SetHostRules(bundle, host, override) +} + +func ruleIndex(rules []Rule, name string) (int, error) { + found := -1 + for i, rule := range rules { + if rule.Name != name { + continue + } + if found >= 0 { + return -1, ErrRuleAmbiguous + } + found = i + } + if found < 0 { + return -1, ErrRuleNotFound + } + return found, nil +} + +func copyBundle(bundle Bundle) Bundle { + return Bundle{ + Policy: copyConfig(bundle.Policy), + Inventory: copyInventory(bundle.Inventory), + } +} + +func copyInventory(inv inventory.Inventory) inventory.Inventory { + next := inv + if inv.Hosts != nil { + next.Hosts = make(map[string]inventory.Host, len(inv.Hosts)) + for key, value := range inv.Hosts { + value.Tags = append([]string(nil), value.Tags...) + next.Hosts[key] = value + } + } + if inv.Groups != nil { + next.Groups = make(map[string]inventory.Group, len(inv.Groups)) + for key, value := range inv.Groups { + value.Tags = append([]string(nil), value.Tags...) + next.Groups[key] = value + } + } + return next +} + +func copyConfig(cfg Config) Config { + next := cfg + next.Rules = copyRules(cfg.Rules) + next.hostOrder = append([]string(nil), cfg.hostOrder...) + if cfg.RuleGroups != nil { + next.RuleGroups = make(map[string]RuleGroup, len(cfg.RuleGroups)) + for key, value := range cfg.RuleGroups { + value.Rules = copyRules(value.Rules) + next.RuleGroups[key] = value + } + } + if cfg.HostOverrides != nil { + next.HostOverrides = make(map[string]HostOverride, len(cfg.HostOverrides)) + for key, value := range cfg.HostOverrides { + value.Rules = copyRules(value.Rules) + next.HostOverrides[key] = value + } + } + next.Output.Redact = append([]string(nil), cfg.Output.Redact...) + return next +} + +func copyRules(rules []Rule) []Rule { + return append([]Rule(nil), rules...) +} diff --git a/internal/policy/store_test.go b/internal/policy/store_test.go new file mode 100644 index 0000000..c818dde --- /dev/null +++ b/internal/policy/store_test.go @@ -0,0 +1,310 @@ +package policy + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/Praeviso/AgentSSH/internal/inventory" +) + +func TestPolicyRuleCRUD(t *testing.T) { + cfg := Config{Rules: []Rule{{Name: "old", Match: Match{CmdRegex: "^ls"}, Action: ActionAllow}}} + next, err := AddRule(cfg, Rule{Name: "danger", Match: Match{CmdRegex: "rm -rf"}, Action: ActionDeny}) + if err != nil { + t.Fatalf("AddRule: %v", err) + } + if len(next.Rules) != 2 || next.Rules[1].Name != "danger" { + t.Fatalf("after add = %#v", next.Rules) + } + if len(cfg.Rules) != 1 { + t.Fatalf("AddRule mutated original = %#v", cfg.Rules) + } + _, err = AddRule(next, Rule{Name: "danger", Match: Match{CmdRegex: "mkfs"}, Action: ActionDeny}) + if !errors.Is(err, ErrRuleExists) { + t.Fatalf("duplicate add err = %v, want ErrRuleExists", err) + } + + updated, err := UpdateRule(next, "danger", Rule{Name: "danger", Match: Match{CmdRegex: "mkfs"}, Action: ActionDeny}) + if err != nil { + t.Fatalf("UpdateRule: %v", err) + } + if updated.Rules[1].Match.CmdRegex != "mkfs" || next.Rules[1].Match.CmdRegex != "rm -rf" { + t.Fatalf("update copy semantics: updated=%#v original=%#v", updated.Rules, next.Rules) + } + + removed, err := RemoveRule(updated, "old") + if err != nil { + t.Fatalf("RemoveRule: %v", err) + } + if len(removed.Rules) != 1 || removed.Rules[0].Name != "danger" { + t.Fatalf("after remove = %#v", removed.Rules) + } + _, err = RemoveRule(removed, "missing") + if !errors.Is(err, ErrRuleNotFound) { + t.Fatalf("missing remove err = %v, want ErrRuleNotFound", err) + } +} + +func TestPolicySaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "policy.yaml") + cfg := Config{ + Rules: []Rule{{ + Name: "danger", + Priority: 100, + Match: Match{CmdRegex: "rm -rf"}, + Action: ActionDeny, + }}, + HostOverrides: map[string]HostOverride{ + HostRulesKey("web-1"): { + Rules: []Rule{{Priority: 10, Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow, Group: "readonly"}}, + }, + }, + RuleGroups: map[string]RuleGroup{ + "readonly": { + Rules: []Rule{{Priority: 5, Match: Match{CmdRegex: "^whoami$"}, Action: ActionAllow}}, + }, + }, + Output: Output{MaxBytes: 1024, Redact: []string{"secret"}}, + } + if err := Save(path, cfg); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Version != 1 || len(loaded.Rules) != 1 || loaded.Rules[0].Name != "danger" || loaded.Rules[0].Priority != 100 || loaded.Output.Redact[0] != "secret" { + t.Fatalf("loaded = %#v", loaded) + } + if group := loaded.RuleGroups["readonly"]; len(group.Rules) != 1 || group.Rules[0].Match.CmdRegex != "^whoami$" { + t.Fatalf("loaded rule_groups = %#v", loaded.RuleGroups) + } + override := loaded.HostOverrides[HostRulesKey("web-1")] + if len(override.Rules) != 1 || override.Rules[0].Priority != 10 || override.Rules[0].Action != ActionAllow || override.Rules[0].Group != "readonly" { + t.Fatalf("loaded = %#v", loaded) + } + raw, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("Load missing: %v", err) + } + if len(raw.Rules) != 0 { + t.Fatalf("missing policy = %#v", raw) + } +} + +func TestRuleAmbiguous(t *testing.T) { + cfg := Config{Rules: []Rule{ + {Name: "dup", Match: Match{CmdRegex: "a"}, Action: ActionDeny}, + {Name: "dup", Match: Match{CmdRegex: "b"}, Action: ActionDeny}, + }} + _, err := RemoveRule(cfg, "dup") + if !errors.Is(err, ErrRuleAmbiguous) { + t.Fatalf("ambiguous err = %v", err) + } +} + +func TestHostRuleSetCRUD(t *testing.T) { + bundle := Bundle{ + Policy: Config{}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{ + "web-1": {Addr: "10.0.0.11"}, + }}, + } + + next, err := SetHostRules(bundle, "web-1", HostOverride{ + Rules: []Rule{{Priority: 5, Match: Match{CmdRegex: "^ls"}, Action: ActionAllow}}, + }) + if err != nil { + t.Fatalf("SetHostRules: %v", err) + } + if _, ok := bundle.Policy.HostOverrides[HostRulesKey("web-1")]; ok { + t.Fatalf("SetHostRules mutated original policy = %#v", bundle.Policy.HostOverrides) + } + ruleSet, ok := LookupHostRules(next, "web-1") + if !ok || ruleSet.Key != "host:web-1" || !ruleSet.Effective || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Priority != 5 { + t.Fatalf("host rules = %#v ok=%v", ruleSet, ok) + } + if _, ok := next.Inventory.Groups["host:web-1"]; ok { + t.Fatalf("host rules should not create inventory groups: %#v", next.Inventory.Groups) + } + + next, err = AddHostRule(next, "web-1", Rule{Priority: 10, Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow}) + if err != nil { + t.Fatalf("AddHostRule: %v", err) + } + ruleSet, _ = LookupHostRules(next, "web-1") + if len(ruleSet.Override.Rules) != 2 || ruleSet.Override.Rules[1].Match.CmdRegex != "^uptime$" || ruleSet.Override.Rules[1].Priority != 10 { + t.Fatalf("host rules after add = %#v", ruleSet.Override.Rules) + } + + next, err = RemoveHostRule(next, "web-1", 0) + if err != nil { + t.Fatalf("RemoveHostRule: %v", err) + } + ruleSet, _ = LookupHostRules(next, "web-1") + if len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Match.CmdRegex != "^uptime$" { + t.Fatalf("host rules after remove = %#v", ruleSet.Override.Rules) + } + + next, err = ClearHostRules(next, "web-1") + if err != nil { + t.Fatalf("ClearHostRules: %v", err) + } + if _, ok := LookupHostRules(next, "web-1"); ok { + t.Fatalf("host rules still present: %#v", next.Policy.HostOverrides) + } + _, err = SetHostRules(bundle, "missing", HostOverride{Rules: []Rule{{Match: Match{CmdRegex: "^ls"}, Action: ActionAllow}}}) + if !errors.Is(err, ErrHostNotFound) { + t.Fatalf("missing host err = %v, want ErrHostNotFound", err) + } +} + +func TestAddHostRuleCreatesHostRules(t *testing.T) { + bundle := Bundle{ + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{ + "web-1": {Addr: "10.0.0.11"}, + }}, + } + next, err := AddHostRule(bundle, "web-1", Rule{Match: Match{CmdRegex: "^whoami$"}, Action: ActionAllow}) + if err != nil { + t.Fatalf("AddHostRule: %v", err) + } + ruleSet, ok := LookupHostRules(next, "web-1") + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Match.CmdRegex != "^whoami$" { + t.Fatalf("host rules after create add = %#v ok=%v", ruleSet, ok) + } +} + +func TestRuleGroupCRUD(t *testing.T) { + bundle := Bundle{} + next, err := CreateGroup(bundle, "readonly") + if err != nil { + t.Fatalf("CreateGroup: %v", err) + } + if _, ok := next.Policy.RuleGroups["readonly"]; !ok { + t.Fatalf("group missing after create: %#v", next.Policy.RuleGroups) + } + if bundle.Policy.RuleGroups != nil { + t.Fatalf("CreateGroup mutated original: %#v", bundle.Policy.RuleGroups) + } + if _, err := CreateGroup(next, "readonly"); !errors.Is(err, ErrGroupExists) { + t.Fatalf("duplicate create err = %v, want ErrGroupExists", err) + } + + next, err = AddGroupRule(next, "readonly", Rule{Priority: 5, Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow}) + if err != nil { + t.Fatalf("AddGroupRule: %v", err) + } + if got := next.Policy.RuleGroups["readonly"].Rules; len(got) != 1 || got[0].Priority != 5 { + t.Fatalf("rules after add = %#v", got) + } + + next, err = UpdateGroupRule(next, "readonly", 0, Rule{Priority: 9, Match: Match{CmdRegex: "^whoami$"}, Action: ActionDeny}) + if err != nil { + t.Fatalf("UpdateGroupRule: %v", err) + } + if got := next.Policy.RuleGroups["readonly"].Rules[0]; got.Priority != 9 || got.Action != ActionDeny || got.Match.CmdRegex != "^whoami$" { + t.Fatalf("rule after update = %#v", got) + } + + next, err = RemoveGroupRule(next, "readonly", 0) + if err != nil { + t.Fatalf("RemoveGroupRule: %v", err) + } + if got := next.Policy.RuleGroups["readonly"].Rules; len(got) != 0 { + t.Fatalf("rules after remove = %#v", got) + } + + next, err = DeleteGroup(next, "readonly") + if err != nil { + t.Fatalf("DeleteGroup: %v", err) + } + if _, ok := next.Policy.RuleGroups["readonly"]; ok { + t.Fatalf("group still present: %#v", next.Policy.RuleGroups) + } + if _, err := DeleteGroup(next, "missing"); !errors.Is(err, ErrGroupNotFound) { + t.Fatalf("missing delete err = %v, want ErrGroupNotFound", err) + } +} + +func TestStampGroupOntoHostCopiesWithProvenance(t *testing.T) { + bundle := Bundle{ + Policy: Config{RuleGroups: map[string]RuleGroup{ + "readonly": {Rules: []Rule{ + {Priority: 5, Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow}, + {Priority: 9, Match: Match{CmdRegex: "^id$"}, Action: ActionDeny, Group: "old"}, + }}, + }}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{ + "web-1": {Addr: "10.0.0.11"}, + }}, + } + next, err := StampGroupOntoHost(bundle, "web-1", "readonly") + if err != nil { + t.Fatalf("StampGroupOntoHost: %v", err) + } + ruleSet, ok := LookupHostRules(next, "web-1") + if !ok || len(ruleSet.Override.Rules) != 2 { + t.Fatalf("host rules = %#v ok=%v", ruleSet, ok) + } + for _, rule := range ruleSet.Override.Rules { + if rule.Group != "readonly" { + t.Fatalf("stamped rule missing provenance: %#v", rule) + } + } + if _, ok := LookupHostRules(bundle, "web-1"); ok { + t.Fatalf("StampGroupOntoHost mutated original: %#v", bundle.Policy.HostOverrides) + } + + edited, err := UpdateGroupRule(next, "readonly", 0, Rule{Priority: 1, Match: Match{CmdRegex: "^changed$"}, Action: ActionDeny}) + if err != nil { + t.Fatalf("UpdateGroupRule: %v", err) + } + ruleSet, _ = LookupHostRules(edited, "web-1") + if got := ruleSet.Override.Rules[0].Match.CmdRegex; got != "^uptime$" { + t.Fatalf("stamped snapshot changed after group edit: %q", got) + } +} + +func TestRemoveHostGroupFiltersByProvenance(t *testing.T) { + bundle := Bundle{ + Policy: Config{HostOverrides: map[string]HostOverride{ + HostRulesKey("web-1"): {Rules: []Rule{ + {Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow, Group: "readonly"}, + {Match: Match{CmdRegex: "^id$"}, Action: ActionDeny}, + {Match: Match{CmdRegex: "^whoami$"}, Action: ActionAllow, Group: "readonly"}, + }}, + }}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, + } + next, err := RemoveHostGroup(bundle, "web-1", "readonly") + if err != nil { + t.Fatalf("RemoveHostGroup: %v", err) + } + ruleSet, ok := LookupHostRules(next, "web-1") + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Match.CmdRegex != "^id$" { + t.Fatalf("host rules after remove group = %#v ok=%v", ruleSet, ok) + } +} + +// An empty group name must NOT match every ungrouped rule via rule.Group == "" — +// that would silently delete host-specific allow/deny rules. It is rejected. +func TestRemoveHostGroupRejectsEmptyName(t *testing.T) { + bundle := Bundle{ + Policy: Config{HostOverrides: map[string]HostOverride{ + HostRulesKey("web-1"): {Rules: []Rule{ + {Match: Match{CmdRegex: "^id$"}, Action: ActionDeny}, // ungrouped + {Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow, Group: "readonly"}, // grouped + }}, + }}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, + } + if _, err := RemoveHostGroup(bundle, "web-1", " "); err == nil { + t.Fatal("RemoveHostGroup with a blank group name should error, not delete ungrouped rules") + } + // Original is untouched. + if got := len(bundle.Policy.HostOverrides[HostRulesKey("web-1")].Rules); got != 2 { + t.Fatalf("original rules mutated: have %d, want 2", got) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 9d49a11..3865cd8 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3,38 +3,26 @@ package session import ( "crypto/rand" "encoding/hex" - "encoding/json" "errors" "fmt" "os" - "path/filepath" "sort" - "time" "github.com/Praeviso/AgentSSH/internal/audit" ) const EnvSession = "AGENTSSH_SESSION" -const idleWindow = 30 * time.Minute +// ErrNoSession is returned when a run declares no session id. Sessions are +// caller-declared so each task maps to exactly one session that audit can review as +// a unit. AgentSSH never infers task boundaries from time (two unrelated tasks on +// the same host minutes apart would merge), so a missing id is a hard error, not a +// guess — see docs/architecture/overview.md §6.1. +var ErrNoSession = errors.New("no session declared (pass --session or set AGENTSSH_SESSION)") -// Clock supplies the current time for deterministic tests. -type Clock interface { - Now() time.Time -} - -type realClock struct{} - -func (realClock) Now() time.Time { - return time.Now().UTC() -} - -// Resolver resolves the session id for a run. -type Resolver struct { - Path string - Clock Clock - NewID func() (string, error) -} +// Resolver resolves the session id for a run. It holds no state: a session is +// whatever the caller declares, nothing is persisted between runs. +type Resolver struct{} // Context is the resolved session information for a run. A session is bound to // exactly one host: Host records which host this session id belongs to, so a @@ -45,10 +33,11 @@ type Context struct { Label string } -// Resolve binds a session id to host, following --session, AGENTSSH_SESSION, then -// the per-host current-session pointer. The pointer is keyed by host so resuming -// within the idle window never crosses hosts — a run against a different host -// always gets that host's own session, enforcing one-session-per-host. +// Resolve binds a session id to host from --session or AGENTSSH_SESSION. A run MUST +// declare one so logically distinct tasks never merge into a single audit session; +// absent both, Resolve returns ErrNoSession rather than inventing or resuming an id. +// The explicit flag wins over the env var so a single task can override a harness's +// ambient session for one run. func (r Resolver) Resolve(host string, explicitID string, label string) (Context, error) { if explicitID != "" { return Context{ID: explicitID, Host: host, Label: label}, nil @@ -56,101 +45,11 @@ func (r Resolver) Resolve(host string, explicitID string, label string) (Context if envID := os.Getenv(EnvSession); envID != "" { return Context{ID: envID, Host: host, Label: label}, nil } - - clock := r.clock() - now := clock.Now().UTC() - pointers, err := readPointers(r.Path) - if err != nil { - return Context{}, err - } - if current, ok := pointers.Hosts[host]; ok && current.ID != "" && now.Sub(current.LastActivity) <= idleWindow { - return Context{ID: current.ID, Host: host, Label: label}, nil - } - - newID, err := r.newID() - if err != nil { - return Context{}, err - } - if err := r.Update(host, newID, now); err != nil { - return Context{}, err - } - return Context{ID: newID, Host: host, Label: label}, nil + return Context{}, ErrNoSession } -// Update writes the current-session pointer activity time for host. -func (r Resolver) Update(host string, id string, when time.Time) error { - if id == "" || host == "" || r.Path == "" { - return nil - } - if err := os.MkdirAll(filepath.Dir(r.Path), 0o700); err != nil { - return fmt.Errorf("create session directory: %w", err) - } - current, err := readPointers(r.Path) - if err != nil { - return err - } - if current.Hosts == nil { - current.Hosts = map[string]pointer{} - } - current.Hosts[host] = pointer{ID: id, LastActivity: when.UTC()} - data, err := json.Marshal(current) - if err != nil { - return fmt.Errorf("marshal session pointer: %w", err) - } - // Write atomically (temp + rename) so a concurrent or interrupted write can't - // leave a torn file that readPointers would treat as empty — that would reset - // every host's pointer at once. The residual read-modify-write race between - // two processes can still lose an update, but the cost is only a fresh session - // for one host (pointers are ephemeral), never a corrupt file. - return writeFileAtomic(r.Path, append(data, '\n')) -} - -func writeFileAtomic(path string, data []byte) error { - dir := filepath.Dir(path) - file, err := os.CreateTemp(dir, "session-*.tmp") - if err != nil { - return fmt.Errorf("create temporary session file: %w", err) - } - tempName := file.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tempName) - } - }() - if err := file.Chmod(0o600); err != nil { - _ = file.Close() - return fmt.Errorf("chmod temporary session file: %w", err) - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return fmt.Errorf("write session pointer: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("close temporary session file: %w", err) - } - if err := os.Rename(tempName, path); err != nil { - return fmt.Errorf("replace session pointer: %w", err) - } - cleanup = false - return nil -} - -func (r Resolver) clock() Clock { - if r.Clock != nil { - return r.Clock - } - return realClock{} -} - -func (r Resolver) newID() (string, error) { - if r.NewID != nil { - return r.NewID() - } - return NewID() -} - -// NewID creates a short random session id. +// NewID creates a short random session id (e.g. "s_1a2b3c4d"). Callers that need a +// fresh per-task id — `agentssh session new`, a harness — use this to mint one. func NewID() (string, error) { var bytes [4]byte if _, err := rand.Read(bytes[:]); err != nil { @@ -159,40 +58,6 @@ func NewID() (string, error) { return "s_" + hex.EncodeToString(bytes[:]), nil } -type pointer struct { - ID string `json:"id"` - LastActivity time.Time `json:"last_activity"` -} - -// pointers is the on-disk current-session file: a per-host map so each host -// tracks its own active session id independently. -type pointers struct { - Hosts map[string]pointer `json:"hosts"` -} - -// readPointers loads the per-host pointer file. A missing file is an empty set. -// A file in the legacy single-pointer format (no "hosts" key) is treated as -// empty: sessions are ephemeral (30-min idle window), so starting fresh is -// harmless and avoids binding a pre-existing global session to a host. -func readPointers(path string) (pointers, error) { - if path == "" { - return pointers{}, nil - } - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return pointers{}, nil - } - if err != nil { - return pointers{}, fmt.Errorf("read session pointer: %w", err) - } - var current pointers - if err := json.Unmarshal(data, ¤t); err != nil { - // Unparseable (or legacy single-pointer) file: start fresh rather than fail. - return pointers{}, nil - } - return current, nil -} - // Summary is one aggregated session row from audit.log. A session is bound to a // single host (see Context.Host), so Host is scalar; legacy multi-host audit // data resolves to the first host seen for that session id. diff --git a/internal/session/session_test.go b/internal/session/session_test.go index e043a84..0ce3fa5 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1,10 +1,9 @@ package session import ( + "errors" "os" - "path/filepath" "testing" - "time" "github.com/Praeviso/AgentSSH/internal/audit" ) @@ -43,7 +42,9 @@ func TestSummariesBindsSingleHost(t *testing.T) { } func TestResolveOrder(t *testing.T) { - resolver := Resolver{Path: filepath.Join(t.TempDir(), "session"), NewID: fixedID("s_new")} + var resolver Resolver + // Explicit --session wins, even over an ambient env session. + t.Setenv(EnvSession, "s_env") ctx, err := resolver.Resolve("web-1", "s_explicit", "label") if err != nil { t.Fatalf("explicit resolve: %v", err) @@ -52,7 +53,7 @@ func TestResolveOrder(t *testing.T) { t.Fatalf("explicit ctx = %#v", ctx) } - t.Setenv(EnvSession, "s_env") + // With no flag, the env session is used. ctx, err = resolver.Resolve("web-1", "", "") if err != nil { t.Fatalf("env resolve: %v", err) @@ -62,66 +63,32 @@ func TestResolveOrder(t *testing.T) { } } -func TestResolveIdleWindow(t *testing.T) { - now := time.Date(2026, 6, 16, 8, 0, 0, 0, time.UTC) - path := filepath.Join(t.TempDir(), "session") - resolver := Resolver{Path: path, Clock: fixedClock{now: now}, NewID: fixedID("s_new")} - if err := resolver.Update("web-1", "s_existing", now.Add(-29*time.Minute)); err != nil { - t.Fatalf("seed pointer: %v", err) - } - ctx, err := resolver.Resolve("web-1", "", "") - if err != nil { - t.Fatalf("resolve within idle window: %v", err) - } - if ctx.ID != "s_existing" { - t.Fatalf("within idle ctx = %#v", ctx) - } - - if err := resolver.Update("web-1", "s_old", now.Add(-31*time.Minute)); err != nil { - t.Fatalf("seed old pointer: %v", err) - } - ctx, err = resolver.Resolve("web-1", "", "") - if err != nil { - t.Fatalf("resolve beyond idle window: %v", err) - } - if ctx.ID != "s_new" { - t.Fatalf("beyond idle ctx = %#v", ctx) +// A run that declares no session is a hard error: AgentSSH never invents or resumes +// an id, so logically distinct tasks can't merge into one audit session. +func TestResolveErrorsWithoutSession(t *testing.T) { + _ = os.Unsetenv(EnvSession) + var resolver Resolver + _, err := resolver.Resolve("web-1", "", "") + if !errors.Is(err, ErrNoSession) { + t.Fatalf("resolve without session: err = %v, want ErrNoSession", err) } } -// TestResolveBindsPerHost is the core one-session-per-host invariant: a live -// session for one host is never reused for a different host, even within the -// idle window — the second host gets its own fresh id. -func TestResolveBindsPerHost(t *testing.T) { - now := time.Date(2026, 6, 16, 8, 0, 0, 0, time.UTC) - path := filepath.Join(t.TempDir(), "session") - resolver := Resolver{Path: path, Clock: fixedClock{now: now}, NewID: fixedID("s_new")} - if err := resolver.Update("web-1", "s_web1", now.Add(-1*time.Minute)); err != nil { - t.Fatalf("seed web-1 pointer: %v", err) - } - // web-1 resumes its own live session. - ctx, err := resolver.Resolve("web-1", "", "") +// NewID mints distinct, well-formed ids so each task can claim a fresh session. +func TestNewIDIsFreshAndPrefixed(t *testing.T) { + a, err := NewID() if err != nil { - t.Fatalf("resolve web-1: %v", err) + t.Fatalf("NewID: %v", err) } - if ctx.ID != "s_web1" { - t.Fatalf("web-1 ctx = %#v, want s_web1", ctx) - } - // web-2 must NOT inherit web-1's session; it gets a new one bound to web-2. - ctx, err = resolver.Resolve("web-2", "", "") + b, err := NewID() if err != nil { - t.Fatalf("resolve web-2: %v", err) - } - if ctx.ID != "s_new" || ctx.Host != "web-2" { - t.Fatalf("web-2 ctx = %#v, want fresh s_new bound to web-2", ctx) + t.Fatalf("NewID: %v", err) } - // web-1's pointer is untouched by the web-2 run. - ctx, err = resolver.Resolve("web-1", "", "") - if err != nil { - t.Fatalf("re-resolve web-1: %v", err) + if a == b { + t.Fatalf("NewID returned the same id twice: %q", a) } - if ctx.ID != "s_web1" { - t.Fatalf("web-1 ctx after web-2 run = %#v, want s_web1 preserved", ctx) + if len(a) < 3 || a[:2] != "s_" { + t.Fatalf("id = %q, want an s_ prefix", a) } } @@ -143,20 +110,6 @@ func TestSummaries(t *testing.T) { } } -func fixedID(id string) func() (string, error) { - return func() (string, error) { - return id, nil - } -} - -type fixedClock struct { - now time.Time -} - -func (c fixedClock) Now() time.Time { - return c.now -} - func TestMain(m *testing.M) { _ = os.Unsetenv(EnvSession) os.Exit(m.Run()) diff --git a/internal/tui/app.go b/internal/tui/app.go index 314f513..52d7a5a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -60,15 +60,32 @@ func (c combinedHelp) FullHelp() [][]key.Binding { return append(c.section.FullHelp(), c.global) } -// screen is the top-level navigation state: the host card grid, or the -// split-pane detail of one selected host. +// screen is the top-level navigation state: the entry tabs, or the split-pane +// detail of one selected host. type screen int const ( - screenGrid screen = iota + screenEntry screen = iota screenDetail ) +const screenGrid = screenEntry + +// entryTab is the active top-level tab on the entry screen. +type entryTab int + +const ( + entryHosts entryTab = iota + entryPolicy +) + +func (t entryTab) title() string { + if t == entryPolicy { + return "Policy" + } + return "Hosts" +} + // detailPane is the active sub-view inside a host's detail screen. type detailPane int @@ -123,6 +140,7 @@ type appModel struct { firstRun bool // show the one-time welcome banner until the first keypress screen screen + entryTab entryTab pane detailPane detailHost string // the host whose detail screen is open } @@ -251,6 +269,12 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case inventoryChangedMsg: m.applyInventoryChange(msg.inventory) return m, nil + case policyChangedMsg: + m.policy.config = msg.config + if msg.text != "" { + return m, toastCmd(msg.text) + } + return m, nil case toastMsg: m.toastID++ m.toast = msg.text @@ -274,10 +298,18 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } if m.firstRun { - // "press any key to begin": consume one keystroke and re-propagate sizes - // since the banner (and its line) is now gone. + // "press any key to begin": dismiss the one-time banner and re-propagate + // sizes (its line is gone). The keystroke isn't wasted — it also performs + // its normal action so the first key gets you moving, except q, so a + // curious keypress can't quit on the greeting. m.firstRun = false - return m.propagateSize() + var sizeCmd tea.Cmd + m, sizeCmd = m.propagateSize() + if msg.String() == "q" { + return m, sizeCmd + } + next, keyCmd := m.updateKey(msg) + return next, tea.Batch(sizeCmd, keyCmd) } return m.updateKey(msg) } @@ -285,6 +317,9 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.screen == screenDetail { return m.routeDetail(msg) } + if m.entryTab == entryPolicy { + return m.updatePolicy(msg) + } return m.updateHosts(msg) } @@ -293,24 +328,47 @@ func (m appModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.screen == screenDetail { return m.updateDetailKey(msg) } - return m.updateGridKey(msg) + return m.updateEntryKey(msg) } -// updateGridKey handles a keypress on the card grid: the shell owns ?/q when the -// grid isn't capturing (a modal/text mode), everything else goes to the grid. -func (m appModel) updateGridKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if !m.hosts.capturing() { +// updateEntryKey handles a keypress on the entry screen. The shell owns +// top-level tab navigation unless the active section is capturing text/modal +// input; section-specific keys are forwarded afterward. +func (m appModel) updateEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if !m.entryCapturing() { switch msg.String() { case "?": m.help.ShowAll = !m.help.ShowAll return m.propagateSize() case "q": return m, tea.Quit + case "tab": + m.entryTab = (m.entryTab + 1) % 2 + if m.entryTab == entryPolicy { + m.policy = m.policy.withHost("") + } + return m.propagateSize() + case "shift+tab": + m.entryTab = (m.entryTab + 1) % 2 + if m.entryTab == entryPolicy { + m.policy = m.policy.withHost("") + } + return m.propagateSize() + case "1": + m.entryTab = entryHosts + return m.propagateSize() + case "2": + m.entryTab = entryPolicy + m.policy = m.policy.withHost("") + return m.propagateSize() } } + if m.entryTab == entryPolicy { + return m.updatePolicy(msg) + } next, cmd := m.updateHosts(msg) mm := next.(appModel) - // The grid signals "open this host" via hosts.open; transition to its detail. + // The Hosts tab signals "open this host" via hosts.open; transition to detail. if mm.hosts.open { mm.hosts.open = false return mm.enterDetail(mm.hosts.selectedHost()) @@ -364,7 +422,8 @@ func (m appModel) updateDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // esc pops the active pane's own state first (e.g. a session's command // detail); only when the pane is already at its root does it exit to the grid. if m.paneAtRoot() { - m.screen = screenGrid + m.screen = screenEntry + m.entryTab = entryHosts return m.propagateSize() } return m.routeDetail(msg) @@ -503,6 +562,8 @@ func (m appModel) welcomeBanner() string { } func (m *appModel) applyInventoryChange(inv inventory.Inventory) { + m.hosts.inventory = inv + m.hosts.rebuildNames() m.sessions.hosts = hostMetaFromInventory(inv) m.policy.inventory = inv // A successful inventory change means inventory.yaml now parses, so clear a @@ -511,7 +572,8 @@ func (m *appModel) applyInventoryChange(inv inventory.Inventory) { // If the host whose detail is open was removed, fall back to the grid. if m.screen == screenDetail { if _, ok := inv.Hosts[m.detailHost]; !ok { - m.screen = screenGrid + m.screen = screenEntry + m.entryTab = entryHosts } } } @@ -526,6 +588,11 @@ func (m appModel) View() string { if m.ready { if m.screen == screenDetail { body = m.renderDetail() + } else if m.entryTab == entryPolicy { + // The pane is already scoped to host "" whenever this tab is active (the + // tab-switch handlers call withHost("")). Render it as-is — calling + // withHost("") here would wipe the drilled-into card/group on every frame. + body = m.policy.View() } else { body = m.hosts.View() } @@ -575,7 +642,14 @@ func (m appModel) tooSmallView() string { func (m appModel) renderStatusBar() string { var bar string if m.screen == screenDetail { - crumb := m.styles.crumb.Render("AgentSSH") + m.styles.dim.Render(" › ") + m.styles.header.Render(m.detailHost) + crumb := m.styles.crumb.Render("AgentSSH") + m.styles.dim.Render(" › ") + m.detailCrumbHost() + // In the Sessions pane, the drilled-into session's id rides on the breadcrumb + // (its standalone identity line was removed in favor of this). + if m.pane == paneSessions { + if id, ok := m.sessions.detailSessionID(); ok && id != "" { + crumb += m.styles.dim.Render(" › ") + m.styles.header.Render(id) + } + } pips := make([]string, 0, 3) for _, p := range []detailPane{paneInfo, paneSessions, panePolicy} { label := fmt.Sprintf("%d %s", int(p)+1, p.title()) @@ -589,8 +663,20 @@ func (m appModel) renderStatusBar() string { } else { n := len(m.hosts.names) crumb := m.styles.crumb.Render("AgentSSH") + tabs := make([]string, 0, 2) + for _, tab := range []entryTab{entryHosts, entryPolicy} { + label := fmt.Sprintf("%d %s", int(tab)+1, tab.title()) + if tab == m.entryTab { + tabs = append(tabs, m.styles.activeTab.Render(label)) + } else { + tabs = append(tabs, m.styles.inactive.Render(label)) + } + } count := m.styles.dim.Render(fmt.Sprintf(" · %d %s", n, plural(n, "host", "hosts"))) - bar = crumb + count + bar = lipgloss.JoinHorizontal(lipgloss.Top, crumb, " ", lipgloss.JoinHorizontal(lipgloss.Top, tabs...), count) + if m.hosts.filterActive() { + bar += m.styles.dim.Render(fmt.Sprintf(" · %d shown", m.hosts.visibleCount())) + } if chain := m.sessions.chainBadge(); chain != "" { bar += m.styles.dim.Render(" · ") + chain } @@ -603,6 +689,22 @@ func (m appModel) renderStatusBar() string { return style.Render(bar) } +// detailCrumbHost renders the breadcrumb's host segment: the host name (bold) plus +// its user@addr connection as dim context, e.g. "web-staging-1 (deploy@10.0.1.21)". +// The connection is dropped when the host has no inventory metadata. +func (m appModel) detailCrumbHost() string { + name := m.styles.header.Render(m.detailHost) + meta, ok := m.sessions.hosts[m.detailHost] + if !ok || (meta.User == "" && meta.Addr == "") { + return name + } + target := meta.Addr + if meta.User != "" { + target = meta.User + "@" + meta.Addr + } + return name + m.styles.dim.Render(fmt.Sprintf(" (%s)", target)) +} + // renderFooter is the persistent bottom rail: the active screen's key bindings // plus the global ones, with a transient toast right-aligned when present. func (m appModel) renderFooter() string { @@ -624,10 +726,19 @@ func (m appModel) renderFooter() string { return m.fullWidthLine(helpView + strings.Repeat(" ", pad) + toast) } -// activeHelpKeyMap returns the footer bindings for the active screen/pane. +// activeHelpKeyMap returns the help bindings for the active screen/pane. The +// footer (ShortHelp) stays terse — a few essential keys plus navigation — while +// the ? overlay (FullHelp) carries every binding, grouped into columns. func (m appModel) activeHelpKeyMap() help.KeyMap { if m.screen != screenDetail { - return m.hosts.helpKeyMap() + nav := []key.Binding{hk("tab/1-2", "tabs")} + var section help.KeyMap + if m.entryTab == entryPolicy { + section = m.policy.helpKeyMap() + } else { + section = m.hosts.helpKeyMap() + } + return mergeHelp(section, nav) } nav := []key.Binding{hk("tab/1-3", "panes"), hk("esc", "back")} var pane help.KeyMap @@ -637,9 +748,29 @@ func (m appModel) activeHelpKeyMap() help.KeyMap { case panePolicy: pane = m.policy.helpKeyMap() default: - pane = helpMap{short: []key.Binding{hk("t", "test")}} + pane = helpMap{ + short: []key.Binding{hk("t", "test")}, + full: [][]key.Binding{{hk("t", "test host connection")}}, + } + } + return mergeHelp(pane, nav) +} + +// mergeHelp appends nav bindings to a section's short help (footer) and adds them +// as a final column in its full help (?), so the footer stays terse while the +// overlay lists everything. Slices are copied so callers aren't mutated. +func mergeHelp(section help.KeyMap, nav []key.Binding) helpMap { + short := append(append([]key.Binding{}, section.ShortHelp()...), nav...) + full := append([][]key.Binding{}, section.FullHelp()...) + full = append(full, nav) + return helpMap{short: short, full: full} +} + +func (m appModel) entryCapturing() bool { + if m.entryTab == entryPolicy { + return m.policy.capturing() } - return helpMap{short: append(pane.ShortHelp(), nav...)} + return m.hosts.capturing() } func (m appModel) fullWidthLine(value string) string { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 7612589..4a3b2c8 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -166,14 +166,43 @@ func TestWelcomeBannerFitsAndDismisses(t *testing.T) { if max, lines := maxLineWidth(v); max > 60 || lines > 16 { t.Fatalf("welcome overflow: %dx%d", max, lines) } - // Any key dismisses the banner. - m = press(t, m, "x") + // A neutral key dismisses the banner and lands on the grid. + m = press(t, m, "g") if m.firstRun { t.Fatal("first key should dismiss the welcome banner") } if strings.Contains(m.View(), "Welcome to AgentSSH") { t.Fatal("welcome banner still shown after a keypress") } + if m.hosts.focus != hostFocusList { + t.Fatalf("neutral first key left grid in focus %v, want list", m.hosts.focus) + } +} + +// The first keystroke isn't wasted: it dismisses the banner and also performs its +// action (here, 'a' opens the add form) — except 'q', which only dismisses so a +// curious keypress can't quit on the greeting. +func TestWelcomeBannerReDispatchesFirstKey(t *testing.T) { + m := sized(t, func() appModel { mm := buildApp(t); mm.firstRun = true; return mm }(), 80, 24) + m = press(t, m, "a") + if m.firstRun { + t.Fatal("'a' should dismiss the banner") + } + if m.hosts.focus != hostFocusForm { + t.Fatalf("'a' on first run did not open the add form: focus=%v", m.hosts.focus) + } +} + +func TestWelcomeBannerQuitKeyOnlyDismisses(t *testing.T) { + m := sized(t, func() appModel { mm := buildApp(t); mm.firstRun = true; return mm }(), 80, 24) + m = press(t, m, "q") + if m.firstRun { + t.Fatal("'q' should still dismiss the banner") + } + // q must not have quit to a confirm/form; the grid is shown with hosts. + if m.hosts.focus != hostFocusList || !strings.Contains(m.View(), "bare") { + t.Fatalf("'q' on first run did not land cleanly on the grid:\n%s", m.View()) + } } func TestEmptyInventoryShowsAddHint(t *testing.T) { diff --git a/internal/tui/doc.go b/internal/tui/doc.go index 8cd4962..ad67891 100644 --- a/internal/tui/doc.go +++ b/internal/tui/doc.go @@ -1,5 +1,5 @@ -// Package tui implements the terminal console, including a read-only Audit UI -// that lists sessions, opens each session into command results, and verifies the -// hash chain. It refuses to start on a non-TTY (see ErrNotInteractive) so the -// caller can fall back to the plain audit/session commands. +// Package tui implements the terminal operator console: host management, audit +// browsing, policy inspection/host rules, and session navigation. It refuses +// to start on a non-TTY (see ErrNotInteractive) so the caller can fall back to +// the plain CLI commands. package tui diff --git a/internal/tui/harness_test.go b/internal/tui/harness_test.go index 4b1d818..2728d4a 100644 --- a/internal/tui/harness_test.go +++ b/internal/tui/harness_test.go @@ -52,20 +52,33 @@ groups: ` const samplePolicy = `version: 1 -defaults: - policy: allow rules: - name: no-rm + priority: 100 match: cmd_regex: "rm -rf" action: deny + - name: allow-readonly + priority: 0 + match: + cmd_regex: "^(whoami|ls)\\b" + action: allow host_overrides: web: - policy: deny - allow_rules: - - cmd_regex: "^ls" + rules: + - priority: 10 + match: + cmd_regex: "^ls" + action: allow output: max_bytes: 1048576 +rule_groups: + readonly: + rules: + - priority: 5 + match: + cmd_regex: "^uptime$" + action: allow ` // buildApp constructs an appModel backed by temp inventory/policy files. The @@ -90,7 +103,6 @@ func buildAppWith(t *testing.T, inv, pol string) appModel { InventoryFile: filepath.Join(dir, "inventory.yaml"), PolicyFile: filepath.Join(dir, "policy.yaml"), AuditFile: filepath.Join(dir, "audit.log"), - SessionFile: filepath.Join(dir, "session"), SecretsFile: filepath.Join(dir, "secrets.enc"), } return newAppModel(paths, lipgloss.NewRenderer(os.Stdout)) diff --git a/internal/tui/hosts.go b/internal/tui/hosts.go index 697ce48..74ea5d3 100644 --- a/internal/tui/hosts.go +++ b/internal/tui/hosts.go @@ -11,16 +11,19 @@ import ( "strings" "time" + "github.com/Praeviso/AgentSSH/internal/audit" "github.com/Praeviso/AgentSSH/internal/config" "github.com/Praeviso/AgentSSH/internal/discovery" "github.com/Praeviso/AgentSSH/internal/executor" "github.com/Praeviso/AgentSSH/internal/hostform" "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" "github.com/Praeviso/AgentSSH/internal/secrets" "github.com/Praeviso/AgentSSH/internal/theme" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -36,6 +39,17 @@ const ( hostFocusDiscover // the discover overlay owns the screen ) +// statusLevel tags the transient grid status line so it renders in a color that +// matches its meaning. Without it every status — including "FAILED " — was +// painted in the green success style, so a failed connectivity test read as a win. +type statusLevel int + +const ( + statusInfo statusLevel = iota // neutral progress / cancellation (dim) + statusOK // success (green) + statusErr // failure (red) +) + type hostsSection struct { paths config.Paths renderer *lipgloss.Renderer @@ -45,10 +59,15 @@ type hostsSection struct { cursor int open bool // set when the operator opened the selected host (enter); the shell consumes it status string - err error // transient operation error (shown inline) - loadErr error // inventory load/parse error (blocking; shows the error card) + statusLevel statusLevel // severity of status, drives its color (see statusLevel) + err error // transient operation error (shown inline) + loadErr error // inventory load/parse error (blocking; shows the error card) form hostform.Model focus hostFocus + filter textinput.Model // the / search box that narrows the grid + filtering bool // the filter box is focused and capturing keys + query string // the live/committed filter query ("" = no filter) + editingHost string testing bool // a host connectivity probe is in flight discover discoveryOverlay discoverSeq int @@ -67,6 +86,13 @@ func (s hostsSection) busy() bool { return s.testing || s.discover.loading || s.discover.probing } +// setStatus sets the transient grid status line together with its severity so the +// two never drift — the level decides the color the status renders in (see View). +func (s *hostsSection) setStatus(level statusLevel, text string) { + s.status = text + s.statusLevel = level +} + type inventoryChangedMsg struct { inventory inventory.Inventory } @@ -120,6 +146,10 @@ func newHostsSection(paths config.Paths, renderer *lipgloss.Renderer, st appStyl sp.Style = renderer.NewStyle().Foreground(lipgloss.Color("212")) } s.spinner = sp + ti := textinput.New() + ti.Placeholder = "name, tag, addr, or user" + ti.Prompt = "/ " + s.filter = ti s.probes = map[string]hostProbe{} if master := os.Getenv("AGENTSSH_MASTER_PASSWORD"); master != "" { if store, err := secrets.Open(paths.SecretsFile, master); err == nil { @@ -134,24 +164,67 @@ func newHostsSection(paths config.Paths, renderer *lipgloss.Renderer, st appStyl return s } -// selectedHost returns the host name under the grid cursor, or "". +// selectedHost returns the host name under the grid cursor, or "". The cursor +// indexes the visible (filtered) list, so this resolves against it. func (s hostsSection) selectedHost() string { - if s.cursor < 0 || s.cursor >= len(s.names) { + vis := s.visible() + if s.cursor < 0 || s.cursor >= len(vis) { return "" } - return s.names[s.cursor] + return vis[s.cursor] } +// visible is the subset of hosts the grid shows: the full sorted list, narrowed +// to those matching the active filter query (by name, tag, addr, or user). +func (s hostsSection) visible() []string { + q := strings.ToLower(strings.TrimSpace(s.query)) + if q == "" { + return s.names + } + out := make([]string, 0, len(s.names)) + for _, name := range s.names { + if hostMatchesQuery(s.inventory.Hosts[name], name, q) { + out = append(out, name) + } + } + return out +} + +// hostMatchesQuery reports whether a host matches the (lowercased) filter query, +// matching its name, any tag, its address, or its user as a substring. +func hostMatchesQuery(h inventory.Host, name, q string) bool { + if strings.Contains(strings.ToLower(name), q) || + strings.Contains(strings.ToLower(h.Addr), q) || + strings.Contains(strings.ToLower(h.User), q) { + return true + } + for _, tag := range h.Tags { + if strings.Contains(strings.ToLower(tag), q) { + return true + } + } + return false +} + +// filterActive reports whether a filter query is currently narrowing the grid. +func (s hostsSection) filterActive() bool { return strings.TrimSpace(s.query) != "" } + +// visibleCount is the number of hosts currently shown under the active filter. +func (s hostsSection) visibleCount() int { return len(s.visible()) } + // capturing reports whether the grid owns the keyboard (a modal/text mode), so // the shell must not steal q/?. func (s hostsSection) capturing() bool { - return s.focus == hostFocusForm || s.focus == hostFocusConfirm || s.focus == hostFocusDiscover + return s.filtering || s.focus == hostFocusForm || s.focus == hostFocusConfirm || s.focus == hostFocusDiscover } func (s hostsSection) helpKeyMap() help.KeyMap { if s.loadErr != nil { return helpMap{short: []key.Binding{hk("r", "reload inventory")}} } + if s.filtering { + return helpMap{short: []key.Binding{hk("enter", "apply"), hk("esc", "clear")}} + } switch s.focus { case hostFocusForm: return helpMap{short: []key.Binding{hk("tab", "next"), hk("shift+tab", "prev"), hk("enter", "save"), hk("esc", "cancel")}} @@ -160,7 +233,15 @@ func (s hostsSection) helpKeyMap() help.KeyMap { case hostFocusConfirm: return helpMap{short: []key.Binding{hk("y", "confirm"), hk("n/esc", "cancel")}} default: - return helpMap{short: []key.Binding{hk("↑↓←→/hjkl", "move"), hk("enter", "open"), hk("a", "add"), hk("d", "discover"), hk("t", "test"), hk("r/x", "remove")}} + // Footer stays terse; ? expands to the grouped full help below. + short := []key.Binding{hk("enter", "open"), hk("/", "filter"), hk("a", "add")} + // Grouped full help (shown on ?): movement, finding, and actions as columns. + full := [][]key.Binding{ + {hk("↑↓←→/hjkl", "move"), hk("g/G", "home/end"), hk("/", "filter")}, + {hk("enter/i", "open"), hk("a", "add"), hk("e", "edit"), hk("d/x", "delete")}, + {hk("D", "discover"), hk("t", "test"), hk("r", "reload")}, + } + return helpMap{short: short, full: full} } } @@ -169,6 +250,13 @@ func (s hostsSection) Init() tea.Cmd { return nil } func (s hostsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if ws, ok := msg.(tea.WindowSizeMsg); ok { s.w, s.h = ws.Width, ws.Height + if s.w > 0 { + fw := s.w - lipgloss.Width(s.filter.Prompt) - 1 + if fw < 8 { + fw = 8 + } + s.filter.Width = fw + } } switch msg := msg.(type) { @@ -221,24 +309,36 @@ func (s hostsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case msg.ok: s.err = nil - s.status = "OK " + msg.name + s.setStatus(statusOK, "OK "+msg.name) s.probes[msg.name] = hostProbe{ok: true, detail: "ok", dur: msg.dur} if msg.os != "" { s.setHostOS(msg.name, msg.os) } case msg.hint != "": s.err = nil - s.status = "FAILED " + msg.name + ": " + msg.hint + s.setStatus(statusErr, "FAILED "+msg.name+": "+msg.hint) s.probes[msg.name] = hostProbe{ok: false, detail: msg.hint, dur: msg.dur} case msg.err != nil: s.err = nil hint := executor.ConnectHint(msg.err) - s.status = "FAILED " + msg.name + ": " + hint + s.setStatus(statusErr, "FAILED "+msg.name+": "+hint) s.probes[msg.name] = hostProbe{ok: false, detail: hint, dur: msg.dur} } return s, nil } + // The filter box overlays the grid (not a modal screen), so it intercepts keys + // before the focus switch while it is open; non-key messages still flow to it so + // its cursor keeps blinking. + if s.filtering { + if km, ok := msg.(tea.KeyMsg); ok { + return s.updateFilter(km) + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + return s, cmd + } + switch s.focus { case hostFocusForm: return s.updateForm(msg) @@ -251,13 +351,40 @@ func (s hostsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } +// updateFilter handles keys while the / filter box is focused: enter commits and +// returns to grid navigation (keeping the filter applied), esc clears it, and any +// other key edits the query with live, as-you-type filtering. +func (s hostsSection) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + s.filtering = false + s.filter.Blur() + s.query = strings.TrimSpace(s.filter.Value()) + s.clampCursor() + return s, nil + case "esc": + s.filtering = false + s.filter.Blur() + s.filter.SetValue("") + s.query = "" + s.clampCursor() + return s, nil + } + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.query = s.filter.Value() + s.cursor = 0 // jump to the first match as the query narrows + s.clampCursor() + return s, cmd +} + // startProbe begins a connectivity test for name (used by the Info pane in the // detail screen). The result lands back via hostProbeMsg. func (s hostsSection) startProbe(name string) (hostsSection, tea.Cmd) { if s.testing || name == "" { return s, nil } - s.status = "testing " + name + "…" + s.setStatus(statusInfo, "testing "+name+"…") s.err = nil s.testing = true return s, tea.Batch(s.probeHostCmd(name), s.spinner.Tick) @@ -273,18 +400,33 @@ func (s hostsSection) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { } result := s.form.Result() s.focus = hostFocusList + editingHost := s.editingHost + s.editingHost = "" s.form = hostform.Model{} if !result.Submitted { - s.status = "add cancelled" + if editingHost != "" { + s.setStatus(statusInfo, "edit cancelled") + } else { + s.setStatus(statusInfo, "add cancelled") + } return s, nil } - if err := s.addHost(result); err != nil { + var err error + var toastText string + if editingHost != "" { + err = s.updateHost(result) + toastText = "host updated: " + result.Name + } else { + err = s.addHost(result) + toastText = "host added: " + result.Name + } + if err != nil { s.err = err s.status = "" return s, nil } s.err = nil - return s, tea.Batch(inventoryChangedCmd(s.inventory), toastCmd("host added: "+result.Name)) + return s, tea.Batch(inventoryChangedCmd(s.inventory), toastCmd(toastText)) } func (s hostsSection) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -294,14 +436,11 @@ func (s hostsSection) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { } switch keyMsg.String() { case "y": - name := "" - if len(s.names) > 0 { - name = s.names[s.cursor] - } - removed := s.removeSelected() + name := s.selectedHost() + removed, policyCmd := s.removeSelected() s.focus = hostFocusList if removed { - return s, tea.Batch(inventoryChangedCmd(s.inventory), toastCmd("host removed: "+name)) + return s, tea.Batch(inventoryChangedCmd(s.inventory), policyCmd, toastCmd("host removed: "+name)) } return s, nil case "n", "esc": @@ -323,8 +462,13 @@ func (s hostsSection) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } cols := s.gridCols() - last := len(s.names) - 1 + last := len(s.visible()) - 1 switch keyMsg.String() { + case "/": + s.filtering = true + s.filter.SetValue(s.query) + s.filter.CursorEnd() + return s, s.filter.Focus() case "left", "h": if s.cursor > 0 { s.cursor-- @@ -350,10 +494,33 @@ func (s hostsSection) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { s.cursor = maxInt(last, 0) case "a": s.focus = hostFocusForm + s.editingHost = "" s.form = hostform.New(hostform.Options{ExistingNames: inventory.HostNames(s.inventory)}, s.renderer) s.form = s.form.SetSize(s.w, s.h) return s, s.form.Init() - case "d": + case "e": + if s.selectedHost() == "" { + s.setStatus(statusInfo, "no host selected") + return s, nil + } + name := s.selectedHost() + host := s.inventory.Hosts[name] + s.focus = hostFocusForm + s.editingHost = name + s.form = hostform.New(hostform.Options{ + Name: name, + Addr: host.Addr, + User: host.User, + Port: host.Port, + Tags: host.Tags, + Alias: host.SSHConfigAlias, + IdentityFile: host.IdentityFile, + FixedName: true, + Title: "Edit inventory host", + }, s.renderer) + s.form = s.form.SetSize(s.w, s.h) + return s, s.form.Init() + case "D": s.focus = hostFocusDiscover s.discoverSeq++ s.discover = discoveryOverlay{ @@ -368,25 +535,37 @@ func (s hostsSection) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { if s.testing { return s, nil } - if len(s.names) == 0 { - s.status = "no host selected" + if s.selectedHost() == "" { + s.setStatus(statusInfo, "no host selected") return s, nil } - name := s.names[s.cursor] - s.status = "testing " + name + "…" + name := s.selectedHost() + s.setStatus(statusInfo, "testing "+name+"…") s.err = nil s.testing = true return s, tea.Batch(s.probeHostCmd(name), s.spinner.Tick) - case "r", "x": - if len(s.names) > 0 { + case "d", "x": + if s.selectedHost() != "" { s.focus = hostFocusConfirm } + case "r": + // Reload inventory.yaml from disk (e.g. after editing it externally); the + // same key reloads from the parse-error card. + return s.reloadInventory() case "enter", "i": - if len(s.names) > 0 { + if s.selectedHost() != "" { s.open = true } case "esc": - s.status = "" + // esc clears an active filter first; only when there is none does it drop the + // transient status line. + if s.filterActive() { + s.query = "" + s.filter.SetValue("") + s.clampCursor() + } else { + s.status = "" + } } return s, nil } @@ -552,7 +731,7 @@ func (s hostsSection) updateDiscovery(msg tea.Msg) (tea.Model, tea.Cmd) { case "esc", "q": s.discover = discoveryOverlay{} s.focus = hostFocusList - s.status = "discover cancelled" + s.setStatus(statusInfo, "discover cancelled") } return s, nil } @@ -699,6 +878,67 @@ func (s *hostsSection) addInventoryHost(base inventory.Inventory, result hostfor return nil } +func (s *hostsSection) updateHost(result hostform.Result) error { + base, err := inventory.Load(s.paths.InventoryFile) + if err != nil { + return err + } + existing, ok := base.Hosts[result.Name] + if !ok { + return inventory.ErrHostNotFound + } + nextHost := inventory.Host{ + Addr: result.Addr, + User: result.User, + Port: result.Port, + SSHConfigAlias: result.Alias, + IdentityFile: result.Identity, + Tags: result.Tags, + OS: existing.OS, + } + var store *secrets.Store + var master string + if result.Password != "" { + master = os.Getenv("AGENTSSH_MASTER_PASSWORD") + if master == "" { + return fmt.Errorf("set AGENTSSH_MASTER_PASSWORD to store a password in the TUI, or use `agentssh secret set `") + } + store, err = secrets.Open(s.paths.SecretsFile, master) + if errors.Is(err, secrets.ErrWrongMaster) { + return fmt.Errorf("cannot open secrets: wrong master password or corrupt secrets file") + } + if err != nil { + return err + } + } + next, err := inventory.UpdateHost(base, result.Name, nextHost) + if err != nil { + return err + } + if err := inventory.Save(s.paths.InventoryFile, next); err != nil { + return err + } + s.inventory = next + s.rebuildNames() + if store != nil { + store.Set(result.Name, result.Password) + if err := store.Save(master); err != nil { + if rbErr := inventory.Save(s.paths.InventoryFile, base); rbErr != nil { + return fmt.Errorf("failed to store password (%v) and to roll back inventory update: %w", err, rbErr) + } + s.inventory = base + s.rebuildNames() + return fmt.Errorf("failed to store password; rolled back inventory update: %w", err) + } + if s.secretHosts == nil { + s.secretHosts = map[string]bool{} + } + s.secretHosts[result.Name] = true + s.secretsReadable = true + } + return nil +} + func (s *hostsSection) setHostOS(name string, osName string) { osName = strings.TrimSpace(osName) if name == "" || osName == "" { @@ -743,42 +983,111 @@ func (s *hostsSection) removeHostByName(name string) error { } s.inventory = next s.rebuildNames() - return nil + _, err = s.clearHostRulesForDeletedHost(name) + return err } -func (s *hostsSection) removeSelected() bool { - if len(s.names) == 0 { - return false +func (s hostsSection) appendDeleteAudit(name string, event audit.Event, errText string, exitCode int) error { + reqID, err := audit.NewReqID() + if err != nil { + return err + } + exit := exitCode + _, err = audit.NewStore(s.paths.AuditFile).Append(audit.Record{ + ReqID: reqID, + Event: event, + Host: name, + Cmd: "inventory rm " + name, + Error: errText, + ExitCode: &exit, + }) + return err +} + +func (s *hostsSection) removeSelected() (bool, tea.Cmd) { + name := s.selectedHost() + if name == "" { + return false, nil } - name := s.names[s.cursor] base, err := inventory.Load(s.paths.InventoryFile) if err != nil { + if auditErr := s.appendDeleteAudit(name, audit.EventFailed, err.Error(), 1); auditErr != nil { + err = fmt.Errorf("%v; audit failed: %w", err, auditErr) + } s.err = err s.status = "" - return false + return false, nil } next, err := inventory.RemoveHost(base, name) if err != nil { + if auditErr := s.appendDeleteAudit(name, audit.EventFailed, err.Error(), 2); auditErr != nil { + err = fmt.Errorf("%v; audit failed: %w", err, auditErr) + } s.err = err s.status = "" - return false + return false, nil } if err := inventory.Save(s.paths.InventoryFile, next); err != nil { + if auditErr := s.appendDeleteAudit(name, audit.EventFailed, err.Error(), 1); auditErr != nil { + err = fmt.Errorf("%v; audit failed: %w", err, auditErr) + } s.err = err s.status = "" - return false + return false, nil } s.inventory = next s.rebuildNames() delete(s.secretHosts, name) s.err = nil - return true + policyCmd, err := s.clearHostRulesForDeletedHost(name) + if err != nil { + if auditErr := s.appendDeleteAudit(name, audit.EventFailed, err.Error(), 1); auditErr != nil { + err = fmt.Errorf("%v; audit failed: %w", err, auditErr) + } + s.err = err + s.status = "" + return true, inventoryChangedCmd(s.inventory) + } + if err := s.appendDeleteAudit(name, audit.EventCompleted, "", 0); err != nil { + s.err = err + s.setStatus(statusErr, "removed "+name+" but failed to audit deletion") + return true, policyCmd + } + return true, policyCmd +} + +func (s *hostsSection) clearHostRulesForDeletedHost(name string) (tea.Cmd, error) { + cfg, err := policy.Load(s.paths.PolicyFile) + if err != nil { + return nil, err + } + next, err := policy.ClearHostRules(policy.Bundle{Policy: cfg}, name) + if errors.Is(err, policy.ErrNoHostRules) { + return nil, nil + } + if err != nil { + return nil, err + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + return nil, err + } + if err := policy.Save(s.paths.PolicyFile, next.Policy); err != nil { + return nil, err + } + return policyChangedCmd(next.Policy, "host rules cleared: "+name), nil } func (s *hostsSection) rebuildNames() { s.names = sortedHostNames(s.inventory.Hosts) - if s.cursor >= len(s.names) { - s.cursor = len(s.names) - 1 + s.clampCursor() +} + +// clampCursor keeps the grid cursor within the visible (filtered) list after the +// inventory or the filter query changes. +func (s *hostsSection) clampCursor() { + n := len(s.visible()) + if s.cursor >= n { + s.cursor = n - 1 } if s.cursor < 0 { s.cursor = 0 @@ -806,8 +1115,8 @@ func (s hostsSection) gridCols() int { if cols < 1 { cols = 1 } - if cols > len(s.names) && len(s.names) > 0 { - cols = len(s.names) + if n := len(s.visible()); cols > n && n > 0 { + cols = n } return cols } @@ -835,9 +1144,26 @@ func (s hostsSection) listChromeHeight() int { if s.status != "" { h++ } + // The filter input (while editing) or the committed-filter chip costs one line. + if s.filtering || s.filterActive() { + h++ + } return h } +// statusStyle maps the status severity to its color: green for success, red for +// failure, dim for neutral progress and cancellations. +func (s hostsSection) statusStyle() lipgloss.Style { + switch s.statusLevel { + case statusOK: + return s.styles.ok + case statusErr: + return s.styles.err + default: + return s.styles.dim + } +} + func (s hostsSection) View() string { if s.focus == hostFocusForm { return s.form.View() @@ -862,7 +1188,17 @@ func (s hostsSection) View() string { b.WriteString(s.spinner.View()) b.WriteString(" ") } - b.WriteString(truncate(s.styles.ok.Render(s.status), s.w)) + b.WriteString(truncate(s.statusStyle().Render(s.status), s.w)) + b.WriteString("\n") + } + // Filter affordance: the live input while editing, or a static chip once a + // committed filter is narrowing the grid. + if s.filtering { + b.WriteString(truncate(s.filter.View(), s.w)) + b.WriteString("\n") + } else if s.filterActive() { + chip := fmt.Sprintf("filter %q · esc clears", strings.TrimSpace(s.query)) + b.WriteString(truncate(s.styles.dim.Render(chip), s.w)) b.WriteString("\n") } if len(s.names) == 0 { @@ -873,6 +1209,10 @@ func (s hostsSection) View() string { b.WriteString(keyHint(s.styles, "d", "discover hosts you can already reach")) return b.String() } + if len(s.visible()) == 0 { + b.WriteString(s.styles.dim.Render(fmt.Sprintf("No hosts match %q.", strings.TrimSpace(s.query)))) + return b.String() + } b.WriteString(s.gridView()) return b.String() } @@ -880,9 +1220,10 @@ func (s hostsSection) View() string { // gridView renders the responsive, equal-sized card grid, windowed vertically so // the selected card stays on screen. func (s hostsSection) gridView() string { + vis := s.visible() cols := s.gridCols() cardW := s.cardOuterWidth(cols) - totalRows := (len(s.names) + cols - 1) / cols + totalRows := (len(vis) + cols - 1) / cols // How many card rows fit in the remaining body height. Reserve one line for // the "rows x–y of z" indicator when the grid can't show every row at once. @@ -910,13 +1251,13 @@ func (s hostsSection) gridView() string { cards := make([]string, 0, cols*2) for c := 0; c < cols; c++ { idx := r*cols + c - if idx >= len(s.names) { + if idx >= len(vis) { break } if c > 0 { cards = append(cards, strings.Repeat(" ", cardGap)) } - cards = append(cards, s.renderHostCard(s.names[idx], cardW, idx == s.cursor)) + cards = append(cards, s.renderHostCard(vis[idx], cardW, idx == s.cursor)) } rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) } @@ -1046,6 +1387,10 @@ func (s hostsSection) infoView(name string, box, height int, focused bool) strin } else { field("probe", s.probeCell(name)) } + // Bridge the human viewer to the CLI that actually executes: show the run + // template for this host (full width so the command isn't over-truncated). + b.WriteString("\n") + b.WriteString(truncate(s.styles.dim.Render("run · agentssh run "+name+" -- "), textW)) } panel := s.styles.panel.Width(box) @@ -1133,10 +1478,7 @@ func (s hostsSection) errorCardView() string { func (s hostsSection) confirmCardView() string { cw := s.cardContentWidth() - name := "" - if len(s.names) > 0 { - name = s.names[s.cursor] - } + name := s.selectedHost() var b strings.Builder b.WriteString(s.styles.confirm.Render(s.styles.glyphs.Warn + " Remove host")) b.WriteString("\n\n") diff --git a/internal/tui/hosts_test.go b/internal/tui/hosts_test.go index 6c8d1ef..b79b087 100644 --- a/internal/tui/hosts_test.go +++ b/internal/tui/hosts_test.go @@ -1,8 +1,14 @@ package tui import ( + "os" "strings" "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/hostform" + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" ) func TestOSMeta(t *testing.T) { @@ -117,3 +123,132 @@ func TestGrid2DNavigation(t *testing.T) { t.Fatalf("G: cursor = %d, want 4", m.hosts.cursor) } } + +func TestHostsEditUpdatesInventory(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "prod-web-01") + m = press(t, m, "e") + if m.hosts.focus != hostFocusForm || m.hosts.editingHost != "prod-web-01" { + t.Fatalf("edit state focus=%v editing=%q", m.hosts.focus, m.hosts.editingHost) + } + if err := m.hosts.updateHost(hostform.Result{ + Name: "prod-web-01", + Addr: "10.0.0.99", + User: "admin", + Port: 2222, + Tags: []string{"prod", "api"}, + Alias: "prod-web", + Identity: "~/.ssh/prod-web", + }); err != nil { + t.Fatalf("updateHost: %v", err) + } + inv, err := inventory.Load(m.hosts.paths.InventoryFile) + if err != nil { + t.Fatalf("load inventory: %v", err) + } + host := inv.Hosts["prod-web-01"] + if host.Addr != "10.0.0.99" || host.User != "admin" || host.Port != 2222 || host.SSHConfigAlias != "prod-web" || host.IdentityFile != "~/.ssh/prod-web" || host.OS != "linux" { + t.Fatalf("updated host = %#v", host) + } + if got := strings.Join(host.Tags, ","); got != "prod,api" { + t.Fatalf("tags = %q", got) + } +} + +func TestHostsRemoveSelectedWritesAudit(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "prod-web-01") + removed, _ := m.hosts.removeSelected() + if !removed { + t.Fatalf("removeSelected failed: status=%q err=%v", m.hosts.status, m.hosts.err) + } + if _, ok := m.hosts.inventory.Hosts["prod-web-01"]; ok { + t.Fatalf("prod-web-01 still present: %#v", m.hosts.inventory.Hosts) + } + records, err := audit.NewStore(m.hosts.paths.AuditFile).ReadAll() + if err != nil { + t.Fatalf("read audit: %v", err) + } + if len(records) != 1 || records[0].Event != audit.EventCompleted || records[0].Host != "prod-web-01" || records[0].Cmd != "inventory rm prod-web-01" { + t.Fatalf("delete audit records = %#v", records) + } +} + +func TestHostsRemoveSelectedClearsOnlyDeletedHostRules(t *testing.T) { + m := sized(t, buildAppWith(t, sampleInventory, `version: 1 +host_overrides: + host:prod-web-01: + rules: + - match: { cmd_regex: '^whoami$' } + action: allow + web: + rules: + - match: { cmd_regex: '^id$' } + action: deny +rule_groups: + readonly: + rules: + - match: { cmd_regex: '^ls$' } + action: allow +`), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "prod-web-01") + + removed, cmd := m.hosts.removeSelected() + if !removed { + t.Fatalf("removeSelected failed: status=%q err=%v", m.hosts.status, m.hosts.err) + } + if cmd == nil { + t.Fatal("removeSelected did not emit policyChangedMsg") + } + msg := cmd() + changed, ok := msg.(policyChangedMsg) + if !ok { + t.Fatalf("policy command message = %T %[1]v, want policyChangedMsg", msg) + } + if _, ok := changed.config.HostOverrides["host:prod-web-01"]; ok { + t.Fatalf("deleted host rules still present in message: %#v", changed.config.HostOverrides) + } + cfg, err := policy.Load(m.paths.PolicyFile) + if err != nil { + t.Fatalf("load policy: %v", err) + } + if _, ok := cfg.HostOverrides["host:prod-web-01"]; ok { + t.Fatalf("deleted host rules still present: %#v", cfg.HostOverrides) + } + if _, ok := cfg.HostOverrides["web"]; !ok { + t.Fatalf("group override removed: %#v", cfg.HostOverrides) + } + if _, ok := cfg.RuleGroups["readonly"]; !ok { + t.Fatalf("rule group removed: %#v", cfg.RuleGroups) + } +} + +func TestHostsRemoveSelectedParseFailureWritesAudit(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "prod-web-01") + if err := os.WriteFile(m.hosts.paths.InventoryFile, []byte("::: not: yaml: ["), 0o600); err != nil { + t.Fatalf("write malformed inventory: %v", err) + } + removed, _ := m.hosts.removeSelected() + if removed { + t.Fatal("removeSelected unexpectedly succeeded") + } + records, err := audit.NewStore(m.hosts.paths.AuditFile).ReadAll() + if err != nil { + t.Fatalf("read audit: %v", err) + } + if len(records) != 1 || records[0].Event != audit.EventFailed || records[0].Host != "prod-web-01" || !strings.Contains(records[0].Error, "yaml") { + t.Fatalf("failed delete audit records = %#v", records) + } +} + +func hostIndex(t *testing.T, names []string, name string) int { + t.Helper() + for i, got := range names { + if got == name { + return i + } + } + t.Fatalf("host %q not found in %#v", name, names) + return 0 +} diff --git a/internal/tui/model.go b/internal/tui/model.go index e546b2b..5e7155f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -58,8 +58,9 @@ const ( type keyMap struct { Up key.Binding Down key.Binding + Home key.Binding + End key.Binding Toggle key.Binding - Detail key.Binding Session key.Binding Filter key.Binding Back key.Binding @@ -69,8 +70,9 @@ func defaultKeys() keyMap { return keyMap{ Up: key.NewBinding(key.WithKeys("up", "k")), Down: key.NewBinding(key.WithKeys("down", "j")), + Home: key.NewBinding(key.WithKeys("home", "g")), + End: key.NewBinding(key.WithKeys("end", "G")), Toggle: key.NewBinding(key.WithKeys("enter", " ")), - Detail: key.NewBinding(key.WithKeys("d")), Session: key.NewBinding(key.WithKeys("l")), Filter: key.NewBinding(key.WithKeys("/")), Back: key.NewBinding(key.WithKeys("esc")), @@ -271,15 +273,15 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursor < len(m.rows)-1 { m.cursor++ } + case key.Matches(msg, m.keys.Home): + m.cursor = 0 + case key.Matches(msg, m.keys.End): + m.cursor = maxInt(len(m.rows)-1, 0) case key.Matches(msg, m.keys.Toggle): if len(m.rows) == 0 { return m, nil } m.openSessionDetail(m.rows[m.cursor].gi) - case key.Matches(msg, m.keys.Detail): - if len(m.rows) > 0 { - m.openSessionDetail(m.rows[m.cursor].gi) - } case key.Matches(msg, m.keys.Session): if len(m.rows) > 0 { if m.sessionFocus == "" { @@ -328,7 +330,7 @@ func (m *model) openSessionDetail(groupIndex int) { func (m model) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { - case key.Matches(msg, m.keys.Back), key.Matches(msg, m.keys.Detail): + case key.Matches(msg, m.keys.Back): m.focus = focusList m.detailGroup = -1 return m, nil @@ -340,6 +342,12 @@ func (m model) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if g, ok := m.detailSession(); ok && m.runCursor < len(g.runs)-1 { m.runCursor++ } + case key.Matches(msg, m.keys.Home): + m.runCursor = 0 + case key.Matches(msg, m.keys.End): + if g, ok := m.detailSession(); ok { + m.runCursor = maxInt(len(g.runs)-1, 0) + } } return m, nil } @@ -442,12 +450,12 @@ func (m model) chainBadge() string { return "" } if m.verifyErr != nil { - return m.styles.bad.Render("审计日志校验失败") + return m.styles.bad.Render(m.styles.glyphs.Warn + " audit verify error") } if m.verifyResult.OK { return "" } - return m.styles.bad.Render(fmt.Sprintf("审计日志异常 seq=%d", m.verifyResult.BrokenSeq)) + return m.styles.bad.Render(fmt.Sprintf("%s audit chain broken at seq=%d", m.styles.glyphs.Warn, m.verifyResult.BrokenSeq)) } func (m model) helpKeyMap() help.KeyMap { @@ -459,7 +467,14 @@ func (m model) helpKeyMap() help.KeyMap { case m.sessionFocus != "": return helpMap{short: []key.Binding{hk("j/k", "move"), hk("enter", "open"), hk("esc", "exit focus")}} default: - return helpMap{short: []key.Binding{hk("j/k", "move"), hk("enter", "open"), hk("d", "open"), hk("l", "focus session"), hk("/", "filter")}} + return helpMap{ + short: []key.Binding{hk("enter", "open"), hk("l", "focus"), hk("/", "filter")}, + full: [][]key.Binding{ + {hk("j/k", "move"), hk("g/G", "home/end")}, + {hk("enter", "open session"), hk("l", "focus session")}, + {hk("/", "filter")}, + }, + } } } @@ -891,6 +906,20 @@ func (m model) detailSession() (sessionGroup, bool) { return sessionGroup{}, false } +// detailSessionID returns the focused session's id while the viewer is drilled into +// a session's command detail, so the shell breadcrumb can carry the session identity. +// It reports false at the list (no session is the active subject). +func (m model) detailSessionID() (string, bool) { + if m.focus != focusDetail { + return "", false + } + g, ok := m.detailSession() + if !ok { + return "", false + } + return g.id, true +} + func (m model) renderSessionDetail() string { g, ok := m.detailSession() if !ok { @@ -899,10 +928,12 @@ func (m model) renderSessionDetail() string { m.runCursor = clamp(m.runCursor, 0, len(g.runs)-1) w := m.detailWidth() var b strings.Builder - // Single-line header: session id + label and the bound host with its user@ip, - // all on one row. Over-wide content is clamped by the final MaxWidth below. - fmt.Fprintf(&b, "%s\n", m.renderSessionHeader(g)) - fmt.Fprintf(&b, "%s\n\n", m.styles.dim.Render(strings.Repeat("─", clamp(w, 1, 72)))) + // The session's identity (id, host, user@ip) now lives in the shell breadcrumb, so + // the body opens straight into the command list. Only tamper evidence — a security + // signal, not a statistic — still surfaces here, above the commands. + if sessionHasBrokenSeq(g, m.brokenSeq) { + fmt.Fprintf(&b, "%s\n\n", m.styles.bad.Render(m.styles.glyphs.Warn+" tamper evidence in this session")) + } b.WriteString(m.renderCommandList(g)) // Clamp every line to the frame so an over-wide row truncates (ANSI aware) // instead of wrapping onto a second row under the shell's Width(). @@ -928,22 +959,6 @@ func (m model) renderCommandList(g sessionGroup) string { return b.String() } -// renderSessionHeader is the session detail's single identity line: the session id -// and label (bold), then the bound host with its user@ip connection (dim context). -// Aggregate statistics (command/denied/failed counts, last-activity clock) are -// intentionally omitted — each command below shows its own status and time. Tamper -// evidence stays: it is a security signal, not a statistic. -func (m model) renderSessionHeader(g sessionGroup) string { - parts := []string{m.styles.header.Render(orDash(g.id) + " · " + labelOr(g.label))} - if host := sessionHost(g); host != "" { - parts = append(parts, m.styles.dim.Render(hostLine(host, m.hosts))) - } - if sessionHasBrokenSeq(g, m.brokenSeq) { - parts = append(parts, m.styles.bad.Render(m.styles.glyphs.Warn+" tamper evidence in this session")) - } - return strings.Join(parts, m.styles.dim.Render(" · ")) -} - type commandListColumnWidths struct { status int cmd int @@ -1071,17 +1086,6 @@ func commandStatus(g theme.Glyphs, run runSummary) string { } } -// sessionHost is the single host a session is bound to: the first non-empty host -// across its runs. Sessions are one-host by construction, so this is unambiguous. -func sessionHost(g sessionGroup) string { - for _, run := range g.runs { - if run.latest.Host != "" { - return run.latest.Host - } - } - return "" -} - func sessionHasBrokenSeq(g sessionGroup, seq *uint64) bool { for _, run := range g.runs { if runHasSeq(run, seq) { @@ -1113,18 +1117,6 @@ func runHasSeq(run runSummary, seq *uint64) bool { return false } -func hostLine(host string, hosts map[string]HostMeta) string { - meta, ok := hosts[host] - if !ok || (meta.User == "" && meta.Addr == "") { - return host - } - target := meta.Addr - if meta.User != "" { - target = meta.User + "@" + meta.Addr - } - return fmt.Sprintf("%s (%s)", host, target) -} - func spanOf(records []audit.Record) (string, string) { if len(records) == 0 { return "", "" @@ -1168,6 +1160,13 @@ func maxInt(a, b int) int { return b } +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + func clockOf(ts string) string { if ts == "" { return "--:--:--" diff --git a/internal/tui/policy_section.go b/internal/tui/policy_section.go index 801e37e..69672e7 100644 --- a/internal/tui/policy_section.go +++ b/internal/tui/policy_section.go @@ -9,7 +9,6 @@ import ( "github.com/Praeviso/AgentSSH/internal/inventory" "github.com/Praeviso/AgentSSH/internal/policy" - "github.com/Praeviso/AgentSSH/internal/theme" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -17,20 +16,57 @@ import ( "github.com/charmbracelet/lipgloss" ) -// policySection renders the single global policy (the permission model). In a -// host's detail screen it is scoped to that host: the command test evaluates as -// the host, and the header names it. The policy itself is global; only the -// evaluation context is per-host. +// policySection renders either the top-level Policy tab (host == "") or the +// host-scoped Policy pane in a detail screen. Rule groups are authoring presets; +// only global rules and stamped/host-overrides participate in evaluation. type policySection struct { path string inventory inventory.Inventory config policy.Config styles appStyles input textinput.Model - host string // the host this pane is scoped to (detail screen); "" = global - result string - err error - w, h int + mode policyMode + host string // detail host; "" means top-level Policy tab + + cardCursor int + ruleCursor int + groupCursor int + target policyTarget + + result string + err error + w, h int +} + +type policyChangedMsg struct { + config policy.Config + text string +} + +type policyMode int + +const ( + policyModeBrowse policyMode = iota + policyModeTest + policyModeRuleAdd + policyModeRuleEdit + policyModeGroupCreate + policyModeGroupPicker + policyModeConfirmHostDelete + policyModeConfirmGroupDelete +) + +type policyTargetKind int + +const ( + policyTargetNone policyTargetKind = iota + policyTargetGlobal + policyTargetGroup +) + +type policyTarget struct { + kind policyTargetKind + group string } func newPolicySection(path string, inv inventory.Inventory, cfg policy.Config, st appStyles, err error) policySection { @@ -40,26 +76,78 @@ func newPolicySection(path string, inv inventory.Inventory, cfg policy.Config, s return policySection{path: path, inventory: inv, config: cfg, styles: st, input: ti, err: err} } -func (s policySection) capturing() bool { return s.input.Focused() } +func (s policySection) capturing() bool { return s.mode != policyModeBrowse || s.input.Focused() } -// atRoot reports whether the pane is at its base state (no test input open), so -// the shell's esc exits the detail screen rather than blurring the input. -func (s policySection) atRoot() bool { return !s.input.Focused() } +// atRoot reports whether the pane is at its base state, so the detail shell's +// esc can exit the host screen. The top-level Policy tab handles its own esc. +func (s policySection) atRoot() bool { + return s.mode == policyModeBrowse && !s.input.Focused() && (s.host != "" || s.target.kind == policyTargetNone) +} -// withHost scopes this pane to host (its command test evaluates as that host). +// withHost scopes this pane to host. Passing "" returns it to the top-level +// Policy tab and clears any open group/global rule list. func (s policySection) withHost(host string) policySection { s.host = host s.result = "" + s.err = nil s.input.SetValue("") s.input.Blur() + s.mode = policyModeBrowse + s.ruleCursor = 0 + s.groupCursor = 0 + if host != "" { + s.target = policyTarget{} + } + if host == "" { + s.target = policyTarget{} + } return s } func (s policySection) helpKeyMap() help.KeyMap { - if s.input.Focused() { + switch s.mode { + case policyModeTest: return helpMap{short: []key.Binding{hk("enter", "evaluate"), hk("esc", "cancel")}} + case policyModeRuleAdd: + return helpMap{short: []key.Binding{hk("enter", "add rule"), hk("esc", "cancel")}} + case policyModeRuleEdit: + return helpMap{short: []key.Binding{hk("enter", "save rule"), hk("esc", "cancel")}} + case policyModeGroupCreate: + return helpMap{short: []key.Binding{hk("enter", "create group"), hk("esc", "cancel")}} + case policyModeGroupPicker: + return helpMap{short: []key.Binding{hk("j/k", "choose group"), hk("enter", "stamp"), hk("esc", "cancel")}} + case policyModeConfirmHostDelete, policyModeConfirmGroupDelete: + return helpMap{short: []key.Binding{hk("y", "confirm"), hk("n/esc", "cancel")}} + } + if s.host != "" { + // Host detail Policy pane: footer shows the few core edits; ? lists all, + // grouped by navigation / host-rule edits / authoring / testing. + short := []key.Binding{hk("a", "add rule"), hk("p", "from group"), hk("r", "remove")} + full := [][]key.Binding{ + {hk("j/k", "select"), hk("g/G", "home/end"), hk("t", "test")}, + {hk("a", "add host rule"), hk("p", "add from group")}, + {hk("r", "remove rule"), hk("R", "remove group"), hk("x", "remove all")}, + } + return helpMap{short: short, full: full} + } + if s.target.kind != policyTargetNone { + // Drilled into the Global card or a rule group: edit its rule list. + short := []key.Binding{hk("a", "add"), hk("e", "edit"), hk("r", "remove")} + full := [][]key.Binding{ + {hk("j/k", "select"), hk("g/G", "home/end")}, + {hk("a", "add rule"), hk("e", "edit rule"), hk("r", "remove rule")}, + {hk("t", "test"), hk("esc", "back to cards")}, + } + return helpMap{short: short, full: full} + } + // Policy cards overview. + short := []key.Binding{hk("enter", "open"), hk("n", "new group"), hk("t", "test")} + full := [][]key.Binding{ + {hk("↑↓←→/hjkl", "move"), hk("g/G", "home/end")}, + {hk("enter", "open card"), hk("n", "new group"), hk("d", "delete group")}, + {hk("t", "test a command")}, } - return helpMap{short: []key.Binding{hk("t", "test a command")}} + return helpMap{short: short, full: full} } func (s policySection) Init() tea.Cmd { return nil } @@ -68,32 +156,23 @@ func (s policySection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: s.w, s.h = msg.Width, msg.Height - if s.w > 0 { - w := s.w - lipgloss.Width(s.input.Prompt) - 1 - if w < 8 { - w = 8 - } - s.input.Width = w - } + s.resizeInput() case tea.KeyMsg: if s.input.Focused() { - switch msg.String() { - case "enter": - s.evaluate() - s.input.Blur() - return s, nil - case "esc": - s.input.Blur() - return s, nil - } - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - return s, cmd + return s.updateInput(msg) } - switch msg.String() { - case "t", "/": - return s, s.input.Focus() + switch s.mode { + case policyModeGroupPicker: + return s.updateGroupPicker(msg) + case policyModeConfirmHostDelete: + return s.updateHostDeleteConfirm(msg) + case policyModeConfirmGroupDelete: + return s.updateGroupDeleteConfirm(msg) + } + if s.host != "" { + return s.updateHostKeys(msg) } + return s.updateTopLevelKeys(msg) } if s.input.Focused() { var cmd tea.Cmd @@ -103,6 +182,265 @@ func (s policySection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } +func (s *policySection) resizeInput() { + if s.w <= 0 { + return + } + w := s.w - 4 - lipgloss.Width(s.input.Prompt) + if w < 8 { + w = 8 + } + s.input.Width = w +} + +func (s policySection) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + var saveCmd tea.Cmd + switch s.mode { + case policyModeRuleAdd: + saveCmd = s.addRuleFromInput() + case policyModeRuleEdit: + saveCmd = s.editRuleFromInput() + case policyModeGroupCreate: + saveCmd = s.createGroupFromInput() + default: + s.evaluate() + } + s.input.Blur() + s.mode = policyModeBrowse + return s, saveCmd + case "esc": + s.input.Blur() + s.mode = policyModeBrowse + return s, nil + } + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd +} + +func (s policySection) updateTopLevelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if s.target.kind != policyTargetNone { + switch msg.String() { + case "esc": + s.target = policyTarget{} + s.ruleCursor = 0 + s.result = "" + return s, nil + case "a": + s.beginRuleInput(policyModeRuleAdd, policy.Rule{}) + return s, s.input.Focus() + case "e": + rule, ok := s.selectedTargetRule() + if !ok { + s.result = "no rule selected" + s.err = nil + return s, nil + } + s.beginRuleInput(policyModeRuleEdit, rule) + return s, s.input.Focus() + case "r", "delete", "backspace": + return s.removeSelectedTargetRule() + case "j", "down": + s.moveRuleCursor(1, len(s.targetRules())) + case "k", "up": + s.moveRuleCursor(-1, len(s.targetRules())) + case "g", "home": + s.ruleCursor = 0 + case "G", "end": + s.ruleCursor = maxInt(len(s.targetRules())-1, 0) + case "t", "/": + s.beginTestInput() + return s, s.input.Focus() + } + return s, nil + } + + switch msg.String() { + case "t", "/": + s.beginTestInput() + return s, s.input.Focus() + case "n": + s.err = nil + s.result = "" + s.mode = policyModeGroupCreate + s.input.SetValue("") + s.input.Placeholder = "readonly" + s.input.Prompt = "group> " + s.resizeInput() + return s, s.input.Focus() + case "d", "delete", "backspace": + targets := s.policyTargets() + if len(targets) == 0 || s.cardCursor >= len(targets) || targets[s.cardCursor].kind != policyTargetGroup { + s.err = nil + s.result = "global policy cannot be deleted" + return s, nil + } + s.err = nil + s.mode = policyModeConfirmGroupDelete + s.result = fmt.Sprintf("delete rule group %s? y/n", targets[s.cardCursor].group) + case "enter", "i": + targets := s.policyTargets() + if len(targets) == 0 { + return s, nil + } + if s.cardCursor >= len(targets) { + s.cardCursor = len(targets) - 1 + } + s.target = targets[s.cardCursor] + s.ruleCursor = 0 + s.result = "" + case "left", "h": + if s.cardCursor > 0 { + s.cardCursor-- + } + case "right", "l": + if s.cardCursor < len(s.policyTargets())-1 { + s.cardCursor++ + } + case "up", "k": + cols := s.policyGridCols() + if s.cardCursor-cols >= 0 { + s.cardCursor -= cols + } + case "down", "j": + cols := s.policyGridCols() + last := len(s.policyTargets()) - 1 + if s.cardCursor+cols <= last { + s.cardCursor += cols + } else if s.cardCursor != last && cols > 0 && s.cardCursor/cols < last/cols { + s.cardCursor = last + } + case "home", "g": + s.cardCursor = 0 + case "end", "G": + s.cardCursor = maxInt(len(s.policyTargets())-1, 0) + } + return s, nil +} + +func (s policySection) updateHostKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "t", "/": + s.beginTestInput() + return s, s.input.Focus() + case "a": + s.beginRuleInput(policyModeRuleAdd, policy.Rule{}) + return s, s.input.Focus() + case "p": + groups := s.sortedRuleGroupNames() + if len(groups) == 0 { + s.err = nil + s.result = "no rule groups" + return s, nil + } + if s.groupCursor >= len(groups) { + s.groupCursor = len(groups) - 1 + } + s.err = nil + s.result = "" + s.mode = policyModeGroupPicker + case "j", "down": + s.moveRuleCursor(1, len(s.hostPolicyRows())) + case "k", "up": + s.moveRuleCursor(-1, len(s.hostPolicyRows())) + case "g", "home": + s.ruleCursor = 0 + case "G", "end": + s.ruleCursor = maxInt(len(s.hostPolicyRows())-1, 0) + case "r": + return s.removeSelectedHostRule() + case "R": + return s.removeSelectedHostGroup() + case "x": + if _, ok := s.hostRuleSet(); ok { + s.mode = policyModeConfirmHostDelete + s.err = nil + s.result = "delete host policy rules? y/n" + } else { + s.err = nil + s.result = "host has no rules" + } + } + return s, nil +} + +func (s policySection) updateGroupPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + groups := s.sortedRuleGroupNames() + switch msg.String() { + case "j", "down": + if s.groupCursor < len(groups)-1 { + s.groupCursor++ + } + case "k", "up": + if s.groupCursor > 0 { + s.groupCursor-- + } + case "g", "home": + s.groupCursor = 0 + case "G", "end": + s.groupCursor = maxInt(len(groups)-1, 0) + case "enter": + if len(groups) == 0 { + s.mode = policyModeBrowse + s.result = "no rule groups" + return s, nil + } + return s.stampSelectedGroup(groups[s.groupCursor]) + case "esc": + s.mode = policyModeBrowse + s.result = "" + } + return s, nil +} + +func (s policySection) updateHostDeleteConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y": + return s.removeHostRuleSet() + case "n", "esc": + s.mode = policyModeBrowse + s.result = "" + } + return s, nil +} + +func (s policySection) updateGroupDeleteConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y": + return s.deleteSelectedGroup() + case "n", "esc": + s.mode = policyModeBrowse + s.result = "" + } + return s, nil +} + +func (s *policySection) beginTestInput() { + s.err = nil + s.result = "" + s.mode = policyModeTest + s.input.SetValue("") + s.input.Placeholder = "command to test (or host:cmd)" + s.input.Prompt = "test> " + s.resizeInput() +} + +func (s *policySection) beginRuleInput(mode policyMode, rule policy.Rule) { + s.err = nil + s.result = "" + s.mode = mode + if mode == policyModeRuleEdit { + s.input.SetValue(formatRuleInput(rule)) + } else { + s.input.SetValue("") + } + s.input.Placeholder = "allow 0 ^systemctl status\\b" + s.input.Prompt = "rule> " + s.resizeInput() +} + func (s *policySection) evaluate() { if s.path != "" { cfg, err := loadPolicy(s.path) @@ -121,7 +459,7 @@ func (s *policySection) evaluate() { } host, command := parsePolicyTestInput(s.input.Value()) if host == "" { - host = s.host // scope to the detail host when the input omits one + host = s.host } if strings.TrimSpace(command) == "" { s.err = errors.New("enter a command to test") @@ -142,6 +480,419 @@ func (s *policySection) evaluate() { } } +func (s policySection) hostRuleSet() (policy.HostRuleSet, bool) { + return policy.LookupHostRules(policy.Bundle{Policy: s.config, Inventory: s.inventory}, s.host) +} + +func (s *policySection) reloadForWrite() (policy.Bundle, error) { + cfg := s.config + if s.path != "" { + loaded, err := policy.Load(s.path) + if err != nil { + return policy.Bundle{}, err + } + cfg = loaded + s.config = loaded + } + return policy.Bundle{Policy: cfg, Inventory: s.inventory}, nil +} + +func (s *policySection) savePolicy(next policy.Config) error { + if s.path == "" { + s.config = next + return nil + } + return policy.Save(s.path, next) +} + +func (s *policySection) addRuleFromInput() tea.Cmd { + rule, err := parseRuleInput(s.input.Value()) + if err != nil { + s.err = err + s.result = "" + return nil + } + if err := validateTUIRule(rule); err != nil { + s.err = err + s.result = "" + return nil + } + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return nil + } + + var next policy.Bundle + switch { + case s.host != "": + next, err = policy.AddHostRule(bundle, s.host, rule) + case s.target.kind == policyTargetGlobal: + next = cloneBundleForTUI(bundle) + next.Policy.Rules = append(next.Policy.Rules, rule) + case s.target.kind == policyTargetGroup: + next, err = policy.AddGroupRule(bundle, s.target.group, rule) + default: + s.err = errors.New("open a policy target before adding a rule") + s.result = "" + return nil + } + if err != nil { + s.err = err + s.result = "" + return nil + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + return nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return nil + } + s.config = next.Policy + s.err = nil + s.result = "rule added" + if s.host != "" { + s.ruleCursor = hostRowIndexForSource(s.hostPolicyRows(), policy.HostRulesKey(s.host), len(s.hostRuleSetRules())-1) + } else { + s.ruleCursor = maxInt(len(s.targetRules())-1, 0) + } + return policyChangedCmd(next.Policy, s.result) +} + +func (s *policySection) editRuleFromInput() tea.Cmd { + if s.host != "" { + return nil + } + rule, err := parseRuleInput(s.input.Value()) + if err != nil { + s.err = err + s.result = "" + return nil + } + if err := validateTUIRule(rule); err != nil { + s.err = err + s.result = "" + return nil + } + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return nil + } + next := cloneBundleForTUI(bundle) + switch s.target.kind { + case policyTargetGlobal: + if s.ruleCursor < 0 || s.ruleCursor >= len(next.Policy.Rules) { + s.err = errors.New("no rule selected") + s.result = "" + return nil + } + next.Policy.Rules[s.ruleCursor] = rule + case policyTargetGroup: + next, err = policy.UpdateGroupRule(bundle, s.target.group, s.ruleCursor, rule) + if err != nil { + s.err = err + s.result = "" + return nil + } + default: + s.err = errors.New("open a policy target before editing a rule") + s.result = "" + return nil + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + return nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return nil + } + s.config = next.Policy + s.err = nil + s.result = "rule updated" + s.clampRuleCursor(len(s.targetRules())) + return policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) removeSelectedTargetRule() (tea.Model, tea.Cmd) { + if s.host != "" || s.target.kind == policyTargetNone { + return s, nil + } + if len(s.targetRules()) == 0 { + s.err = nil + s.result = "no rule selected" + return s, nil + } + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return s, nil + } + next := cloneBundleForTUI(bundle) + switch s.target.kind { + case policyTargetGlobal: + if s.ruleCursor < 0 || s.ruleCursor >= len(next.Policy.Rules) { + s.err = errors.New("no rule selected") + s.result = "" + return s, nil + } + next.Policy.Rules = append(next.Policy.Rules[:s.ruleCursor], next.Policy.Rules[s.ruleCursor+1:]...) + case policyTargetGroup: + next, err = policy.RemoveGroupRule(bundle, s.target.group, s.ruleCursor) + if err != nil { + s.err = err + s.result = "" + return s, nil + } + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return s, nil + } + s.config = next.Policy + s.clampRuleCursor(len(s.targetRules())) + s.err = nil + s.result = "rule removed" + return s, policyChangedCmd(next.Policy, s.result) +} + +func (s *policySection) createGroupFromInput() tea.Cmd { + name := strings.TrimSpace(s.input.Value()) + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return nil + } + next, err := policy.CreateGroup(bundle, name) + if err != nil { + s.err = err + s.result = "" + return nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return nil + } + s.config = next.Policy + s.cardCursor = targetIndex(s.policyTargets(), policyTarget{kind: policyTargetGroup, group: name}) + s.err = nil + s.result = "group created" + return policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) deleteSelectedGroup() (tea.Model, tea.Cmd) { + targets := s.policyTargets() + if len(targets) == 0 || s.cardCursor >= len(targets) || targets[s.cardCursor].kind != policyTargetGroup { + s.mode = policyModeBrowse + s.result = "global policy cannot be deleted" + return s, nil + } + groupName := targets[s.cardCursor].group + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + next, err := policy.DeleteGroup(bundle, groupName) + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + s.config = next.Policy + s.mode = policyModeBrowse + s.cardCursor = maxInt(minInt(s.cardCursor, len(s.policyTargets())-1), 0) + s.err = nil + s.result = "group deleted" + return s, policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) stampSelectedGroup(groupName string) (tea.Model, tea.Cmd) { + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + next, err := policy.StampGroupOntoHost(bundle, s.host, groupName) + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + s.config = next.Policy + s.mode = policyModeBrowse + s.err = nil + s.result = "group stamped" + s.ruleCursor = firstHostGroupRow(s.hostPolicyRows(), groupName) + return s, policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) removeSelectedHostRule() (tea.Model, tea.Cmd) { + row, ok := s.selectedHostPolicyRow() + if !ok { + s.err = nil + s.result = "no host rule selected" + return s, nil + } + if row.Scope != "host" { + s.err = nil + s.result = "global rows are read-only here" + return s, nil + } + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return s, nil + } + var next policy.Bundle + if row.Source == policy.HostRulesKey(s.host) { + next, err = policy.RemoveHostRule(bundle, s.host, row.Index) + } else { + next, err = removeHostOverrideRuleForTUI(bundle, row.Source, row.Index) + } + if err != nil { + s.err = err + s.result = "" + return s, nil + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return s, nil + } + s.config = next.Policy + s.clampRuleCursor(len(s.hostPolicyRows())) + s.err = nil + s.result = "host rule removed" + return s, policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) removeSelectedHostGroup() (tea.Model, tea.Cmd) { + row, ok := s.selectedHostPolicyRow() + if !ok || row.Scope != "host" || strings.TrimSpace(row.Rule.Group) == "" { + s.err = nil + s.result = "select a stamped group row" + return s, nil + } + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + return s, nil + } + // Remove the group from the override that owns the selected row, not blindly + // from this host's own rules; the row may be inherited from a group override. + var next policy.Bundle + if row.Source == policy.HostRulesKey(s.host) { + next, err = policy.RemoveHostGroup(bundle, s.host, row.Rule.Group) + } else { + next, err = removeHostOverrideGroupForTUI(bundle, row.Source, row.Rule.Group) + } + if err != nil { + s.err = err + s.result = "" + return s, nil + } + if err := validatePolicyForTUI(next.Policy, s.inventory); err != nil { + s.err = err + s.result = "" + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + return s, nil + } + s.config = next.Policy + s.clampRuleCursor(len(s.hostPolicyRows())) + s.err = nil + s.result = "host group removed" + return s, policyChangedCmd(next.Policy, s.result) +} + +func (s policySection) removeHostRuleSet() (tea.Model, tea.Cmd) { + bundle, err := s.reloadForWrite() + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + next, err := policy.ClearHostRules(bundle, s.host) + if err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + if err := s.savePolicy(next.Policy); err != nil { + s.err = err + s.result = "" + s.mode = policyModeBrowse + return s, nil + } + s.config = next.Policy + s.mode = policyModeBrowse + s.err = nil + s.result = "host policy removed" + s.ruleCursor = 0 + return s, policyChangedCmd(next.Policy, s.result) +} + +func policyChangedCmd(cfg policy.Config, text string) tea.Cmd { + return func() tea.Msg { + return policyChangedMsg{config: cfg, text: text} + } +} + func parsePolicyTestInput(value string) (string, string) { value = strings.TrimSpace(value) host, command, ok := strings.Cut(value, ":") @@ -151,142 +902,610 @@ func parsePolicyTestInput(value string) (string, string) { return "", value } -func (s policySection) View() string { - fit := func(line string) string { - if s.w > 0 { - return truncate(line, s.w) +func parseRuleInput(value string) (policy.Rule, error) { + fields := strings.Fields(strings.TrimSpace(value)) + if len(fields) < 2 { + return policy.Rule{}, errors.New("enter: allow|deny [priority] ") + } + action := policy.Action(fields[0]) + if action != policy.ActionAllow && action != policy.ActionDeny { + return policy.Rule{}, errors.New("rule action must be allow or deny") + } + priority := 0 + regexStart := 1 + if len(fields) >= 3 { + if parsed, err := strconv.Atoi(fields[1]); err == nil { + priority = parsed + regexStart = 2 } - return line } + cmdRegex := strings.TrimSpace(strings.Join(fields[regexStart:], " ")) + if cmdRegex == "" { + return policy.Rule{}, errors.New("enter a command regex") + } + return policy.Rule{ + Priority: priority, + Match: policy.Match{CmdRegex: cmdRegex}, + Action: action, + }, nil +} + +func parseHostRuleInput(value string) (policy.Rule, error) { + return parseRuleInput(value) +} + +func formatRuleInput(rule policy.Rule) string { + return strings.TrimSpace(fmt.Sprintf("%s %d %s", rule.Action, rule.Priority, rule.Match.CmdRegex)) +} - // The pane identity ("Policy" + the host) already lives in the shell breadcrumb, - // so the body starts directly with the policy panels. - lines := strings.Split(s.renderPolicyBody(), "\n") +func validateTUIRule(rule policy.Rule) error { + _, err := policy.NewEngine(policy.Config{Rules: []policy.Rule{{ + Name: "validate", + Match: rule.Match, + Action: rule.Action, + Priority: rule.Priority, + }}}, inventory.Inventory{}) + return err +} + +func validatePolicyForTUI(cfg policy.Config, inv inventory.Inventory) error { + _, err := policy.NewEngine(cfg, inv) + return err +} - // The test area appears below the policy body, set off by a blank line, and only - // while a test is active (input focused) or there is a result/error to show. The - // static "press t" affordance lives in the footer help bar, not in the body. - var test []string +func (s policySection) View() string { + if s.host != "" { + return s.hostPolicyView() + } + if s.target.kind != policyTargetNone { + return s.targetRuleListView() + } + return s.policyCardsView() +} + +func (s policySection) hostPolicyView() string { + inner := s.w - 2 + if inner < 8 { + inner = 8 + } + content := s.renderHostPolicyBody(inner) + if extra := s.extraLines(inner); extra != "" { + content += "\n\n" + extra + } + // Borderless, like the Sessions list: the rule rows read as one flat table + // rather than a boxed card. The shell clamps width/height around it. + return s.styles.background.MaxWidth(s.w).Render(content) +} + +func (s policySection) targetRuleListView() string { + inner := s.w - 2 + if inner < 8 { + inner = 8 + } + content := s.renderTargetRuleList(inner) + if extra := s.extraLines(inner); extra != "" { + content += "\n\n" + extra + } + return s.styles.background.MaxWidth(s.w).Render(content) +} + +func (s policySection) extraLines(inner int) string { + var extra []string + if s.mode == policyModeGroupPicker { + extra = append(extra, s.renderGroupPicker(inner)) + } if s.input.Focused() { - test = append(test, s.input.View()) + extra = append(extra, s.input.View()) } if s.err != nil { - test = append(test, fit(s.styles.err.Render(s.err.Error()))) - } - if s.result != "" { - style := s.styles.ok - if strings.HasPrefix(s.result, string(policy.ActionDeny)) { + extra = append(extra, truncate(s.styles.err.Render(s.err.Error()), inner)) + } else if s.result != "" { + style := s.styles.dim + switch { + case strings.HasPrefix(s.result, string(policy.ActionDeny)): style = s.styles.deny + case strings.HasPrefix(s.result, string(policy.ActionAllow)): + style = s.styles.ok + case s.mode == policyModeConfirmHostDelete || s.mode == policyModeConfirmGroupDelete: + style = s.styles.confirm } - test = append(test, fit(style.Render(s.result))) + extra = append(extra, truncate(style.Render(s.result), inner)) } - if len(test) > 0 { - lines = append(lines, "") - lines = append(lines, test...) + return strings.Join(extra, "\n") +} + +func (s policySection) renderHostPolicyBody(inner int) string { + var b strings.Builder + b.WriteString(s.styles.header.Render(truncate(s.styles.glyphs.Marker+" "+s.host, inner))) + b.WriteString("\n") + b.WriteString(s.styles.dim.Render("host tier evaluates before global · global rows are read-only here")) + rows := s.hostPolicyRows() + if len(rows) == 0 { + b.WriteString("\n\n") + b.WriteString(s.styles.dim.Render("No active rules. Default deny still applies.")) + b.WriteString("\n") + b.WriteString(s.styles.dim.Render(outputSummary(s.config.Output))) + return b.String() } - // The shell clamps the body to the pane height (MaxHeight); on a short terminal - // the lower panels simply clip. - return strings.Join(lines, "\n") + start, end := scrollWindow(s.ruleCursor, len(rows), s.ruleVisibleHeight()) + tableRows := make([][]string, 0, end-start) + for _, row := range rows[start:end] { + group := "" + if row.Scope == "host" { + group = row.Rule.Group + } + action := string(row.Rule.Action) + if row.Rule.Action == policy.ActionDeny { + action = s.styles.deny.Render(action) + } else { + action = s.styles.ok.Render(action) + } + tableRows = append(tableRows, []string{ + row.Scope, + strconv.Itoa(row.Rule.Priority), + action, + row.Rule.Match.CmdRegex, + group, + }) + } + cursor := -1 + if s.ruleCursor >= start && s.ruleCursor < end { + cursor = s.ruleCursor - start + } + b.WriteString("\n\n") + b.WriteString(renderTable(s.styles, policyRuleColumns(), tableRows, cursor, inner, true)) + if end < len(rows) || start > 0 { + b.WriteString("\n") + b.WriteString(s.styles.dim.Render(fmt.Sprintf("rows %d-%d of %d", start+1, end, len(rows)))) + } + b.WriteString("\n") + b.WriteString(s.styles.dim.Render("output · " + outputSummary(s.config.Output))) + return b.String() } -// panel wraps body in a full-width rounded-border box (the same style as the host -// info card), so each policy section is clearly framed instead of bunched together. -func (s policySection) panel(body string) string { - boxW := s.w - 2 // border adds one column each side, outside Width - if boxW < 12 { - boxW = 12 +func (s policySection) renderTargetRuleList(inner int) string { + title := "Global" + if s.target.kind == policyTargetGroup { + title = "Group: " + s.target.group + } + rules := s.targetRules() + var b strings.Builder + b.WriteString(s.styles.header.Render(truncate(s.styles.glyphs.Marker+" "+title, inner))) + b.WriteString("\n") + b.WriteString(s.styles.dim.Render("rules use: allow|deny [priority] ")) + if len(rules) == 0 { + b.WriteString("\n\n") + b.WriteString(s.styles.dim.Render("No rules. Press a to add one.")) + return b.String() + } + start, end := scrollWindow(s.ruleCursor, len(rules), s.ruleVisibleHeight()) + tableRows := make([][]string, 0, end-start) + for _, rule := range rules[start:end] { + action := string(rule.Action) + if rule.Action == policy.ActionDeny { + action = s.styles.deny.Render(action) + } else { + action = s.styles.ok.Render(action) + } + tableRows = append(tableRows, []string{ + strconv.Itoa(rule.Priority), + action, + rule.Match.CmdRegex, + rule.Group, + }) + } + cursor := -1 + if s.ruleCursor >= start && s.ruleCursor < end { + cursor = s.ruleCursor - start } - return s.styles.panel.Width(boxW).Render(body) + b.WriteString("\n\n") + b.WriteString(renderTable(s.styles, targetRuleColumns(), tableRows, cursor, inner, true)) + return b.String() } -// renderPolicyBody lays the global policy out as a stack of full-width bordered -// panels — defaults (posture + output), rules, and host overrides — each separated -// by a blank line. The tables fill their panel and shrink responsively. -func (s policySection) renderPolicyBody() string { - cfg := s.config - inner := s.w - 4 // panel content width: frame minus border (2) and padding (2) - if inner < 8 { - inner = 8 +func (s policySection) policyCardsView() string { + var b strings.Builder + if s.err != nil { + b.WriteString(truncate(s.styles.err.Render(s.err.Error()), s.w)) + b.WriteString("\n") + } + targets := s.policyTargets() + if len(targets) == 0 { + b.WriteString(s.styles.dim.Render("No policy targets.")) + return b.String() + } + s.clampCardCursor() + b.WriteString(s.policyCardGrid(targets)) + if extra := s.extraLines(maxInt(s.w, 8)); extra != "" { + b.WriteString("\n") + b.WriteString(extra) } + return b.String() +} - defaultPolicy := cfg.Defaults.Policy - if defaultPolicy == "" { - defaultPolicy = policy.ActionAllow +func (s policySection) policyCardGrid(targets []policyTarget) string { + cols := s.policyGridCols() + cardW := s.policyCardOuterWidth(cols) + totalRows := (len(targets) + cols - 1) / cols + avail := s.h + visRows := totalRows + if s.h > 0 { + fullGridH := totalRows*cardRowH - cardRowGap + reserve := 0 + if fullGridH > avail { + reserve = 1 + } + visRows = (avail - reserve + cardRowGap) / cardRowH + if visRows < 1 { + visRows = 1 + } + } + cursorRow := 0 + if cols > 0 { + cursorRow = s.cardCursor / cols } + startRow, endRow := scrollWindow(cursorRow, totalRows, visRows) + rows := make([]string, 0, endRow-startRow) + for r := startRow; r < endRow; r++ { + cards := make([]string, 0, cols*2) + for c := 0; c < cols; c++ { + idx := r*cols + c + if idx >= len(targets) { + break + } + if c > 0 { + cards = append(cards, strings.Repeat(" ", cardGap)) + } + cards = append(cards, s.renderPolicyCard(targets[idx], cardW, idx == s.cardCursor)) + } + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) + } + grid := strings.Join(rows, strings.Repeat("\n", cardRowGap+1)) + if endRow < totalRows || startRow > 0 { + grid += "\n" + s.styles.dim.Render(fmt.Sprintf("rows %d-%d of %d", startRow+1, endRow, totalRows)) + } + return grid +} - const kw = 16 // key column width for the aligned defaults rows - facts := s.styles.dim.Render(padRight("Default posture", kw)) + policyActionCell(s.styles.glyphs, defaultPolicy) + "\n" + - s.styles.dim.Render(padRight("Output", kw)) + fmt.Sprintf("max_bytes=%d · redactions=%d", cfg.Output.MaxBytes, len(cfg.Output.Redact)) +func (s policySection) renderPolicyCard(target policyTarget, outerW int, selected bool) string { + box := outerW - 2 + if box < 12 { + box = 12 + } + textW := box - 2 + if textW < 1 { + textW = 1 + } + name, rules := s.targetNameAndRules(target) + allow, deny := countRuleActions(rules) + labelStyle := s.styles.background + if selected { + labelStyle = s.styles.header + } + line1 := fitCell(labelStyle.Render(truncate(name, textW)), textW, false) + line2 := fitCell(s.styles.dim.Render(truncate(fmt.Sprintf("%d rules · %d allow · %d deny", len(rules), allow, deny), textW)), textW, false) + style := s.styles.card + if selected { + style = s.styles.cardSel + } + return style.Width(box).Render(lipgloss.JoinVertical(lipgloss.Left, line1, line2)) +} - panels := []string{ - s.panel(facts), - s.panel(s.styles.dim.Render("Rules") + "\n" + renderPolicyRules(s.styles, cfg, inner)), +func (s policySection) renderGroupPicker(inner int) string { + groups := s.sortedRuleGroupNames() + if len(groups) == 0 { + return s.styles.dim.Render("No rule groups") } - if ov := renderPolicyOverrides(s.styles, cfg, inner); ov != "" { - panels = append(panels, s.panel(s.styles.dim.Render("Host overrides")+"\n"+ov)) + start, end := scrollWindow(s.groupCursor, len(groups), 5) + var lines []string + lines = append(lines, s.styles.header.Render("Choose rule group")) + for i := start; i < end; i++ { + name := groups[i] + group := s.config.RuleGroups[name] + prefix := " " + if i == s.groupCursor { + prefix = s.styles.cursor.Render("> ") + } + lines = append(lines, truncate(prefix+name+s.styles.dim.Render(fmt.Sprintf(" · %d rule(s)", len(group.Rules))), inner)) } - return strings.Join(panels, "\n\n") + return strings.Join(lines, "\n") } -var policyRuleColumns = []tableColumn{ - {header: "NAME", min: 6, max: 24, weight: 1}, - {header: "ACTION", min: 7}, - {header: "CMD REGEX", min: 10, max: 80, weight: 3}, +func policyRuleColumns() []tableColumn { + return []tableColumn{ + {header: "SCOPE", min: 6, max: 8}, + {header: "PRIORITY", right: true, min: 8, max: 8}, + {header: "ACTION", min: 5, max: 7}, + {header: "COMMAND", min: 12, weight: 2}, + {header: "GROUP", min: 5, max: 18, weight: 1}, + } } -func policyActionCell(g theme.Glyphs, action policy.Action) string { - if action == policy.ActionDeny { - return g.Deny + " DENY" +func targetRuleColumns() []tableColumn { + return []tableColumn{ + {header: "PRIORITY", right: true, min: 8, max: 8}, + {header: "ACTION", min: 5, max: 7}, + {header: "COMMAND", min: 12, weight: 2}, + {header: "GROUP", min: 5, max: 18, weight: 1}, } - return g.OK + " ALLOW" } -func renderPolicyRules(st appStyles, cfg policy.Config, avail int) string { - if len(cfg.Rules) == 0 { - return st.dim.Render("(none)") +func (s policySection) ruleVisibleHeight() int { + if s.h <= 0 { + return 0 } - rows := make([][]string, 0, len(cfg.Rules)) - for i, rule := range cfg.Rules { - name := rule.Name - if name == "" { - name = fmt.Sprintf("[%d]", i) - } - rows = append(rows, []string{ - name, - policyActionCell(st.glyphs, rule.Action), - rule.Match.CmdRegex, - }) + visible := s.h - 9 + if visible < 1 { + return 1 } - return renderTable(st, policyRuleColumns, rows, -1, avail, true) + return visible } -func renderPolicyOverrides(st appStyles, cfg policy.Config, avail int) string { - if len(cfg.HostOverrides) == 0 { - return "" +func (s policySection) policyTargets() []policyTarget { + targets := []policyTarget{{kind: policyTargetGlobal}} + for _, name := range s.sortedRuleGroupNames() { + targets = append(targets, policyTarget{kind: policyTargetGroup, group: name}) } - rows := make([][]string, 0, len(cfg.HostOverrides)) - for _, name := range sortedOverrideNames(cfg.HostOverrides) { - override := cfg.HostOverrides[name] - rows = append(rows, []string{ - name, - policyActionCell(st.glyphs, override.Policy), - strconv.Itoa(len(override.AllowRules)), - }) + return targets +} + +func (s policySection) targetNameAndRules(target policyTarget) (string, []policy.Rule) { + switch target.kind { + case policyTargetGroup: + return target.group, s.config.RuleGroups[target.group].Rules + default: + return "Global", s.config.Rules } - return renderTable(st, hostOverrideColumns, rows, -1, avail, true) } -var hostOverrideColumns = []tableColumn{ - {header: "HOST", min: 6, max: 40, weight: 2}, - {header: "POLICY", min: 7}, - {header: "ALLOW", right: true, min: 5}, +func (s policySection) targetRules() []policy.Rule { + switch s.target.kind { + case policyTargetGlobal: + return s.config.Rules + case policyTargetGroup: + return s.config.RuleGroups[s.target.group].Rules + default: + return nil + } +} + +func (s policySection) selectedTargetRule() (policy.Rule, bool) { + rules := s.targetRules() + if s.ruleCursor < 0 || s.ruleCursor >= len(rules) { + return policy.Rule{}, false + } + return rules[s.ruleCursor], true } -func sortedOverrideNames(overrides map[string]policy.HostOverride) []string { - names := make([]string, 0, len(overrides)) - for name := range overrides { +func (s policySection) sortedRuleGroupNames() []string { + names := make([]string, 0, len(s.config.RuleGroups)) + for name := range s.config.RuleGroups { names = append(names, name) } sort.Strings(names) return names } + +func (s policySection) hostPolicyRows() []policy.EffectiveRule { + return policy.EffectiveRules(s.config, s.inventory, s.host) +} + +func (s policySection) selectedHostPolicyRow() (policy.EffectiveRule, bool) { + rows := s.hostPolicyRows() + if s.ruleCursor < 0 || s.ruleCursor >= len(rows) { + return policy.EffectiveRule{}, false + } + return rows[s.ruleCursor], true +} + +func (s policySection) hostRuleSetRules() []policy.Rule { + if ruleSet, ok := s.hostRuleSet(); ok { + return ruleSet.Override.Rules + } + return nil +} + +func (s *policySection) moveRuleCursor(delta int, n int) { + if n <= 0 { + s.ruleCursor = 0 + return + } + s.ruleCursor += delta + if s.ruleCursor < 0 { + s.ruleCursor = 0 + } + if s.ruleCursor >= n { + s.ruleCursor = n - 1 + } +} + +func (s *policySection) clampRuleCursor(n int) { + if n <= 0 { + s.ruleCursor = 0 + return + } + if s.ruleCursor >= n { + s.ruleCursor = n - 1 + } + if s.ruleCursor < 0 { + s.ruleCursor = 0 + } +} + +func (s *policySection) clampCardCursor() { + n := len(s.policyTargets()) + if n <= 0 { + s.cardCursor = 0 + return + } + if s.cardCursor >= n { + s.cardCursor = n - 1 + } + if s.cardCursor < 0 { + s.cardCursor = 0 + } +} + +func (s policySection) policyGridCols() int { + if s.w <= 0 { + return 1 + } + cols := (s.w + cardGap) / (cardMinOuter + cardGap) + if cols < 1 { + cols = 1 + } + if n := len(s.policyTargets()); cols > n && n > 0 { + cols = n + } + return cols +} + +func (s policySection) policyCardOuterWidth(cols int) int { + if cols < 1 { + cols = 1 + } + avail := s.w - cardGap*(cols-1) + w := avail / cols + if w < cardMinOuter { + w = cardMinOuter + } + return w +} + +func cloneBundleForTUI(bundle policy.Bundle) policy.Bundle { + next := bundle + next.Policy = clonePolicyConfigForTUI(bundle.Policy) + return next +} + +func clonePolicyConfigForTUI(cfg policy.Config) policy.Config { + next := cfg + next.Rules = append([]policy.Rule(nil), cfg.Rules...) + if cfg.RuleGroups != nil { + next.RuleGroups = make(map[string]policy.RuleGroup, len(cfg.RuleGroups)) + for name, group := range cfg.RuleGroups { + group.Rules = append([]policy.Rule(nil), group.Rules...) + next.RuleGroups[name] = group + } + } + if cfg.HostOverrides != nil { + next.HostOverrides = make(map[string]policy.HostOverride, len(cfg.HostOverrides)) + for name, override := range cfg.HostOverrides { + override.Rules = append([]policy.Rule(nil), override.Rules...) + next.HostOverrides[name] = override + } + } + next.Output.Redact = append([]string(nil), cfg.Output.Redact...) + return next +} + +func removeHostOverrideRuleForTUI(bundle policy.Bundle, source string, index int) (policy.Bundle, error) { + override, ok := bundle.Policy.HostOverrides[source] + if !ok { + return bundle, fmt.Errorf("host override %q not found", source) + } + if index < 0 || index >= len(override.Rules) { + return bundle, fmt.Errorf("host override %q rule index %d out of range", source, index) + } + next := cloneBundleForTUI(bundle) + override = next.Policy.HostOverrides[source] + override.Rules = append(override.Rules[:index], override.Rules[index+1:]...) + next.Policy.HostOverrides[source] = override + return next, nil +} + +// removeHostOverrideGroupForTUI bulk-removes the provenance-tagged rules of a +// stamped group from the override that actually owns the selected row, which may +// be a matched group override, not the host's own host: rules. +func removeHostOverrideGroupForTUI(bundle policy.Bundle, source, group string) (policy.Bundle, error) { + if strings.TrimSpace(group) == "" { + return bundle, fmt.Errorf("policy group name is required") + } + override, ok := bundle.Policy.HostOverrides[source] + if !ok { + return bundle, fmt.Errorf("host override %q not found", source) + } + next := cloneBundleForTUI(bundle) + override = next.Policy.HostOverrides[source] + kept := make([]policy.Rule, 0, len(override.Rules)) + for _, rule := range override.Rules { + if rule.Group == group { + continue + } + kept = append(kept, rule) + } + override.Rules = kept + next.Policy.HostOverrides[source] = override + return next, nil +} + +func hostRowIndexForSource(rows []policy.EffectiveRule, source string, index int) int { + for i, row := range rows { + if row.Source == source && row.Index == index { + return i + } + } + return maxInt(len(rows)-1, 0) +} + +func firstHostGroupRow(rows []policy.EffectiveRule, group string) int { + for i, row := range rows { + if row.Scope == "host" && row.Rule.Group == group { + return i + } + } + return maxInt(len(rows)-1, 0) +} + +func targetIndex(targets []policyTarget, target policyTarget) int { + for i, candidate := range targets { + if candidate.kind == target.kind && candidate.group == target.group { + return i + } + } + return 0 +} + +func countRuleActions(rules []policy.Rule) (allow, deny int) { + for _, rule := range rules { + switch rule.Action { + case policy.ActionAllow: + allow++ + case policy.ActionDeny: + deny++ + } + } + return allow, deny +} + +func outputSummary(out policy.Output) string { + limit := "unlimited" + if out.MaxBytes > 0 { + limit = humanBytes(out.MaxBytes) + " cap" + } + switch n := len(out.Redact); n { + case 0: + return limit + case 1: + return limit + " · 1 redaction" + default: + return fmt.Sprintf("%s · %d redactions", limit, n) + } +} + +func humanBytes(n int) string { + switch { + case n >= 1<<20: + if n%(1<<20) == 0 { + return fmt.Sprintf("%d MiB", n/(1<<20)) + } + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + if n%(1<<10) == 0 { + return fmt.Sprintf("%d KiB", n/(1<<10)) + } + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} diff --git a/internal/tui/policy_test.go b/internal/tui/policy_test.go index 1082837..921ec3c 100644 --- a/internal/tui/policy_test.go +++ b/internal/tui/policy_test.go @@ -1,21 +1,73 @@ package tui import ( + "os" + "path/filepath" "strings" "testing" + + "github.com/Praeviso/AgentSSH/internal/policy" ) +// Removing a stamped group from the host pane must mutate the override that owns +// the selected row — a matched group override, not blindly the host's own rules. +// Otherwise R on an inherited row deletes the wrong rules. +func TestRemoveSelectedHostGroupRespectsSource(t *testing.T) { + inv := `version: 1 +hosts: + web-1: { addr: 10.0.0.11, user: deploy, tags: [prod] } +groups: + prod: { tags: [prod] } +` + pol := `version: 1 +host_overrides: + host:web-1: + rules: + - { match: { cmd_regex: '^a$' }, action: allow, group: shared } + prod: + rules: + - { match: { cmd_regex: '^b$' }, action: deny, group: shared } +` + m := sized(t, buildAppWith(t, inv, pol), 100, 24) + m.detailHost = "web-1" + m.screen = screenDetail + m.pane = panePolicy + m.policy = m.policy.withHost("web-1") + + // Select the row that belongs to the prod group override (not host:web-1). + rows := m.policy.hostPolicyRows() + idx := -1 + for i, r := range rows { + if r.Source == "prod" { + idx = i + } + } + if idx < 0 { + t.Fatalf("prod override row not found among %d rows", len(rows)) + } + m.policy.ruleCursor = idx + + next, _ := m.policy.removeSelectedHostGroup() + cfg := next.(policySection).config + if got := len(cfg.HostOverrides["prod"].Rules); got != 0 { + t.Fatalf("prod override rules = %d, want 0 (removed from the selected row's source)", got) + } + if got := len(cfg.HostOverrides[policy.HostRulesKey("web-1")].Rules); got != 1 { + t.Fatalf("host:web-1 rules = %d, want 1 (untouched)", got) + } +} + // The policy pane in a host's detail evaluates the command as that host, so the // host's group overrides apply without the operator typing a host: prefix. func TestPolicyPaneScopesEvaluationToHost(t *testing.T) { - // prod-web-01 is tagged web; the sample policy denies group web except ^ls. + // prod-web-01 is tagged web; the sample policy allows ^ls through that group. m := buildApp(t) ps := m.policy.withHost("prod-web-01") ps.input.SetValue("ls -la") ps.evaluate() if !strings.HasPrefix(ps.result, "allow") { - t.Fatalf("allowlisted cmd result = %q, want allow", ps.result) + t.Fatalf("allowed cmd result = %q, want allow", ps.result) } if !strings.Contains(ps.result, "host=prod-web-01") { t.Fatalf("result missing host scope: %q", ps.result) @@ -24,7 +76,7 @@ func TestPolicyPaneScopesEvaluationToHost(t *testing.T) { ps.input.SetValue("cat /etc/passwd") ps.evaluate() if !strings.HasPrefix(ps.result, "deny") { - t.Fatalf("non-allowlisted cmd on denied group result = %q, want deny", ps.result) + t.Fatalf("unmatched cmd result = %q, want deny", ps.result) } } @@ -57,3 +109,191 @@ func TestPolicyPaneAtRoot(t *testing.T) { t.Fatal("policy pane with focused input should not be at root") } } + +func TestPolicyPaneHostRuleSetCRUD(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") + + m = press(t, m, "a") + if !m.policy.capturing() { + t.Fatal("rule input should capture after a") + } + m.policy.input.SetValue("allow 5 ^uptime$") + m = press(t, m, "enter") + ruleSet, ok := policy.LookupHostRules(policy.Bundle{Policy: m.policy.config, Inventory: m.policy.inventory}, "bare") + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Match.CmdRegex != "^uptime$" || ruleSet.Override.Rules[0].Action != policy.ActionAllow || ruleSet.Override.Rules[0].Priority != 5 { + t.Fatalf("host rules after rule add = %#v ok=%v", ruleSet, ok) + } + if !strings.Contains(readFile(t, m.paths.PolicyFile), "host:bare") { + t.Fatalf("policy file missing host rules:\n%s", readFile(t, m.paths.PolicyFile)) + } + + m = press(t, m, "r") + ruleSet, ok = policy.LookupHostRules(policy.Bundle{Policy: m.policy.config, Inventory: m.policy.inventory}, "bare") + if !ok || len(ruleSet.Override.Rules) != 0 { + t.Fatalf("host rules after rule remove = %#v ok=%v", ruleSet, ok) + } + + m = press(t, m, "x") + if !m.policy.capturing() || !strings.Contains(m.policy.result, "delete host policy rules") { + t.Fatalf("delete confirm state mode=%v result=%q", m.policy.mode, m.policy.result) + } + m = press(t, m, "y") + if _, ok := policy.LookupHostRules(policy.Bundle{Policy: m.policy.config, Inventory: m.policy.inventory}, "bare"); ok { + t.Fatalf("host rules still present after delete: %#v", m.policy.config.HostOverrides) + } + if strings.Contains(readFile(t, m.paths.PolicyFile), "host:bare") { + t.Fatalf("policy file still has host rules:\n%s", readFile(t, m.paths.PolicyFile)) + } +} + +func TestTopLevelPolicyTabCardsAndGroupCRUD(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m = press(t, m, "2") + if m.entryTab != entryPolicy { + t.Fatalf("entryTab = %v, want policy", m.entryTab) + } + v := m.View() + for _, want := range []string{"Global", "readonly", "rules"} { + if !strings.Contains(v, want) { + t.Fatalf("policy tab missing %q:\n%s", want, v) + } + } + + m = press(t, m, "n") + if !m.policy.capturing() { + t.Fatal("new group input should capture") + } + m.policy.input.SetValue("ops") + m = press(t, m, "enter") + if _, ok := m.policy.config.RuleGroups["ops"]; !ok { + t.Fatalf("group not created: %#v", m.policy.config.RuleGroups) + } + + m.policy.cardCursor = targetIndex(m.policy.policyTargets(), policyTarget{kind: policyTargetGroup, group: "ops"}) + m = press(t, m, "enter") + if m.policy.target.kind != policyTargetGroup || m.policy.target.group != "ops" { + t.Fatalf("target after open = %#v", m.policy.target) + } + m = press(t, m, "a") + m.policy.input.SetValue("deny 7 ^reboot$") + m = press(t, m, "enter") + rules := m.policy.config.RuleGroups["ops"].Rules + if len(rules) != 1 || rules[0].Action != policy.ActionDeny || rules[0].Priority != 7 || rules[0].Match.CmdRegex != "^reboot$" { + t.Fatalf("group rule after add = %#v", rules) + } + m = press(t, m, "e") + m.policy.input.SetValue("allow 3 ^uptime$") + m = press(t, m, "enter") + rules = m.policy.config.RuleGroups["ops"].Rules + if len(rules) != 1 || rules[0].Action != policy.ActionAllow || rules[0].Priority != 3 || rules[0].Match.CmdRegex != "^uptime$" { + t.Fatalf("group rule after edit = %#v", rules) + } + m = press(t, m, "r") + if got := m.policy.config.RuleGroups["ops"].Rules; len(got) != 0 { + t.Fatalf("group rule after remove = %#v", got) + } + m = press(t, m, "esc") // back to cards + m = press(t, m, "d") + if !m.policy.capturing() || !strings.Contains(m.policy.result, "delete rule group ops") { + t.Fatalf("delete group confirm mode=%v result=%q", m.policy.mode, m.policy.result) + } + m = press(t, m, "y") + if _, ok := m.policy.config.RuleGroups["ops"]; ok { + t.Fatalf("group still present after delete: %#v", m.policy.config.RuleGroups) + } +} + +func TestTopLevelPolicyGlobalRuleCRUD(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m = press(t, m, "2") + m.policy.cardCursor = 0 // Global is always first. + m = press(t, m, "enter") + if m.policy.target.kind != policyTargetGlobal { + t.Fatalf("target = %#v, want global", m.policy.target) + } + + initial := len(m.policy.config.Rules) + m = press(t, m, "a") + m.policy.input.SetValue("allow 11 ^date$") + m = press(t, m, "enter") + if len(m.policy.config.Rules) != initial+1 || m.policy.config.Rules[len(m.policy.config.Rules)-1].Match.CmdRegex != "^date$" { + t.Fatalf("global rules after add = %#v", m.policy.config.Rules) + } + m.policy.ruleCursor = len(m.policy.config.Rules) - 1 + m = press(t, m, "e") + m.policy.input.SetValue("deny 12 ^date$") + m = press(t, m, "enter") + if got := m.policy.config.Rules[len(m.policy.config.Rules)-1]; got.Action != policy.ActionDeny || got.Priority != 12 { + t.Fatalf("global rule after edit = %#v", got) + } + m = press(t, m, "r") + if len(m.policy.config.Rules) != initial { + t.Fatalf("global rules after remove = %#v", m.policy.config.Rules) + } +} + +func TestHostPolicyUnifiedListAndGroupActions(t *testing.T) { + m := sized(t, buildApp(t), 110, 32) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") + + m = press(t, m, "p") + if m.policy.mode != policyModeGroupPicker { + t.Fatalf("group picker mode = %v", m.policy.mode) + } + m = press(t, m, "enter") + ruleSet, ok := m.policy.hostRuleSet() + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Group != "readonly" { + t.Fatalf("host rules after stamp = %#v ok=%v", ruleSet, ok) + } + v := m.policy.View() + for _, want := range []string{"SCOPE", "global", "host", "readonly", "^uptime$"} { + if !strings.Contains(v, want) { + t.Fatalf("host unified policy missing %q:\n%s", want, v) + } + } + hostIdx := strings.Index(v, "host") + globalIdx := strings.Index(v, "global") + if hostIdx < 0 || globalIdx < 0 || hostIdx > globalIdx { + t.Fatalf("host rows should render before global rows:\n%s", v) + } + + rows := m.policy.hostPolicyRows() + globalRow := -1 + for i, row := range rows { + if row.Scope == "global" { + globalRow = i + break + } + } + if globalRow < 0 { + t.Fatalf("no global row in %#v", rows) + } + m.policy.ruleCursor = globalRow + m = press(t, m, "r") + if !strings.Contains(m.policy.result, "global rows are read-only") { + t.Fatalf("global row remove result = %q", m.policy.result) + } + if b, _ := m.policy.hostRuleSet(); len(b.Override.Rules) != 1 { + t.Fatalf("global-row remove changed host rules: %#v", b.Override.Rules) + } + + m.policy.ruleCursor = firstHostGroupRow(m.policy.hostPolicyRows(), "readonly") + m = press(t, m, "R") + if b, _ := m.policy.hostRuleSet(); len(b.Override.Rules) != 0 { + t.Fatalf("host group remove left rules: %#v", b.Override.Rules) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(raw) +} diff --git a/internal/tui/ux_test.go b/internal/tui/ux_test.go new file mode 100644 index 0000000..590b86a --- /dev/null +++ b/internal/tui/ux_test.go @@ -0,0 +1,391 @@ +package tui + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/hostform" + "github.com/Praeviso/AgentSSH/internal/inventory" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// filterFor enters the host grid's / filter and types query through the real key +// path, asserting it actually opened the filter box. +func filterFor(t *testing.T, m appModel, query string) appModel { + t.Helper() + m = press(t, m, "/") + if !m.hosts.filtering { + t.Fatalf("/ did not enter filter mode") + } + return press(t, m, query) +} + +func TestHostFilterByName(t *testing.T) { + m := filterFor(t, sized(t, buildApp(t), 100, 30), "prod") + vis := m.hosts.visible() + if len(vis) != 1 || vis[0] != "prod-web-01" { + t.Fatalf("filter prod visible = %v, want [prod-web-01]", vis) + } + if got := m.hosts.selectedHost(); got != "prod-web-01" { + t.Fatalf("selectedHost = %q, want prod-web-01", got) + } + view := m.hosts.View() + if !strings.Contains(view, "prod-web-01") || strings.Contains(view, "db-replica") { + t.Fatalf("filtered grid view did not narrow:\n%s", view) + } +} + +// The filter matches name, tag, address, and user — not just the name. +func TestHostFilterMatchesEveryField(t *testing.T) { + cases := map[string]string{ + "backup": "db-replica", // tag + "10.0.0.40": "win-box", // addr + "postgres": "db-replica", // user + } + for q, want := range cases { + m := filterFor(t, sized(t, buildApp(t), 100, 30), q) + vis := m.hosts.visible() + if len(vis) != 1 || vis[0] != want { + t.Fatalf("filter %q visible = %v, want [%s]", q, vis, want) + } + } +} + +func TestHostFilterEmptyStateGuidance(t *testing.T) { + m := filterFor(t, sized(t, buildApp(t), 100, 30), "zzznomatch") + if len(m.hosts.visible()) != 0 { + t.Fatalf("expected no matches, got %v", m.hosts.visible()) + } + if got := m.hosts.selectedHost(); got != "" { + t.Fatalf("selectedHost on empty match = %q, want empty", got) + } + if !strings.Contains(m.hosts.View(), "No hosts match") { + t.Fatalf("empty-match view missing guidance:\n%s", m.hosts.View()) + } +} + +func TestHostFilterCursorClampsThenEscClears(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = 4 // win-box, the last card + m = filterFor(t, m, "prod") + if m.hosts.cursor != 0 { + t.Fatalf("cursor after narrowing to one match = %d, want 0", m.hosts.cursor) + } + m = press(t, m, "enter") // commit, back to nav + if m.hosts.filtering { + t.Fatal("enter should exit filter editing") + } + if !m.hosts.filterActive() { + t.Fatal("enter should keep the filter applied") + } + m = press(t, m, "esc") // esc in nav clears the committed filter + if m.hosts.filterActive() || len(m.hosts.visible()) != len(m.hosts.names) { + t.Fatalf("esc did not restore the full grid: active=%v shown=%d total=%d", + m.hosts.filterActive(), len(m.hosts.visible()), len(m.hosts.names)) + } +} + +func TestHostFilterEscWhileEditingCancels(t *testing.T) { + m := filterFor(t, sized(t, buildApp(t), 100, 30), "prod") + m = press(t, m, "esc") + if m.hosts.filtering || m.hosts.filterActive() { + t.Fatalf("esc while editing should cancel: filtering=%v active=%v", + m.hosts.filtering, m.hosts.filterActive()) + } +} + +func TestHostFilterShownCountInStatusBar(t *testing.T) { + m := filterFor(t, sized(t, buildApp(t), 100, 30), "prod") + m = press(t, m, "enter") + if bar := m.renderStatusBar(); !strings.Contains(bar, "shown") { + t.Fatalf("status bar missing filtered count:\n%s", bar) + } +} + +// Command letters (a/e/d/t/r) must type into the query while filtering, not fire +// their grid actions. +func TestHostFilterSwallowsCommandKeys(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m = press(t, m, "/") + m = press(t, m, "a") + if m.hosts.focus == hostFocusForm { + t.Fatal("'a' while filtering opened the add form") + } + if m.hosts.query != "a" { + t.Fatalf("query = %q, want \"a\"", m.hosts.query) + } +} + +// A failed probe must read as an error (red), not the green success style; an OK +// probe reads as success. This guards the status-line severity coloring. +func TestHostStatusSeverityFromProbe(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + + next, _ := m.Update(hostProbeMsg{name: "prod-web-01", hint: "connection refused"}) + m = next.(appModel) + if m.hosts.statusLevel != statusErr { + t.Fatalf("failed probe level = %v, want statusErr", m.hosts.statusLevel) + } + if !strings.HasPrefix(m.hosts.status, "FAILED") { + t.Fatalf("failed probe status = %q", m.hosts.status) + } + if m.hosts.statusStyle().Render("x") != m.hosts.styles.err.Render("x") { + t.Fatal("failed status is not rendered in the error style") + } + + next, _ = m.Update(hostProbeMsg{name: "prod-web-01", ok: true}) + m = next.(appModel) + if m.hosts.statusLevel != statusOK { + t.Fatalf("ok probe level = %v, want statusOK", m.hosts.statusLevel) + } + if m.hosts.statusStyle().Render("x") != m.hosts.styles.ok.Render("x") { + t.Fatal("ok status is not rendered in the success style") + } +} + +// The audit chain badge is English and carries a glyph so it survives NO_COLOR. +func TestChainBadgeIsEnglish(t *testing.T) { + r := lipgloss.NewRenderer(os.Stdout) + m := newModel(twoHostRecords(), map[string]HostMeta{}, newStyles(r), nil) + + m.applyVerify(verifyMsg{result: audit.VerifyResult{OK: false, BrokenSeq: 7}}) + tamper := m.chainBadge() + if !strings.Contains(tamper, "audit chain broken") || !strings.Contains(tamper, "seq=7") { + t.Fatalf("tamper badge = %q", tamper) + } + + m.applyVerify(verifyMsg{err: errors.New("boom")}) + verr := m.chainBadge() + if !strings.Contains(verr, "audit verify error") { + t.Fatalf("verify-error badge = %q", verr) + } + + for _, b := range []string{tamper, verr} { + for _, ru := range b { + if ru >= 0x4e00 && ru <= 0x9fff { + t.Fatalf("badge still contains CJK: %q", b) + } + } + } +} + +func TestSessionsHomeEndNavigation(t *testing.T) { + r := lipgloss.NewRenderer(os.Stdout) + m := newModel(twoHostRecords(), map[string]HostMeta{}, newStyles(r), nil) + if len(m.rows) < 2 { + t.Fatalf("precondition: rows = %d, want >= 2", len(m.rows)) + } + last := len(m.rows) - 1 + + end, _ := m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) + if got := end.(model).cursor; got != last { + t.Fatalf("G: cursor = %d, want %d", got, last) + } + home, _ := end.(model).updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) + if got := home.(model).cursor; got != 0 { + t.Fatalf("g: cursor = %d, want 0", got) + } +} + +// Regression: moveRuleCursor used to have a value receiver, so j/k never moved +// the policy host-rule selection. With two rules, j must advance and G jump. +func TestPolicyRuleCursorMoves(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") // policy pane + + for _, rule := range []string{"^foo", "^bar"} { + m = press(t, m, "a") + m.policy.input.SetValue("allow " + rule) + m = press(t, m, "enter") + } + if b, ok := m.policy.hostRuleSet(); !ok || len(b.Override.Rules) != 2 { + t.Fatalf("precondition: want 2 host rules, got ok=%v hostRules=%#v", ok, b) + } + + m.policy.ruleCursor = 0 + m = press(t, m, "j") + if m.policy.ruleCursor != 1 { + t.Fatalf("j: ruleCursor = %d, want 1 (value-receiver regression)", m.policy.ruleCursor) + } + m = press(t, m, "k") + if m.policy.ruleCursor != 0 { + t.Fatalf("k: ruleCursor = %d, want 0", m.policy.ruleCursor) + } + m = press(t, m, "G") + last := len(m.policy.hostPolicyRows()) - 1 + if m.policy.ruleCursor != last { + t.Fatalf("G: ruleCursor = %d, want %d", m.policy.ruleCursor, last) + } + m = press(t, m, "g") + if m.policy.ruleCursor != 0 { + t.Fatalf("g: ruleCursor = %d, want 0", m.policy.ruleCursor) + } +} + +// --- round 2: keybinding rework, form markers, info hint --- + +// d/x delete the selected host (with confirm); D opens discovery (was d). +func TestGridDeleteAndDiscoverKeys(t *testing.T) { + base := sized(t, buildApp(t), 100, 30) + + if md := press(t, base, "d"); md.hosts.focus != hostFocusConfirm { + t.Fatalf("'d' should open the delete confirm, focus = %v", md.hosts.focus) + } + if mx := press(t, base, "x"); mx.hosts.focus != hostFocusConfirm { + t.Fatalf("'x' should open the delete confirm, focus = %v", mx.hosts.focus) + } + if mD := press(t, base, "D"); mD.hosts.focus != hostFocusDiscover { + t.Fatalf("'D' should open discovery, focus = %v", mD.hosts.focus) + } +} + +// r reloads inventory.yaml from disk, picking up an external edit. +func TestGridReloadKey(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + inv, err := inventory.Load(m.hosts.paths.InventoryFile) + if err != nil { + t.Fatalf("load: %v", err) + } + next, err := inventory.AddHost(inv, "zeta", inventory.Host{Addr: "10.0.0.99"}) + if err != nil { + t.Fatalf("addhost: %v", err) + } + if err := inventory.Save(m.hosts.paths.InventoryFile, next); err != nil { + t.Fatalf("save: %v", err) + } + m = press(t, m, "r") + if !contains(m.hosts.names, "zeta") { + t.Fatalf("'r' did not reload the new host: %v", m.hosts.names) + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +// The sessions viewer's d=open alias is gone; d is now inert there. +func TestSessionsDKeyNoLongerOpens(t *testing.T) { + r := lipgloss.NewRenderer(os.Stdout) + m := newModel(twoHostRecords(), map[string]HostMeta{}, newStyles(r), nil) + next, _ := m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + if got := next.(model).focus; got != focusList { + t.Fatalf("'d' should no longer open detail; focus = %v", got) + } +} + +// The add form marks required fields and explains the * legend. +func TestFormMarksRequiredFields(t *testing.T) { + f := hostform.New(hostform.Options{}, lipgloss.NewRenderer(os.Stdout)).SetSize(80, 24) + v := f.View() + if !strings.Contains(v, "* required") { + t.Fatalf("form missing required legend:\n%s", v) + } + if strings.Count(v, "*") < 3 { // name marker + addr marker + legend + t.Fatalf("expected required markers on name and addr plus the legend:\n%s", v) + } +} + +// The host Info pane shows the CLI run template, bridging viewer to action. +func TestInfoPaneShowsRunHint(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + v := m.hosts.infoView("prod-web-01", 80, 20, true) + if !strings.Contains(v, "agentssh run prod-web-01") { + t.Fatalf("info pane missing run hint:\n%s", v) + } +} + +// The policy pane is one calm Info-style card with a unified, borderless rule +// list: host tier rows first, then global read-only context rows. +func TestPolicyPaneShowsPlainEffectiveOutcome(t *testing.T) { + m := sized(t, buildApp(t), 92, 34) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") // policy pane + + // No host rules: global context still renders with a scope column. + v := m.policy.View() + for _, want := range []string{"bare", "SCOPE", "PRIORITY", "ACTION", "COMMAND", "GROUP", "global", "output"} { + if !strings.Contains(v, want) { + t.Fatalf("policy pane missing %q:\n%s", want, v) + } + } + for _, gone := range []string{"status=", "allow=0", "Host overrides", "This host", "policy for this host", "host tier: no host-specific rules"} { + if strings.Contains(v, gone) { + t.Fatalf("policy pane still shows superseded text %q:\n%s", gone, v) + } + } + + // Adding a host rule lists it under the host tier ahead of global context. + m = press(t, m, "a") + m.policy.input.SetValue("allow 5 ^journalctl\\b") + m = press(t, m, "enter") + v = m.policy.View() + hostIdx := strings.Index(v, "host") + globalIdx := strings.Index(v, "global") + if hostIdx < 0 || globalIdx < 0 || hostIdx > globalIdx || !strings.Contains(v, "^journalctl") { + t.Fatalf("host rule not surfaced under host tier:\n%s", v) + } +} + +// The rule/test input and its feedback render INSIDE the card (before the bottom +// border), not as detached lines floating below the box. +// The host Policy pane is borderless (a flat list like Sessions). Opening the rule +// input attaches it below the pane's content rather than floating it detached. +func TestPolicyInputRendersAttached(t *testing.T) { + m := sized(t, buildApp(t), 92, 24) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") + m = press(t, m, "a") // open the rule input + + view := m.policy.View() + if strings.Contains(view, "╰") { + t.Fatalf("host policy pane should be borderless, found a card border:\n%s", view) + } + lines := strings.Split(view, "\n") + prompt, hint := -1, -1 + for i, ln := range lines { + if strings.Contains(ln, "rule>") { + prompt = i + } + if strings.Contains(ln, "host tier") { + hint = i + } + } + if prompt < 0 { + t.Fatalf("rule input not shown:\n%s", view) + } + if hint < 0 || prompt < hint { + t.Fatalf("rule input must render attached below the pane content:\n%s", view) + } +} + +// Adding a host rule is direct in the default-deny model: the action and priority +// are part of the rule rather than an implicit lockdown posture. +func TestPolicyAddRuleCreatesHostRuleSet(t *testing.T) { + m := sized(t, buildApp(t), 92, 24) + m.hosts.cursor = hostIndex(t, m.hosts.names, "bare") + m = press(t, m, "enter") + m = press(t, m, "3") + + m = press(t, m, "a") + if !m.policy.capturing() { + t.Fatal("'a' should open the rule input") + } + m.policy.input.SetValue("deny 50 ^rm") + m = press(t, m, "enter") + if b, ok := m.policy.hostRuleSet(); !ok || len(b.Override.Rules) != 1 { + t.Fatalf("'a' should create host rules: ok=%v hostRules=%#v", ok, b) + } +} diff --git a/scripts/e2e_tui_audit_run_summary.sh b/scripts/e2e_tui_audit_run_summary.sh index 83460ff..655d44d 100755 --- a/scripts/e2e_tui_audit_run_summary.sh +++ b/scripts/e2e_tui_audit_run_summary.sh @@ -68,12 +68,15 @@ YAML cat > "$home/policy.yaml" <<'YAML' version: 1 -defaults: - policy: allow rules: - name: catastrophic + priority: 100 match: { cmd_regex: 'rm\s+-rf' } action: deny + - name: demo-readonly + priority: 0 + match: { cmd_regex: '^(echo|systemctl status|uptime)\b' } + action: allow output: max_bytes: 24 redact: diff --git a/skills/agentssh-usage/SKILL.md b/skills/agentssh-usage/SKILL.md new file mode 100644 index 0000000..2eecdbd --- /dev/null +++ b/skills/agentssh-usage/SKILL.md @@ -0,0 +1,64 @@ +--- +name: agentssh-usage +description: How to operate managed servers through AgentSSH — the trust boundary, session discipline, policy, output, and audit. Load this before running any `agentssh` command against a host. +--- + +# Operating Servers with AgentSSH + +AgentSSH is the only way you touch managed hosts. You call `agentssh`; the CLI resolves the host, checks policy, filters output, and records every command to a tamper-evident audit log. This is the manual for using it well — it is **not** a runbook for any specific task. Apply these practices to whatever the operator asks. + +## Trust boundary + +- You only ever call `agentssh hosts` and `agentssh run`. You never see addresses, keys, or passwords — they stay in the operator's ssh-agent, `~/.ssh/`, and an encrypted local store. +- Do not try to SSH directly, read credential files, or reconstruct connection details. If `agentssh` can't reach a host, report that; don't route around it. + +## Core loop + +1. **Discover the target.** Never assume a hostname or group — confirm it: + + ```bash + agentssh hosts + ``` + +2. **Open one session for the task.** A `run` requires a declared session, and one task = one session keeps the audit trail reviewable as a unit. Mint one and reuse it for every command in this task: + + ```bash + export AGENTSSH_SESSION=$(agentssh session new) # e.g. s_1a2b3c4d + ``` + + Optionally label the task on its first run: `--session-label "fix 502 on web-1"`. + +3. **Diagnose read-only first.** Inspect before you change anything — status, logs, metrics. Keep output bounded (`-n`, `head`, `--no-pager`); large dumps get truncated and waste context. + +4. **Act only after summarizing.** Before a state-changing command (restart, reload, write, delete), state what you found, the risk, and the exact command. Rely on the harness/operator confirmation flow for the go-ahead. + +5. **Review via audit.** Hand back the session id and key request ids so the operator can replay the task: + + ```bash + agentssh audit ls --session "$AGENTSSH_SESSION" + agentssh audit show + agentssh tui # audit grouped by session + ``` + +## Practices that matter + +- **Policy is the safety boundary, not a suggestion.** A `deny` is final — do not retry the same command, reword it, or look for a syntax that slips past. An `allow` may still trigger an operator confirmation; wait for it. +- **One session per task.** Don't reuse a previous task's `AGENTSSH_SESSION`, and don't share one session across unrelated tasks — that merges them in the audit trail. Start a new task → mint a new id. +- **Bounded, relevant output.** Prefer targeted commands (`systemctl status`, `journalctl -n`, `ps --sort`) over broad recursive scans. Output filtering may redact secrets and truncate length before results reach you; treat `«REDACTED»` and truncation as expected. +- **No destructive exploration.** Never run recursive deletes, mass `kill`, or cleanup as part of diagnosis. If the task needs them, propose them explicitly and let policy + the operator gate it. +- **Read exit codes, don't fight them.** `0` ok · `1` remote command failed · `2` usage (e.g. no session declared, missing `--`) · `6` policy denied · `9` connection failed. A `9` means fix connectivity/inventory, not retry blindly. + +## Command reference + +```bash +agentssh hosts [--json] # list targets (names + tags only; no credentials) +agentssh session new # mint a fresh session id for a task +agentssh session ls # recent sessions (id / label / span / command count) +agentssh run [--session ] [--session-label ] [--json] -- +agentssh status [--json] # look up a past run's result (exit / denied) +agentssh audit ls [--session ] | show | verify # browse / inspect / verify the hash chain +agentssh policy test --host '' # check how a command would be judged, without running it +agentssh tui # interactive audit + policy viewer +``` + +The command after `--` is sent verbatim as one remote command. Bind every run in a task to the same session (via `$AGENTSSH_SESSION` or `--session`) so audit groups them by task. diff --git a/skills/investigate-cpu/SKILL.md b/skills/investigate-cpu/SKILL.md deleted file mode 100644 index 785cb67..0000000 --- a/skills/investigate-cpu/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: investigate-cpu -description: Investigate high CPU on an AgentSSH managed host using mostly read-only commands. ---- - -# Investigate High CPU - -Use this skill when the user reports high CPU, load average, slow responses, or a suspected runaway process on a managed host. - -## Principles - -- Prefer read-only commands. Do not kill or restart processes until the operator explicitly confirms the plan. -- Keep output bounded. Ask for narrower checks instead of dumping huge logs. -- Treat policy `deny` as final. Do not try alternate syntax to bypass a denied command. - -## Flow - -1. Confirm the host or group: - -```bash -agentssh hosts -``` - -2. Check load and CPU summary: - -```bash -agentssh run -- uptime -agentssh run -- top -b -n 1 -o %CPU | head -40 -``` - -3. Identify the hottest processes: - -```bash -agentssh run -- ps -eo pid,ppid,user,stat,pcpu,pmem,comm,args --sort=-pcpu | head -25 -``` - -4. If a service is implicated, inspect its status and recent logs: - -```bash -agentssh run -- systemctl status -agentssh run -- journalctl -u -n 80 --no-pager -``` - -5. If the cause is unclear, collect narrow system context: - -```bash -agentssh run -- vmstat 1 5 -agentssh run -- df -h -``` - -6. Before any state-changing action, summarize evidence, likely risk, and the exact command you intend to run. - -## Notes - -- Do not run broad recursive searches or destructive cleanup as part of initial CPU diagnosis. -- If output contains secrets, AgentSSH output filtering may redact it before it enters the model context. -- Final answers should include the suspected process/service, supporting command outputs, and any relevant request ids for audit review. diff --git a/skills/restart-service/SKILL.md b/skills/restart-service/SKILL.md deleted file mode 100644 index 971a88f..0000000 --- a/skills/restart-service/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: restart-service -description: Diagnose and safely restart a systemd service on an AgentSSH managed host. ---- - -# Restart A Systemd Service - -Use this skill when a service appears unhealthy and the operator wants a careful, auditable restart through `agentssh`. - -## Principles - -- Start with read-only diagnosis. Do not restart first. -- Use `agentssh hosts` to confirm the target host or group name before running commands. -- Treat policy `deny` as final. Do not retry the same denied command or try to bypass policy. -- Before a state-changing command such as `restart` or `reload`, summarize the diagnosis and rely on the agent harness/operator confirmation flow. - -## Flow - -1. Identify the host and service name from the user request. -2. Check service state: - -```bash -agentssh run -- systemctl status -``` - -3. Check recent logs, keeping output small: - -```bash -agentssh run -- journalctl -u -n 80 --no-pager -``` - -4. If the service is clearly failed or wedged, explain what you found and restart: - -```bash -agentssh run -- sudo systemctl restart -``` - -5. Verify recovery: - -```bash -agentssh run -- systemctl status -``` - -6. If restart fails, collect focused follow-up evidence: - -```bash -agentssh run -- journalctl -u -n 120 --no-pager -``` - -## Notes - -- Prefer `status`, `journalctl`, `reload`, and `restart` over broad shell exploration. -- Avoid destructive cleanup commands. AgentSSH policy may deny dangerous commands, and that denial is the safety boundary. -- Include the returned request ids in the final summary when helpful so the operator can inspect `agentssh audit show ` or `agentssh tui`. From b7de37ca6daa75f77c5ad8f6e9ad60fe1bd98854 Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Mon, 29 Jun 2026 11:22:14 +0800 Subject: [PATCH 2/2] Fix 15 code-review findings in policy/session/audit/TUI Addresses correctness bugs and cleanups found reviewing the policy/session/ TUI overhaul. All fixes carry regression tests; go build/test pass. Correctness: - audit: TruncateBroken now cuts by break index (new VerifyResult.BrokenIndex), not the stored seq value, so a tampered seq can no longer wipe valid records or report a false-success repair. - policy: reject removed v0.5.1 schema keys (defaults / policy / allow_rules) on load with a migration hint instead of silently dropping them and flipping allow-by-default to deny-all. - run: group/multi-target runs derive a unique session id per host, restoring one-session-per-host; stale comments corrected. - policy: empty action defaults to allow again (invalid actions still error), so one action-less rule no longer bricks all execution. - policy/store: RemoveHostRule/RemoveHostGroup work on orphaned host overrides whose host left inventory. - inventory rm: clear/validate/save policy before writing inventory removal, avoiding an orphaned override on policy failure. - policy: custom MarshalYAML preserves host_overrides file order, protecting equal-priority tie-breaks across a save round-trip. - tui: editing a named global rule preserves its Name so CLI lookups still work. - session/tui: deterministic secondary sort by id for same-second sessions and session groups. Cleanup: - idempotent group stamping; shared internal/fileutil.WriteFileAtomic for policy+inventory saves; saveValidatedPolicy helper; remove dead parseHostRuleInput; simplify withHost target reset. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/agentssh/main.go | 90 +++++++++----------------- cmd/agentssh/main_test.go | 75 ++++++++++++++++++++++ internal/audit/audit_test.go | 19 ++++++ internal/audit/record.go | 43 +++++++------ internal/fileutil/atomic.go | 63 ++++++++++++++++++ internal/inventory/store.go | 28 +------- internal/policy/policy.go | 100 ++++++++++++++++++++++++++++- internal/policy/policy_test.go | 57 +++++++++++++++- internal/policy/store.go | 65 +++++++++++-------- internal/policy/store_test.go | 107 +++++++++++++++++++++++++++++++ internal/session/session.go | 15 +++-- internal/session/session_test.go | 13 ++++ internal/tui/model.go | 5 +- internal/tui/model_test.go | 13 ++++ internal/tui/policy_section.go | 16 ++--- internal/tui/policy_test.go | 23 +++++++ 16 files changed, 582 insertions(+), 150 deletions(-) create mode 100644 internal/fileutil/atomic.go diff --git a/cmd/agentssh/main.go b/cmd/agentssh/main.go index fafae0e..fc6596d 100644 --- a/cmd/agentssh/main.go +++ b/cmd/agentssh/main.go @@ -1149,13 +1149,13 @@ func runInventoryRM(cmd *cobra.Command, name string) error { } return err } - if err := writeInventoryAtomic(paths.Home, paths.InventoryFile, next); err != nil { + if err := clearHostRulesForDeletedHost(paths, name); err != nil { if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { return auditErr } return err } - if err := clearHostRulesForDeletedHost(paths, name); err != nil { + if err := writeInventoryAtomic(paths.Home, paths.InventoryFile, next); err != nil { if auditErr := appendInventoryAudit(paths, audit.EventFailed, name, "inventory rm "+name, err.Error(), exitRemoteFailed); auditErr != nil { return auditErr } @@ -1180,10 +1180,7 @@ func clearHostRulesForDeletedHost(paths config.Paths, name string) error { if err != nil { return err } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - return policy.Save(paths.PolicyFile, next.Policy) + return saveValidatedPolicy(paths, next.Policy) } func writeInventoryAtomic(home string, path string, inv inventory.Inventory) error { @@ -1678,10 +1675,8 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if err != nil { return err } - // Bind the session to this target host. A group run therefore records one - // session per host, never a single session spanning several hosts. A session - // must be declared (--session / AGENTSSH_SESSION) — one session per task keeps - // the audit trail grouped by task rather than by an arbitrary time window. + // Resolve the declared session id, then derive a host-specific id for + // multi-target runs so a session never spans several hosts. sessionCtx, err := sessionResolver.Resolve(target.Name, flags.session, flags.sessionLabel) if err != nil { if errors.Is(err, session.ErrNoSession) { @@ -1691,6 +1686,7 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag } return fmt.Errorf("resolve session: %w", err) } + sessionCtx.ID = sessionIDForTarget(sessionCtx.ID, target.Name, len(resolved.Targets) > 1) decision, err := engine.Evaluate(target.Name, remoteCommand) if err != nil { return fmt.Errorf("evaluate policy for %s: %w", target.Name, err) @@ -1805,6 +1801,13 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag return nil } +func sessionIDForTarget(baseID string, host string, multiTarget bool) string { + if !multiTarget || strings.TrimSpace(host) == "" { + return baseID + } + return baseID + "@" + strings.TrimSpace(host) +} + func printRunHuman(cmd *cobra.Command, host string, result executor.Result, filtered output.FilterResult) { out := cmd.OutOrStdout() marker := "✓" @@ -2000,10 +2003,7 @@ func runPolicyRuleAdd(cmd *cobra.Command, opts policyRuleOptions) error { if err != nil { return err } - if err := validatePolicyConfig(next); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next); err != nil { + if err := saveValidatedPolicy(paths, next); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy rule %s\n", rule.Name) @@ -2049,10 +2049,7 @@ func runPolicyRuleUpdate(cmd *cobra.Command, opts policyRuleOptions) error { if err != nil { return policyRuleUsageError(opts.Name, err) } - if err := validatePolicyConfig(next); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next); err != nil { + if err := saveValidatedPolicy(paths, next); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "updated policy rule %s\n", nextRule.Name) @@ -2068,10 +2065,7 @@ func runPolicyRuleRM(cmd *cobra.Command, name string) error { if err != nil { return policyRuleUsageError(name, err) } - if err := validatePolicyConfig(next); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next); err != nil { + if err := saveValidatedPolicy(paths, next); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy rule %s\n", name) @@ -2109,10 +2103,7 @@ func runPolicyGroupAdd(cmd *cobra.Command, name string) error { if err != nil { return policyGroupUsageError(name, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "added policy group %s\n", strings.TrimSpace(name)) @@ -2128,10 +2119,7 @@ func runPolicyGroupRM(cmd *cobra.Command, name string) error { if err != nil { return policyGroupUsageError(name, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy group %s\n", strings.TrimSpace(name)) @@ -2151,10 +2139,7 @@ func runPolicyGroupRuleAdd(cmd *cobra.Command, opts policyGroupRuleOptions) erro if err != nil { return policyGroupUsageError(opts.Group, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } group := next.Policy.RuleGroups[strings.TrimSpace(opts.Group)] @@ -2191,10 +2176,7 @@ func runPolicyGroupRuleRM(cmd *cobra.Command, groupName string, index int) error if err != nil { return policyGroupUsageError(groupName, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy group rule %s[%d]\n", strings.TrimSpace(groupName), index) @@ -2235,10 +2217,7 @@ func runPolicyHostRuleAdd(cmd *cobra.Command, opts policyHostRuleOptions) error if err != nil { return policyGroupOrHostUsageError(opts.Host, opts.FromGroup, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } group := next.Policy.RuleGroups[strings.TrimSpace(opts.FromGroup)] @@ -2256,10 +2235,7 @@ func runPolicyHostRuleAdd(cmd *cobra.Command, opts policyHostRuleOptions) error if err != nil { return policyHostUsageError(opts.Host, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } ruleSet, _ := policy.LookupHostRules(next, opts.Host) @@ -2296,10 +2272,7 @@ func runPolicyHostRuleRM(cmd *cobra.Command, host string, index int) error { if err != nil { return policyHostUsageError(host, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host rule %s[%d]\n", strings.TrimSpace(host), index) @@ -2315,10 +2288,7 @@ func runPolicyHostGroupRM(cmd *cobra.Command, host string, groupName string) err if err != nil { return policyGroupOrHostUsageError(host, groupName, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host group %s[%s]\n", strings.TrimSpace(host), strings.TrimSpace(groupName)) @@ -2334,10 +2304,7 @@ func runPolicyHostRM(cmd *cobra.Command, host string) error { if err != nil { return policyHostUsageError(host, err) } - if err := validatePolicyConfig(next.Policy); err != nil { - return err - } - if err := policy.Save(paths.PolicyFile, next.Policy); err != nil { + if err := saveValidatedPolicy(paths, next.Policy); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "removed policy host %s\n", strings.TrimSpace(host)) @@ -2471,6 +2438,13 @@ func validatePolicyConfig(cfg policy.Config) error { return nil } +func saveValidatedPolicy(paths config.Paths, cfg policy.Config) error { + if err := validatePolicyConfig(cfg); err != nil { + return err + } + return policy.Save(paths.PolicyFile, cfg) +} + func policyRuleByName(cfg policy.Config, name string) (policy.Rule, error) { found := -1 for i, rule := range cfg.Rules { diff --git a/cmd/agentssh/main_test.go b/cmd/agentssh/main_test.go index a9370e0..c94543b 100644 --- a/cmd/agentssh/main_test.go +++ b/cmd/agentssh/main_test.go @@ -501,6 +501,44 @@ rule_groups: } } +func TestInventoryRemoveDoesNotWriteInventoryWhenPolicyClearFails(t *testing.T) { + home := t.TempDir() + writeInventory(t, home, ` +version: 1 +hosts: + web-1: + addr: 10.0.0.11 + db-1: + addr: 10.0.0.12 +`) + writePolicy(t, home, ` +version: 1 +host_overrides: + host:web-1: + rules: + - match: { cmd_regex: '^whoami$' } + action: allow +rules: + - name: broken + match: { cmd_regex: '[' } + action: deny +`) + t.Setenv("AGENTSSH_HOME", home) + + _, _, err := runCommandForTest(t, "inventory", "rm", "web-1") + if err == nil { + t.Fatal("inventory rm err = nil, want invalid policy error") + } + inv := readInventoryFile(t, home) + if _, ok := inv.Hosts["web-1"]; !ok { + t.Fatalf("web-1 was removed despite policy failure: %#v", inv.Hosts) + } + rawPolicy := readFileString(t, filepath.Join(home, "policy.yaml")) + if !strings.Contains(rawPolicy, "host:web-1") { + t.Fatalf("host policy was removed despite policy failure:\n%s", rawPolicy) + } +} + func TestInventoryRemoveMissingWritesFailedAudit(t *testing.T) { home := t.TempDir() writeInventory(t, home, ` @@ -1026,6 +1064,43 @@ func TestRunSessionFlagSatisfiesRequirement(t *testing.T) { } } +func TestRunGroupDerivesSessionPerHost(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writeTestPolicy(t, home) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "") + withFakeExecutor(t, fakeExecutor{}) + + stdout, stderr, err := runCommandForTest(t, "run", "web", "--session", "s_batch", "--json", "--", "echo", "hi") + if err != nil { + t.Fatalf("group run err = %v stdout=%s stderr=%s", err, stdout, stderr) + } + var responses []runResponse + if err := json.Unmarshal([]byte(stdout), &responses); err != nil { + t.Fatalf("decode group response: %v\n%s", err, stdout) + } + if len(responses) != 2 { + t.Fatalf("responses = %#v", responses) + } + want := map[string]string{"web-1": "s_batch@web-1", "web-2": "s_batch@web-2"} + for _, response := range responses { + if response.SessionID != want[response.Host] { + t.Fatalf("response session for %s = %q, want %q in %#v", response.Host, response.SessionID, want[response.Host], responses) + } + } + records := mustReadAudit(t, home) + seen := map[string]string{} + for _, record := range records { + seen[record.Host] = record.SessionID + } + for host, sessionID := range want { + if seen[host] != sessionID { + t.Fatalf("audit session for %s = %q, want %q; records=%#v", host, seen[host], sessionID, records) + } + } +} + func TestRunUpdatesInventoryOSAfterConnection(t *testing.T) { home := t.TempDir() writeInventory(t, home, ` diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 83ca252..f153fd3 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -120,6 +120,25 @@ func TestTruncateBrokenRemovesBrokenTailAndBacksUp(t *testing.T) { } } +func TestTruncateBrokenUsesBrokenIndexNotSeqValue(t *testing.T) { + store := testStoreWithRecords(t) + records := mustRead(t, store) + records[1].Seq = 0 + writeRecords(t, store.Path, records) + + result, err := store.TruncateBroken() + if err != nil { + t.Fatalf("TruncateBroken: %v", err) + } + if !result.Changed || result.Kept != 1 || result.Removed != 2 || result.BrokenSeq != 0 || result.BrokenIndex != 1 || result.Reason != "seq" { + t.Fatalf("repair result = %#v", result) + } + repaired := mustRead(t, store) + if len(repaired) != 1 || repaired[0].ReqID != "r1" { + t.Fatalf("repaired records = %#v", repaired) + } +} + func TestTruncateBrokenNoopsWhenChainOK(t *testing.T) { store := testStoreWithRecords(t) result, err := store.TruncateBroken() diff --git a/internal/audit/record.go b/internal/audit/record.go index 28e5a87..ccd026b 100644 --- a/internal/audit/record.go +++ b/internal/audit/record.go @@ -215,13 +215,13 @@ func (s Store) Verify() (VerifyResult, error) { prevHash := ZeroHash for i, record := range records { if record.Seq != uint64(i) { - return VerifyResult{OK: false, BrokenSeq: record.Seq, Reason: "seq"}, nil + return VerifyResult{OK: false, BrokenSeq: record.Seq, BrokenIndex: i, Reason: "seq"}, nil } if record.PrevHash != prevHash { - return VerifyResult{OK: false, BrokenSeq: record.Seq, Reason: "prev_hash"}, nil + return VerifyResult{OK: false, BrokenSeq: record.Seq, BrokenIndex: i, Reason: "prev_hash"}, nil } if ComputeHash(record) != record.Hash { - return VerifyResult{OK: false, BrokenSeq: record.Seq, Reason: "hash"}, nil + return VerifyResult{OK: false, BrokenSeq: record.Seq, BrokenIndex: i, Reason: "hash"}, nil } prevHash = record.Hash } @@ -230,20 +230,22 @@ func (s Store) Verify() (VerifyResult, error) { // VerifyResult describes audit chain verification. type VerifyResult struct { - OK bool - Count int - BrokenSeq uint64 - Reason string + OK bool + Count int + BrokenSeq uint64 + BrokenIndex int + Reason string } // RepairResult describes a destructive audit-log repair. type RepairResult struct { - Changed bool - Kept int - Removed int - BrokenSeq uint64 - Reason string - BackupPath string + Changed bool + Kept int + Removed int + BrokenSeq uint64 + BrokenIndex int + Reason string + BackupPath string } // TruncateBroken removes the first broken record and every later record. @@ -262,7 +264,7 @@ func (s Store) TruncateBroken() (RepairResult, error) { if result.OK { return RepairResult{Changed: false, Kept: len(records)}, nil } - cut := int(result.BrokenSeq) + cut := result.BrokenIndex if cut < 0 || cut > len(records) { cut = len(records) } @@ -274,12 +276,13 @@ func (s Store) TruncateBroken() (RepairResult, error) { return RepairResult{}, err } return RepairResult{ - Changed: true, - Kept: cut, - Removed: len(records) - cut, - BrokenSeq: result.BrokenSeq, - Reason: result.Reason, - BackupPath: backup, + Changed: true, + Kept: cut, + Removed: len(records) - cut, + BrokenSeq: result.BrokenSeq, + BrokenIndex: result.BrokenIndex, + Reason: result.Reason, + BackupPath: backup, }, nil } diff --git a/internal/fileutil/atomic.go b/internal/fileutil/atomic.go new file mode 100644 index 0000000..4874804 --- /dev/null +++ b/internal/fileutil/atomic.go @@ -0,0 +1,63 @@ +package fileutil + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteFileAtomic writes data through a temporary file in path's directory, then +// renames it over the destination. +func WriteFileAtomic(path string, data []byte, perm os.FileMode, tempPattern string) error { + dir := filepath.Dir(path) + file, err := os.CreateTemp(dir, tempPattern) + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + tempName := file.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if err := file.Chmod(perm); err != nil { + _ = file.Close() + return fmt.Errorf("chmod temporary file: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write temporary file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close temporary file: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + return fmt.Errorf("replace file: %w", err) + } + cleanup = false + return nil +} + +// LabelAtomicError rewrites WriteFileAtomic's generic operation names with the +// caller's file label, preserving package-specific error text. +func LabelAtomicError(err error, label string) error { + message := err.Error() + replacements := []struct { + from string + to string + }{ + {"create temporary file", "create temporary " + label + " file"}, + {"chmod temporary file", "chmod temporary " + label + " file"}, + {"write temporary file", "write temporary " + label + " file"}, + {"close temporary file", "close temporary " + label + " file"}, + {"replace file", "replace " + label + " file"}, + } + for _, replacement := range replacements { + if strings.HasPrefix(message, replacement.from) { + return fmt.Errorf("%s%s", replacement.to, strings.TrimPrefix(message, replacement.from)) + } + } + return err +} diff --git a/internal/inventory/store.go b/internal/inventory/store.go index 990ea97..58c165f 100644 --- a/internal/inventory/store.go +++ b/internal/inventory/store.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/Praeviso/AgentSSH/internal/fileutil" "gopkg.in/yaml.v3" ) @@ -45,32 +46,9 @@ func Save(path string, inv Inventory) error { if err != nil { return fmt.Errorf("marshal inventory: %w", err) } - file, err := os.CreateTemp(dir, "inventory-*.yaml") - if err != nil { - return fmt.Errorf("create temporary inventory file: %w", err) - } - tempName := file.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tempName) - } - }() - if err := file.Chmod(0o600); err != nil { - _ = file.Close() - return fmt.Errorf("chmod temporary inventory file: %w", err) - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return fmt.Errorf("write temporary inventory file: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("close temporary inventory file: %w", err) - } - if err := os.Rename(tempName, path); err != nil { - return fmt.Errorf("replace inventory file: %w", err) + if err := fileutil.WriteFileAtomic(path, data, 0o600, "inventory-*.yaml"); err != nil { + return fileutil.LabelAtomicError(err, "inventory") } - cleanup = false return nil } diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 62d9f7c..711b6fd 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -145,6 +145,9 @@ func (c Config) withMaps() Config { // host_overrides. That order is only a tie-breaker before priority sorting; rule // groups remain authoring-only and are not part of engine evaluation. func (c *Config) UnmarshalYAML(value *yaml.Node) error { + if err := rejectLegacySchemaKeys(value); err != nil { + return err + } type configYAML struct { Version int `yaml:"version"` Rules []Rule `yaml:"rules"` @@ -167,6 +170,101 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error { return nil } +func (c Config) MarshalYAML() (any, error) { + c = c.withMaps() + root := &yaml.Node{Kind: yaml.MappingNode} + appendNode(root, "version", scalarNode(c.Version)) + appendNode(root, "rules", mustYAMLNode(c.Rules)) + appendNode(root, "rule_groups", mustYAMLNode(c.RuleGroups)) + appendNode(root, "host_overrides", c.hostOverridesNode()) + appendNode(root, "output", mustYAMLNode(c.Output)) + return root, nil +} + +func appendNode(mapping *yaml.Node, key string, value *yaml.Node) { + mapping.Content = append(mapping.Content, scalarNode(key), value) +} + +func scalarNode(value any) *yaml.Node { + var node yaml.Node + if err := node.Encode(value); err != nil { + panic(err) + } + return &node +} + +func mustYAMLNode(value any) *yaml.Node { + var node yaml.Node + if err := node.Encode(value); err != nil { + panic(err) + } + return &node +} + +func (c Config) hostOverridesNode() *yaml.Node { + node := &yaml.Node{Kind: yaml.MappingNode} + seen := map[string]struct{}{} + for _, key := range c.hostOrder { + override, ok := c.HostOverrides[key] + if !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + appendNode(node, key, mustYAMLNode(override)) + seen[key] = struct{}{} + } + var missing []string + for key := range c.HostOverrides { + if _, ok := seen[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + for _, key := range missing { + appendNode(node, key, mustYAMLNode(c.HostOverrides[key])) + } + return node +} + +func rejectLegacySchemaKeys(value *yaml.Node) error { + if value == nil || value.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(value.Content); i += 2 { + key := value.Content[i].Value + if key == "defaults" || key == "policy" || key == "allow_rules" { + return legacySchemaError(key, key) + } + if key != "host_overrides" { + continue + } + overrides := value.Content[i+1] + if overrides == nil || overrides.Kind != yaml.MappingNode { + continue + } + for j := 0; j+1 < len(overrides.Content); j += 2 { + overrideName := overrides.Content[j].Value + override := overrides.Content[j+1] + if override == nil || override.Kind != yaml.MappingNode { + continue + } + for k := 0; k+1 < len(override.Content); k += 2 { + overrideKey := override.Content[k].Value + if overrideKey == "policy" || overrideKey == "allow_rules" { + return legacySchemaError(overrideKey, fmt.Sprintf("host_overrides.%s.%s", overrideName, overrideKey)) + } + } + } + } + return nil +} + +func legacySchemaError(key string, path string) error { + return fmt.Errorf("policy.yaml uses removed v0.5.1 key %q at %s; migrate to schema version 1 using top-level rules and host_overrides..rules with explicit action allow|deny", key, path) +} + func hostOverrideOrder(value *yaml.Node) []string { if value == nil || value.Kind != yaml.MappingNode { return nil @@ -333,7 +431,7 @@ func validateAction(action Action, source string) (Action, error) { case ActionAllow, ActionDeny: return action, nil case "": - return "", fmt.Errorf("policy %s is missing action", source) + return ActionAllow, nil default: return "", fmt.Errorf("policy %s has invalid action %q", source, action) } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 476ea80..52e80ad 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1,9 +1,11 @@ package policy import ( + "strings" "testing" "github.com/Praeviso/AgentSSH/internal/inventory" + "gopkg.in/yaml.v3" ) func TestEvaluateHostTierBeforeGlobalPriority(t *testing.T) { @@ -275,10 +277,61 @@ func TestInvalidPolicyRules(t *testing.T) { }, inventory.Inventory{}); err == nil { t.Fatal("NewEngine invalid regex error = nil") } - if _, err := NewEngine(Config{ + engine, err := NewEngine(Config{ Rules: []Rule{{Name: "missing-action", Match: Match{CmdRegex: "^ls"}}}, + }, inventory.Inventory{}) + if err != nil { + t.Fatalf("NewEngine missing action: %v", err) + } + decision, err := engine.Evaluate("", "ls") + if err != nil { + t.Fatalf("Evaluate missing action: %v", err) + } + if decision.Action != ActionAllow { + t.Fatalf("missing action decision = %#v, want allow", decision) + } + if _, err := NewEngine(Config{ + Rules: []Rule{{Name: "bad-action", Match: Match{CmdRegex: "^ls"}, Action: Action("prompt")}}, }, inventory.Inventory{}); err == nil { - t.Fatal("NewEngine missing action error = nil") + t.Fatal("NewEngine invalid action error = nil") + } +} + +func TestUnmarshalRejectsLegacySchemaKeys(t *testing.T) { + tests := []struct { + name string + yml string + want string + }{ + { + name: "defaults", + yml: "version: 1\ndefaults:\n policy: allow\n", + want: `removed v0.5.1 key "defaults" at defaults`, + }, + { + name: "top level allow_rules", + yml: "version: 1\nallow_rules:\n - '^ls$'\n", + want: `removed v0.5.1 key "allow_rules" at allow_rules`, + }, + { + name: "host override policy", + yml: "version: 1\nhost_overrides:\n web:\n policy: deny\n", + want: `removed v0.5.1 key "policy" at host_overrides.web.policy`, + }, + { + name: "host override allow_rules", + yml: "version: 1\nhost_overrides:\n web:\n allow_rules:\n - '^ls$'\n", + want: `removed v0.5.1 key "allow_rules" at host_overrides.web.allow_rules`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg Config + err := yaml.Unmarshal([]byte(tt.yml), &cfg) + if err == nil || !strings.Contains(err.Error(), tt.want) || !strings.Contains(err.Error(), "migrate to schema version 1") { + t.Fatalf("err = %v, want %q with migration hint", err, tt.want) + } + }) } } diff --git a/internal/policy/store.go b/internal/policy/store.go index 4d8d6c4..ea98f7d 100644 --- a/internal/policy/store.go +++ b/internal/policy/store.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/Praeviso/AgentSSH/internal/fileutil" "github.com/Praeviso/AgentSSH/internal/inventory" "gopkg.in/yaml.v3" ) @@ -73,32 +74,9 @@ func Save(path string, cfg Config) error { if err != nil { return fmt.Errorf("marshal policy: %w", err) } - file, err := os.CreateTemp(dir, "policy-*.yaml") - if err != nil { - return fmt.Errorf("create temporary policy file: %w", err) - } - tempName := file.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tempName) - } - }() - if err := file.Chmod(0o600); err != nil { - _ = file.Close() - return fmt.Errorf("chmod temporary policy file: %w", err) - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return fmt.Errorf("write temporary policy file: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("close temporary policy file: %w", err) - } - if err := os.Rename(tempName, path); err != nil { - return fmt.Errorf("replace policy file: %w", err) + if err := fileutil.WriteFileAtomic(path, data, 0o600, "policy-*.yaml"); err != nil { + return fileutil.LabelAtomicError(err, "policy") } - cleanup = false return nil } @@ -284,7 +262,7 @@ func RemoveHostRule(bundle Bundle, host string, index int) (Bundle, error) { override := ruleSet.Override override.Rules = append([]Rule(nil), override.Rules...) override.Rules = append(override.Rules[:index], override.Rules[index+1:]...) - return SetHostRules(bundle, host, override) + return setExistingHostRules(bundle, host, override) } // CreateGroup returns a copy of bundle with a new empty authoring rule group. @@ -394,6 +372,7 @@ func StampGroupOntoHost(bundle Bundle, host string, groupName string) (Bundle, e override = ruleSet.Override } override.Rules = copyRules(override.Rules) + override.Rules = removeRulesFromGroup(override.Rules, groupName) for _, rule := range group.Rules { copied := rule copied.Group = groupName @@ -422,7 +401,39 @@ func RemoveHostGroup(bundle Bundle, host string, groupName string) (Bundle, erro } override.Rules = append(override.Rules, rule) } - return SetHostRules(bundle, host, override) + return setExistingHostRules(bundle, host, override) +} + +func setExistingHostRules(bundle Bundle, host string, override HostOverride) (Bundle, error) { + host = strings.TrimSpace(host) + if host == "" { + return bundle, fmt.Errorf("policy host name is required") + } + key := HostRulesKey(host) + if _, ok := bundle.Policy.HostOverrides[key]; !ok { + return bundle, ErrNoHostRules + } + next := copyBundle(bundle) + if next.Policy.Version == 0 { + next.Policy.Version = 1 + } + if next.Policy.HostOverrides == nil { + next.Policy.HostOverrides = map[string]HostOverride{} + } + override.Rules = append([]Rule(nil), override.Rules...) + next.Policy.HostOverrides[key] = override + return next, nil +} + +func removeRulesFromGroup(rules []Rule, groupName string) []Rule { + out := make([]Rule, 0, len(rules)) + for _, rule := range rules { + if rule.Group == groupName { + continue + } + out = append(out, rule) + } + return out } func ruleIndex(rules []Rule, name string) (int, error) { diff --git a/internal/policy/store_test.go b/internal/policy/store_test.go index c818dde..1026b27 100644 --- a/internal/policy/store_test.go +++ b/internal/policy/store_test.go @@ -2,7 +2,9 @@ package policy import ( "errors" + "os" "path/filepath" + "strings" "testing" "github.com/Praeviso/AgentSSH/internal/inventory" @@ -93,6 +95,60 @@ func TestPolicySaveLoadRoundTrip(t *testing.T) { } } +func TestPolicySavePreservesHostOverrideOrder(t *testing.T) { + path := filepath.Join(t.TempDir(), "policy.yaml") + if err := os.WriteFile(path, []byte(` +version: 1 +host_overrides: + z: + rules: + - priority: 0 + match: { cmd_regex: '^uptime$' } + action: deny + a: + rules: + - priority: 0 + match: { cmd_regex: '^uptime$' } + action: allow +`), 0o600); err != nil { + t.Fatalf("write policy: %v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if err := Save(path, loaded); err != nil { + t.Fatalf("Save: %v", err) + } + reloaded, err := Load(path) + if err != nil { + t.Fatalf("Load after save: %v", err) + } + engine, err := NewEngine(reloaded, inventory.Inventory{ + Hosts: map[string]inventory.Host{"web-1": {Tags: []string{"both"}}}, + Groups: map[string]inventory.Group{"z": {Tags: []string{"both"}}, "a": {Tags: []string{"both"}}}, + }) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + decision, err := engine.Evaluate("web-1", "uptime") + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if decision.Action != ActionDeny || decision.Rule != "z/rules[0]" { + t.Fatalf("decision = %#v, want z deny to remain first after save/load", decision) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read policy: %v", err) + } + zIndex := strings.Index(string(raw), "z:") + aIndex := strings.Index(string(raw), "a:") + if zIndex < 0 || aIndex < 0 || zIndex > aIndex { + t.Fatalf("host override order not preserved:\n%s", raw) + } +} + func TestRuleAmbiguous(t *testing.T) { cfg := Config{Rules: []Rule{ {Name: "dup", Match: Match{CmdRegex: "a"}, Action: ActionDeny}, @@ -160,6 +216,34 @@ func TestHostRuleSetCRUD(t *testing.T) { } } +func TestRemoveHostRuleAndGroupWorkForOrphanedOverride(t *testing.T) { + bundle := Bundle{ + Policy: Config{HostOverrides: map[string]HostOverride{ + HostRulesKey("old-web"): {Rules: []Rule{ + {Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow}, + {Match: Match{CmdRegex: "^id$"}, Action: ActionDeny, Group: "readonly"}, + }}, + }}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{}}, + } + next, err := RemoveHostRule(bundle, "old-web", 0) + if err != nil { + t.Fatalf("RemoveHostRule: %v", err) + } + ruleSet, ok := LookupHostRules(next, "old-web") + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Match.CmdRegex != "^id$" { + t.Fatalf("after RemoveHostRule = %#v ok=%v", ruleSet, ok) + } + next, err = RemoveHostGroup(next, "old-web", "readonly") + if err != nil { + t.Fatalf("RemoveHostGroup: %v", err) + } + ruleSet, ok = LookupHostRules(next, "old-web") + if !ok || len(ruleSet.Override.Rules) != 0 { + t.Fatalf("after RemoveHostGroup = %#v ok=%v", ruleSet, ok) + } +} + func TestAddHostRuleCreatesHostRules(t *testing.T) { bundle := Bundle{ Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{ @@ -267,6 +351,29 @@ func TestStampGroupOntoHostCopiesWithProvenance(t *testing.T) { } } +func TestStampGroupOntoHostIsIdempotentForSameGroup(t *testing.T) { + bundle := Bundle{ + Policy: Config{ + RuleGroups: map[string]RuleGroup{ + "readonly": {Rules: []Rule{{Priority: 5, Match: Match{CmdRegex: "^uptime$"}, Action: ActionAllow}}}, + }, + }, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, + } + next, err := StampGroupOntoHost(bundle, "web-1", "readonly") + if err != nil { + t.Fatalf("first StampGroupOntoHost: %v", err) + } + next, err = StampGroupOntoHost(next, "web-1", "readonly") + if err != nil { + t.Fatalf("second StampGroupOntoHost: %v", err) + } + ruleSet, ok := LookupHostRules(next, "web-1") + if !ok || len(ruleSet.Override.Rules) != 1 || ruleSet.Override.Rules[0].Group != "readonly" { + t.Fatalf("host rules = %#v ok=%v", ruleSet, ok) + } +} + func TestRemoveHostGroupFiltersByProvenance(t *testing.T) { bundle := Bundle{ Policy: Config{HostOverrides: map[string]HostOverride{ diff --git a/internal/session/session.go b/internal/session/session.go index 3865cd8..77ffbc4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -24,9 +24,9 @@ var ErrNoSession = errors.New("no session declared (pass --session or set AGENTS // whatever the caller declares, nothing is persisted between runs. type Resolver struct{} -// Context is the resolved session information for a run. A session is bound to -// exactly one host: Host records which host this session id belongs to, so a -// session never spans more than one target. +// Context is the resolved session information for a run. Callers that fan out +// across multiple hosts must derive a host-specific ID before writing audit +// records, so one stored session id stays bound to one host. type Context struct { ID string Host string @@ -58,8 +58,8 @@ func NewID() (string, error) { return "s_" + hex.EncodeToString(bytes[:]), nil } -// Summary is one aggregated session row from audit.log. A session is bound to a -// single host (see Context.Host), so Host is scalar; legacy multi-host audit +// Summary is one aggregated session row from audit.log. Host is scalar because +// new run paths write one stored session id per host; legacy multi-host audit // data resolves to the first host seen for that session id. type Summary struct { ID string @@ -115,7 +115,10 @@ func Summaries(records []audit.Record) []Summary { result = append(result, *summary) } sort.Slice(result, func(i, j int) bool { - return result[i].End > result[j].End + if result[i].End != result[j].End { + return result[i].End > result[j].End + } + return result[i].ID < result[j].ID }) return result } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 0ce3fa5..9f61bcf 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -110,6 +110,19 @@ func TestSummaries(t *testing.T) { } } +func TestSummariesSortsSameEndBySessionID(t *testing.T) { + summaries := Summaries([]audit.Record{ + {ReqID: "r2", SessionID: "s_b", TS: "2026-06-20T10:00:00Z"}, + {ReqID: "r1", SessionID: "s_a", TS: "2026-06-20T10:00:00Z"}, + }) + if len(summaries) != 2 { + t.Fatalf("summaries = %#v", summaries) + } + if summaries[0].ID != "s_a" || summaries[1].ID != "s_b" { + t.Fatalf("sort = %#v, want same End sorted by id", summaries) + } +} + func TestMain(m *testing.M) { _ = os.Unsetenv(EnvSession) os.Exit(m.Run()) diff --git a/internal/tui/model.go b/internal/tui/model.go index 5e7155f..b1385ef 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -714,7 +714,10 @@ func buildGroups(records []audit.Record, query string, sessionFocus string) []se groups = append(groups, makeGroup(id, runs)) } sort.Slice(groups, func(i, j int) bool { - return groups[i].end > groups[j].end + if groups[i].end != groups[j].end { + return groups[i].end > groups[j].end + } + return groups[i].id < groups[j].id }) if noSession := grouped[""]; len(noSession) > 0 { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index efd90b8..371db33 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -46,6 +46,19 @@ func TestWithHostFilterScopesSessions(t *testing.T) { } } +func TestBuildGroupsSortsSameEndBySessionID(t *testing.T) { + groups := buildGroups([]audit.Record{ + {Seq: 1, ReqID: "r2", SessionID: "s_b", Host: "web-1", Event: audit.EventCompleted, TS: "2026-06-20T10:00:00Z"}, + {Seq: 2, ReqID: "r1", SessionID: "s_a", Host: "web-1", Event: audit.EventCompleted, TS: "2026-06-20T10:00:00Z"}, + }, "", "") + if len(groups) != 2 { + t.Fatalf("groups = %#v", groups) + } + if groups[0].id != "s_a" || groups[1].id != "s_b" { + t.Fatalf("sort = %#v, want same end sorted by id", groups) + } +} + func TestModelAtRoot(t *testing.T) { r := lipgloss.NewRenderer(os.Stdout) m := newModel(twoHostRecords(), map[string]HostMeta{}, newStyles(r), nil) diff --git a/internal/tui/policy_section.go b/internal/tui/policy_section.go index 69672e7..1d564a5 100644 --- a/internal/tui/policy_section.go +++ b/internal/tui/policy_section.go @@ -95,12 +95,7 @@ func (s policySection) withHost(host string) policySection { s.mode = policyModeBrowse s.ruleCursor = 0 s.groupCursor = 0 - if host != "" { - s.target = policyTarget{} - } - if host == "" { - s.target = policyTarget{} - } + s.target = policyTarget{} return s } @@ -593,8 +588,13 @@ func (s *policySection) editRuleFromInput() tea.Cmd { s.result = "" return nil } + rule.Name = next.Policy.Rules[s.ruleCursor].Name next.Policy.Rules[s.ruleCursor] = rule case policyTargetGroup: + group := bundle.Policy.RuleGroups[strings.TrimSpace(s.target.group)] + if s.ruleCursor >= 0 && s.ruleCursor < len(group.Rules) { + rule.Name = group.Rules[s.ruleCursor].Name + } next, err = policy.UpdateGroupRule(bundle, s.target.group, s.ruleCursor, rule) if err != nil { s.err = err @@ -930,10 +930,6 @@ func parseRuleInput(value string) (policy.Rule, error) { }, nil } -func parseHostRuleInput(value string) (policy.Rule, error) { - return parseRuleInput(value) -} - func formatRuleInput(rule policy.Rule) string { return strings.TrimSpace(fmt.Sprintf("%s %d %s", rule.Action, rule.Priority, rule.Match.CmdRegex)) } diff --git a/internal/tui/policy_test.go b/internal/tui/policy_test.go index 921ec3c..ffbf668 100644 --- a/internal/tui/policy_test.go +++ b/internal/tui/policy_test.go @@ -229,12 +229,35 @@ func TestTopLevelPolicyGlobalRuleCRUD(t *testing.T) { if got := m.policy.config.Rules[len(m.policy.config.Rules)-1]; got.Action != policy.ActionDeny || got.Priority != 12 { t.Fatalf("global rule after edit = %#v", got) } + if got := m.policy.config.Rules[len(m.policy.config.Rules)-1]; got.Name != "" { + t.Fatalf("new unnamed global rule gained name: %#v", got) + } m = press(t, m, "r") if len(m.policy.config.Rules) != initial { t.Fatalf("global rules after remove = %#v", m.policy.config.Rules) } } +func TestTopLevelPolicyGlobalRuleEditPreservesName(t *testing.T) { + m := sized(t, buildApp(t), 100, 30) + m = press(t, m, "2") + m.policy.cardCursor = 0 + m = press(t, m, "enter") + m.policy.ruleCursor = 1 // sample policy's named allow-readonly rule. + + m = press(t, m, "e") + m.policy.input.SetValue("deny 3 ^whoami$") + m = press(t, m, "enter") + + got := m.policy.config.Rules[1] + if got.Name != "allow-readonly" || got.Action != policy.ActionDeny || got.Priority != 3 || got.Match.CmdRegex != "^whoami$" { + t.Fatalf("edited named global rule = %#v", got) + } + if _, err := policy.RemoveRule(m.policy.config, "allow-readonly"); err != nil { + t.Fatalf("edited global rule should remain removable by name: %v", err) + } +} + func TestHostPolicyUnifiedListAndGroupActions(t *testing.T) { m := sized(t, buildApp(t), 110, 32) m.hosts.cursor = hostIndex(t, m.hosts.names, "bare")