From 2c14d313c7139c176756683c3f8c45ba1f3b6129 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 15:56:33 +0000 Subject: [PATCH 01/46] docs(openspec): propose approval-policy-v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking redesign of the persistent approval store and prompt UX: - typed (verb, directory) ApprovalEntry replaces the v1 flat string list; v1 file quarantines to .v1.bak on first read (no migration) - safe-verb ∩ safe-space short-circuit (per-OS verb list, audience-aware roots from ToolAudienceProfileResolver) auto-runs read-only inspection inside session_dir / project_dir - ShellTool cwd defaults to project_dir → session_dir (today inherits daemon-process cwd) - ShellTokenizer refuses pattern extraction on bash control-flow / unbalanced input so junk fragments never persist - 5-button prompt (Once / This chat / Always here / Always anywhere / Deny) with danger styling on the destructive options; one-line resolution message - netclaw approvals trust-verb CLI for unattended/scheduled grants - AGENTS.md + tool description + failure-path hint coordinate to push the agent toward set_working_directory; eval cases (positive/negative/recovery/ schedule pre-approval) lock the behavior in --- .../changes/approval-policy-v2/.openspec.yaml | 2 + openspec/changes/approval-policy-v2/design.md | 198 +++++++ .../changes/approval-policy-v2/proposal.md | 40 ++ .../specs/netclaw-cli/spec.md | 166 ++++++ .../specs/session-cwd/spec.md | 186 +++++++ .../specs/tool-approval-gates/spec.md | 504 ++++++++++++++++++ openspec/changes/approval-policy-v2/tasks.md | 112 ++++ 7 files changed, 1208 insertions(+) create mode 100644 openspec/changes/approval-policy-v2/.openspec.yaml create mode 100644 openspec/changes/approval-policy-v2/design.md create mode 100644 openspec/changes/approval-policy-v2/proposal.md create mode 100644 openspec/changes/approval-policy-v2/specs/netclaw-cli/spec.md create mode 100644 openspec/changes/approval-policy-v2/specs/session-cwd/spec.md create mode 100644 openspec/changes/approval-policy-v2/specs/tool-approval-gates/spec.md create mode 100644 openspec/changes/approval-policy-v2/tasks.md diff --git a/openspec/changes/approval-policy-v2/.openspec.yaml b/openspec/changes/approval-policy-v2/.openspec.yaml new file mode 100644 index 00000000..054b8c01 --- /dev/null +++ b/openspec/changes/approval-policy-v2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/approval-policy-v2/design.md b/openspec/changes/approval-policy-v2/design.md new file mode 100644 index 00000000..1b0f328c --- /dev/null +++ b/openspec/changes/approval-policy-v2/design.md @@ -0,0 +1,198 @@ +## Context + +`tool-approval-gates` (originally specified in `2026-04-29-tool-approval-gates`, extended in `2026-05-07-directory-scoped-approval-patterns`) shipped a flat-string approval store keyed by audience and tool name. Each pattern is a string that the matcher inspects at evaluation time to decide whether it represents a verb chain, a normalized full command, a directory root, or a bash fragment. The shape works in trivial cases and fails in non-trivial ones: complex bash blocks shred into junk fragments that get persisted as approvals; the matcher's "is this a directory? a verb?" heuristic depends on trailing slashes; the channel adapters render two parallel sections (`Patterns` and `Directory Roots`) because the data model can't distinguish them. + +Real use surfaced two outcomes neither the spec nor the original PRs anticipated: + +1. **Approval volume is too high.** The agent's only declared safe space is `~/.netclaw/sessions//` (the per-session scratch dir established by `SessionMessageAssembler`). Any shell call against the user's actual project — typically read-only `grep`/`ls`/`cat`/`git status` — triggers the approval gate. Users running `netclaw` against a repository they own end up clicking through dozens of prompts that have no security value. +2. **Approval clarity is too low.** Aaron's persisted store accumulated 50 entries including `done`, `for pid`, `awk {print $2})`, `do threads=$(grep`, `fds=$(ls`. None of those will ever match a sensible future invocation. The user sees them in `netclaw approvals list` and cannot reason about what they're authorizing or revoking. + +We have no users yet, so we can do a clean redesign. This change replaces the flat-string store with a typed `(verb, directory)` model, layers a safe-verbs ∩ safe-space short-circuit on top, and rebuilds the prompt UX so a single click maps to a single decision. + +The session already has the right primitives. `WorkingContext` (`src/Netclaw.Actors/Sessions/WorkingContext.cs`) persists a `ProjectDirectory` set via the `set_working_directory` tool, surviving compaction and daemon restart. `ScopedFileAccessPolicy` (`src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs`) and `ToolAudienceProfileResolver` already implement audience-aware root resolution with symlink-segment protection for file_read. We're adopting that pattern for shell. + +## Goals / Non-Goals + +**Goals:** + +- Reduce approval prompt volume to commands that genuinely warrant interrupting the user (mutation, or anything outside declared safe spaces). +- Make every prompt the user sees answer one obvious question: *"approve `` in ``?"*. +- Type the approval store so each entry self-describes its scope — verb plus directory, with an explicit `null` for the global wildcard. +- Stop persisting bash fragments and over-precise normalized commands. Approvals should be coarse enough to reuse and specific enough to reason about. +- Give scheduled/unattended tasks a clean path to pre-approval that doesn't require hand-editing JSON. +- Push the agent toward calling `set_working_directory` early when working on a project, so the trust boundary is correctly declared. + +**Non-Goals:** + +- Migration of the existing v1 store. Quarantine to `.v1.bak` and start fresh; users have no production approvals worth preserving. +- Glob or regex pattern matching. Verb chains and absolute directory paths only. +- Stale-entry pruning of the v2 store. Track separately if it becomes a real problem. +- Persistent "trust this folder forever" grants beyond what `(verb, directory)` already expresses. +- Rewriting `ShellTokenizer` to be a real bash parser. We add cheap structural detection that refuses pattern extraction when the input is messy; we do not attempt to understand for-loops, subshells, or heredocs semantically. +- Modal-driven scope picker. Considered and rejected — see Decision 6. + +## Decisions + +### 1. Trust boundary is `safe verb ∩ safe space`, not just one or the other + +**What:** Three-position policy. Auto-run with no prompt only when the verb is on a curated per-OS safe-verbs list AND the cwd resolves under one of the audience-aware safe-space roots (`session_dir`, `project_dir` for Personal/Team, `session_dir` only for Public). Anything else prompts. Hard-deny list (layer 1) is unchanged. + +**Why:** A pure "in safe space → run anything" model auto-allows mutation (`git push` in your repo still pushes to the world). A pure "verb is read-only → run anywhere" model is the curated-allowlist pattern we explicitly rejected when we built this in the first place. The intersection captures the right thing: read-only inspection of declared work surfaces is implicit; everything else is explicit. + +**Alternatives considered:** + +- *In safe space → run anything (subject to hard-deny):* Too loose. `git push` in a project dir bypassing approval is an obvious foot-gun. +- *Verb on safe list anywhere → run:* Re-introduces the global verb allowlist. The `freshdesk` case (a user-installed CLI we know nothing about) shows this is actually attractive for some commands but should be opt-in via `trust-verb`, not a default. +- *Per-session "first prompt then cache":* Burns the user's attention in the first 5 minutes of every session. Doesn't survive `set_working_directory` being a thing. + +### 2. Safe-verbs list is per-OS, file-driven + +**What:** `safe-verbs.linux.json` and `safe-verbs.windows.json` shipped with the daemon. Each is a flat list of verb chains. Users can override at `~/.netclaw/config/safe-verbs..json`. + +**Why:** The list of read-only-by-nature verbs differs sharply between OSes — `dir`/`type`/`Get-Content` on Windows vs. `ls`/`cat`/`find` on POSIX. A single combined list either bloats unnecessarily or omits things users will hit. Per-OS keeps the defaults focused. + +**Alternatives considered:** + +- *Single combined list:* Bloats; verbs that don't exist on the target OS are dead weight in the matcher. +- *Hardcoded constants:* No user override path. Users can't add their own internal CLIs without a code change. +- *Config in `netclaw.json`:* Mixes operational config (hot-reloaded) with security policy (should not be silently swappable). + +Default Linux/macOS list: `ls`, `find`, `grep`, `egrep`, `fgrep`, `rg`, `cat`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`, `tr`, `awk`, `sed -n`, `file`, `pwd`, `which`, `stat`, `tree`, `du`, `df`, `git status`, `git log`, `git diff`, `git show`, `git branch`, `git remote`, `git rev-parse`, `git ls-files`, `git blame`. + +Default Windows list: `dir`, `type`, `more`, `where`, `findstr`, `Get-ChildItem`, `Get-Content`, `Select-String`, `Get-Item`, `Test-Path`, `Get-Location`, `Resolve-Path`, plus the same git read subcommands. + +`sed -n` is intentional — `sed -i` mutates files. `awk` is on the list because no flags currently mutate; if that ever changes the gate is structural (verb-chain match, not `awk -i`). + +### 3. Approval atom is `(verb, directory)`, both required, directory may be null + +**What:** + +```csharp +public sealed record ApprovalEntry +{ + public required string Verb { get; init; } // "git remote", "freshdesk" + public string? Directory { get; init; } // absolute path, or null = anywhere +} +``` + +The on-disk schema bumps to `version: 2`. v1 files are quarantined to `.v1.bak` on first read; the daemon writes a fresh empty v2 file. + +**Why:** Every persistent approval needs to answer two questions: *what verb* and *where*. Today the store collapses both into a single string and tries to recover them at evaluation time. The recovery is fragile (trailing-slash check tells the matcher "this is a directory") and the result reads as line noise to humans. Typing the entry kills both problems. + +`directory: null` is the explicit global wildcard. It exists for cases like the `freshdesk` CLI where the user genuinely wants the verb to run anywhere — typically scheduled or unattended invocations where the cwd will vary across firings. + +**Alternatives considered:** + +- *Separate buckets per shape:* `verb_in_directory: [...]` and `verb_anywhere: [...]`. Verbose; same information, more ceremony. +- *String convention with separator:* `"git remote@/abs/path/"`. Stringly-typed; will eventually grow ambiguity. +- *Auto-translate v1 → v2:* Too many shapes are unrecoverable (bash fragments, normalized commands with embedded args, bare directory roots without verbs). Honest quarantine + clean slate is safer. + +### 4. `ShellTool` cwd defaults to `project_dir` then `session_dir`, never daemon cwd + +**What:** When the model omits `WorkingDirectory`, `ShellTool` resolves cwd in priority order: `project_dir` (from `WorkingContext`) if set, else `session_dir`. Today the code at `src/Netclaw.Actors/Tools/ShellTool.cs:81-82` falls through to `ProcessStartInfo`'s default — the daemon process's cwd, which is wherever the daemon happened to be launched. + +**Why:** The daemon-cwd default is a footgun. It can be `/`, `~/.netclaw`, anywhere — completely unrelated to what the agent is "working on." Approval policy can't reason about it; the user can't predict it. Forcing the cwd into a declared safe space makes the trust boundary structural: every shell call has a known parent that's either inside a safe space or explicitly elsewhere. + +**Alternatives considered:** + +- *Require the model to always pass `WorkingDirectory`:* Brittle; the model frequently omits it for short commands. +- *Default to `session_dir` only:* Loses the work the user did when they (or the agent) called `set_working_directory`. + +### 5. `ShellTokenizer` refuses to extract patterns from messy input + +**What:** When `SplitCompoundCommand` encounters bash control-flow tokens (`for`, `while`, `do`, `done`, `then`, `fi`, `case`, `esac`) or unbalanced quotes/brackets, it returns an empty verb-chain list. The approval gate offers only `Once` and `Deny` — no persistent grant — and the prompt body shows "complex command — only one-shot approval available." + +**Why:** Today's splitter only knows `&&`/`||`/`;`. Anything else gets treated as a single segment, normalized, and shoved into the patterns list. That's how `done`, `for pid`, and `awk {print $2})` end up in Aaron's store. Refusing to extract on detection is the cheap, safe answer — we don't pretend to understand the command, we just refuse to remember it. + +**Alternatives considered:** + +- *Best-effort extraction with junk filtering:* Risks drift. The list of "things that look like junk" is open-ended. +- *Real bash parser:* Out of scope. Our needs are bounded by "is this clean enough to remember"; we don't need to interpret. + +### 6. Five-button prompt, no modal + +**What:** Approval prompt presents `Once`, `This chat`, `Always here`, `Always anywhere`, `Deny` as five buttons in one row. `Always anywhere` and `Deny` use the platform's danger styling (`style: "danger"` on Slack, `ButtonStyle.Danger` on Discord). + +**Why:** A four-button prompt with a modal on `Approve always` was considered for elegance — the elevated decision (in this folder vs anywhere) gets a deliberate confirmation step. But the state-management cost is real: ~200–300 lines per channel adapter for the round-trip handler, a new "scope chosen" follow-up message in the protocol, and additional failure modes (user dismisses modal without submitting, daemon restart between original click and modal submit). Five buttons collapse all of that to one click and one persist decision per button. + +The danger-styled `Always anywhere` button is the mitigation for the "fat-finger" risk: it reads visually distinct, matching `Deny`. Users who want to elevate a grant to global can do so with one deliberate click; the rare nature of the case is reflected in the styling, not the click count. + +Slack and Discord both cap at 5 buttons per row, so we are at the ceiling. A sixth button would need either a row split (changes the visual hierarchy) or an overflow menu (less obvious). We don't expect to need a sixth. + +**Alternatives considered:** + +- *4 buttons + modal on Approve always:* Elegant, expensive. See above. +- *4 buttons, drop "This chat":* Cleaner row but loses a useful intermediate scope. Some users debug iteratively across many similar commands and don't want to commit to "always." +- *Single approve button + scope dropdown:* Slack supports it but the UX feels indirect ("pick from this menu, then click the approve button you already clicked"). + +### 7. Compound commands group by cwd, persist as a batch + +**What:** When the model issues `cmd1 && cmd2 && cmd3`, the matcher extracts every verb chain and presents them as bullets in a single prompt. One click on `Always here` persists `(verb, cwd)` for each verb in one shot. Cross-directory compounds (rare) get treated as one prompt scoped to the cwd; if the user wants finer control, they Deny and let the agent split. + +**Why:** Forcing the agent to run one verb per call means N prompts for one logical operation — annoying. Splitting at our layer would need cross-call state to reconstruct the user's intent, which we don't have. Letting the user approve once for the whole compound matches how they actually think about the operation ("yes, do all three of those things"). + +**Alternatives considered:** + +- *One prompt per verb:* User-hostile. Three prompts for `git fetch && git rebase && git status`. +- *Refuse compound outside safe space:* Forces the agent to issue one at a time. Cleaner per-prompt, but multiplies prompts when the user is actively working. + +### 8. `netclaw approvals trust-verb ` is the only path to global grants + +**What:** `Always anywhere` in the prompt and `netclaw approvals trust-verb ` in the CLI both write `(verb, null)` to the store. The CLI is the deliberate, scriptable path; the prompt is the in-the-moment path. Both flow through `ToolApprovalStore.AddApproval` with the same comparer. + +**Why:** Scheduled tasks need pre-approval (the schedule fires unattended; nobody can click). Hand-editing JSON is the current state and it's the source of `done`/`for pid` style entries. A typed CLI command makes the intent explicit and the audit trail visible. + +The agent uses this from inside a session as well: at schedule-creation time, when it identifies that an unattended task will need a verb to be globally approved, it asks the user and (on confirmation) calls the equivalent action. + +**Alternatives considered:** + +- *Daemon RPC instead of CLI shelling:* Cleaner, but requires a new RPC surface. Defer until we have other RPC needs. +- *Implicit auto-trust for verbs the agent calls during schedule setup:* Way too magic. Users should know what's being globally trusted. + +### 9. Reuse `ScopedFileAccessPolicy` infrastructure + +**What:** A new `ScopedShellSafeVerbPolicy` mirrors the `ScopedFileAccessPolicy` shape. Both use `ToolAudienceProfileResolver` for root resolution and `ContainsSymlinkSegment` for symlink-segment defense. + +**Why:** The audience model (Personal/Team/Public) and the symlink-segment guard are well-tested and battle-hardened. Re-implementing them for shell would be duplicate code that drifts. Public audience inherits the same `session_dir`-only restriction file_read enforces — Public sessions can never auto-allow shell against `project_dir` even when set. + +### 10. Resolution message replaces dual sections with one line + +**What:** Today's resolution message has separate `Patterns` and `Directory Roots` sections. New format is one line: + +- `Saved: jsonlint, git pull, git rev-parse in ~/repos/foo/` +- `Saved: freshdesk anywhere` +- `Saved for this chat: jsonlint in ~/repos/foo/` +- `Approved (no save)` — for Once +- `Denied` + +**Why:** The two-section format is the on-screen artifact of the data-model conflation in v1. Once the entries are typed, the rendering simplifies. One line is enough; the verbs and the scope are both present and unambiguous. + +## Risks / Trade-offs + +- **Risk: safe-verbs list drifts from reality.** A new tool (`rg`, `delta`, `eza`) ships and isn't on the list, so users go through approval friction we didn't intend. → Mitigation: user-overridable file at `~/.netclaw/config/safe-verbs..json`. Update default lists at release boundaries based on observed friction. +- **Risk: a verb on the safe list turns out to have a mutating mode.** `awk -i inplace` mutates; if `awk` is on the list and we match by verb chain, we'd auto-allow it. → Mitigation: verb-chain matcher pins to `awk` (no flags); safe-list entries that need flag pinning use the verb+subcommand form (`sed -n`, not `sed`). Audit the list at definition time, document the rationale next to each entry. +- **Risk: `project_dir` set incorrectly auto-allows too much.** User opens a session, agent guesses wrong, calls `set_working_directory ~/`. Now the entire home dir is "safe." → Mitigation: the safe-verbs list is the second axis — even with `~/` as project_dir, only read-only verbs auto-run. Mutation still prompts. The eval cases (positive + negative) explicitly cover this. AGENTS.md guidance anchors on intent ("you're working on a specific codebase"), not on dodging approvals. +- **Risk: bash-fragment refusal annoys users with legitimate complex commands.** Someone writes `for f in *.log; do grep ERROR "$f"; done` and gets only `Once`/`Deny`. → Mitigation: this is the right answer. We can't reason about persistent grants for control-flow we don't parse. The user can split the command into one-shot pieces or, for repeated needs, register the inner verb (`grep`) globally via `trust-verb`. +- **Risk: 5 buttons feel cluttered on narrow Slack channels.** Mobile especially. → Mitigation: revisit if observed. The terse labels (`Once`, `This chat`, etc.) keep the row width down; danger styling visually breaks the row into "safe" and "powerful" halves. +- **Risk: agent regresses on `set_working_directory` adoption after AGENTS.md change.** Eval suite catches this on every PR. Positive case asserts the call happens early; negative case asserts no preemptive call when there's no project signal; recovery case asserts the failure-path hint is read and acted on. +- **Risk: daemon restart kills pending approval prompts (existing bug, not introduced here).** Aaron flagged this independently — clicking an approval button after a restart hits a dead actor. Out of scope for this change but compounds with the new prompt UX. Track separately. +- **Trade-off: breaking change wipes the v1 store.** No users in production yet, so the cost is bounded. We document the quarantine clearly so users who manually curated their v1 store can mine it for ideas. +- **Trade-off: `Always anywhere` is one click away.** Mitigated by danger styling, but a determined misclick is still possible. CLI-only would be safer; we chose the in-prompt path because the friction of "pop out to a terminal" defeats the purpose during active sessions. + +## Migration Plan + +This is a breaking change with no data migration. Deployment: + +1. Daemon upgrades. On first read of `~/.netclaw/config/tool-approvals.json`, the loader checks for `version: 2`. If absent or non-2, the file is moved to `tool-approvals.json.v1.bak` and the loader returns an empty v2 store. +2. The daemon writes a fresh `tool-approvals.json` with `{"version": 2, "audiences": {}}` on the first persist call. +3. The next `netclaw approvals list` invocation surfaces a one-line note: "Your previous approvals (N entries) were quarantined to ~/.netclaw/config/tool-approvals.json.v1.bak during a schema upgrade. Inspect or restore manually if needed." +4. Users who relied on specific v1 entries re-establish them via the new prompts or `netclaw approvals trust-verb ` for global grants. + +Rollback: revert the daemon. The v1 file is intact at `tool-approvals.json.v1.bak` — operator can rename it back. Approvals written under v2 are lost on rollback, which matches the breaking-change posture. + +## Open Questions + +- **Verb-chain granularity for compound subcommands.** `git remote` vs `git remote get-url` — today's matcher pins to verb + first subcommand (`git remote`). Should `git remote get-url` be a distinct grant from `git remote add`? Probably not for v1 (the existing granularity is fine), but worth revisiting if users complain that one approval is doing too much. +- **`netclaw approvals trust-verb` confirmation UX.** Should the CLI prompt for confirmation when adding a global wildcard, or trust the explicit command name? Current call: trust the command name (no extra confirm). Revisit if accidental adds become a pattern. +- **Resolution message edit-in-place vs. new message.** Slack supports `chat.update` to edit the original prompt; Discord similar. Editing in place feels cleaner than appending a new "resolved" message. Open: verify both platforms behave correctly when the resolution message arrives after the prompt has been thread-quoted by another reply. +- **Eval case: what counts as a "project signal"?** The positive eval asserts the agent calls `set_working_directory` "early" when the user mentions a repo path. We need an explicit threshold for the eval — first user message? First three turns? — so the assertion isn't ambiguous. diff --git a/openspec/changes/approval-policy-v2/proposal.md b/openspec/changes/approval-policy-v2/proposal.md new file mode 100644 index 00000000..f5d63e53 --- /dev/null +++ b/openspec/changes/approval-policy-v2/proposal.md @@ -0,0 +1,40 @@ +## Why + +Directory-scoped approvals (PR #896 / #927 / #937) landed on `dev` but real use exposes two unshipped problems we want to fix together before the feature ever reaches a release: (1) we prompt for too much — even read-only `grep`/`ls`/`git status` against the user's project trigger an approval, because the only declared safe space is the per-session scratch dir; (2) when we do prompt, the on-disk store mingles verb chains, full normalized commands, directory roots, and bash-fragment garbage in a single flat string list, and the prompt's `Patterns` / `Directory Roots` split is not something users can reason about. Aaron's persisted `tool-approvals.json` accumulated 50 entries including nonsense like `done`, `for pid`, `awk {print $2})`, `do threads=$(grep`. We have no users yet — this is the moment to do a clean breaking redesign instead of compounding the problem. + +## What Changes + +- **BREAKING** `tool-approvals.json` schema goes to `version: 2`. Each entry is a typed `(verb, directory)` pair (`{ "verb": "git remote", "directory": "/abs/path/" | null }`). v1 files are quarantined to `.v1.bak` on first read and a fresh v2 store is written. No automatic translation. +- **BREAKING** Approval matcher operates on `ApprovalEntry` objects, not on opaque strings. The "is this string a verb? a path? a normalized command?" inspection logic is deleted. +- **BREAKING** Approval prompt UX: 5-button row replaces today's 4. New: `Once`, `This chat`, `Always here`, `Always anywhere`, `Deny`. `Always anywhere` and `Deny` are styled as danger. Prompt body shows the cwd in the header and verbs as bullets, eliminating the `Patterns` / `Directory Roots` split. Resolution message replaces the dual sections with a single line ("Saved: jsonlint, git pull in ~/repos/foo/" or "Saved: freshdesk anywhere"). +- New: **safe-verbs ∩ safe-space short-circuit.** A curated per-OS safe-verbs list (`safe-verbs.linux.json`, `safe-verbs.windows.json`) plus the agent's existing safe spaces (`session_dir`, optional `project_dir` from `WorkingContext`) form a three-position policy: auto-run when the verb is on the list and cwd is under a safe-space root; prompt otherwise; hard-deny list unchanged. Mutation inside a safe space still prompts (`git push` in your repo still prompts). +- New: `ScopedShellSafeVerbPolicy` mirrors `ScopedFileAccessPolicy` and reuses `ToolAudienceProfileResolver` for audience-aware root resolution and symlink-segment protection. Public audience inherits the same `session_dir`-only restriction file_read has. +- New: `ShellTool` cwd defaults to `project_dir` if set, else `session_dir`. Today it inherits the daemon process's cwd, which is a security and UX bug (`src/Netclaw.Actors/Tools/ShellTool.cs:81-82`). +- New: Compound shell commands extract every verb chain and present them as one prompt grouped by the cwd. One click on `Always here` / `Always anywhere` persists N `(verb, dir)` pairs at once. +- New: `ShellTokenizer` refuses to extract verb chains from bash control-flow blocks (`for`/`while`/`do`/`done`/`then`/`fi`/`case`/`esac`) or unbalanced quotes/brackets — only `Once` / `Deny` are offered for messy input, with a hint that complex commands cannot persist. +- New: `netclaw approvals trust-verb [--audience]` CLI command writes a `(verb, null)` entry — the global wildcard. Used both interactively and by the agent at schedule-creation time. `list` and `revoke` updated to label entries as ` in ` or ` anywhere`. +- New agent guidance, three coordinated touch-ups: AGENTS.md instruction to call `set_working_directory` early when working on a project (load-bearing, with consequences spelled out); rewrite of the `set_working_directory` tool description to read as "expand your trust boundary" rather than "set cwd"; shell-tool failure path returns a hint pointing at `set_working_directory` when a call is denied because cwd is outside both safe spaces. +- New schedule-creation flow: when the agent helps the user set up a scheduled task, it identifies the verbs the task needs and proactively suggests pre-approval (`netclaw approvals trust-verb `) before the schedule fires unattended. +- New eval cases for `set_working_directory` adoption: positive (project-scoped session calls it early), negative (no-project session does NOT call it preemptively), recovery (denied shell call → agent reads hint → calls it on next turn). + +## Capabilities + +### New Capabilities + +None. All changes fit inside existing capabilities. + +### Modified Capabilities + +- `tool-approval-gates`: replaces the flat-string approval store with a typed `(verb, directory)` model; introduces the safe-verbs ∩ safe-space short-circuit; redesigns the prompt UX to a 5-button row and rewrites the resolution message; adds bash-fragment refusal at extraction time. +- `session-cwd`: shell cwd default falls back to `project_dir` then `session_dir` (was: daemon process cwd); shell-tool failure path returns a `set_working_directory` hint when denial reason is "cwd outside safe spaces". +- `netclaw-cli`: `netclaw approvals trust-verb` subcommand; `list` and `revoke` reflect the v2 entry shape. + +## Impact + +- **Storage:** breaking schema change. v1 file quarantined to `tool-approvals.json.v1.bak` on first read; users start with an empty v2 store. Existing approvals do NOT carry over. +- **Code:** new `ScopedShellSafeVerbPolicy` (mirrors `ScopedFileAccessPolicy`). Modifications across `Netclaw.Configuration`, `Netclaw.Security`, `Netclaw.Actors.Tools`, `Netclaw.Actors.Protocol`, `Netclaw.Channels.Slack`, `Netclaw.Channels.Discord`, `Netclaw.Cli`. Reuses `ToolAudienceProfileResolver` and the symlink-segment guard. +- **Config:** ships `safe-verbs.linux.json` and `safe-verbs.windows.json` with the daemon; users can override at `~/.netclaw/config/safe-verbs..json`. +- **Agent identity:** AGENTS.md gains load-bearing guidance about `set_working_directory`; bumps require eval suite to pass (positive + negative + recovery cases). +- **Skills:** `feeds/skills/.system/files/netclaw-operations/SKILL.md` updated for schedule-creation pre-approval flow and the new approval prompt shape. +- **Security:** safe-space short-circuit is gated by safe-verbs list ∩ safe-space root ∩ symlink-free path. Mutation in safe spaces still prompts. Public audience continues to be restricted to `session_dir` only. Hard-deny list unchanged. No change to ACL evaluation order; this only relaxes the interactive approval gate (layer 2) for a narrowly defined set of verb-and-location combinations. +- **Operational:** `netclaw approvals list` + `revoke` semantics change shape. Operators editing the JSON by hand will hit the v1 quarantine flow on the first daemon read after upgrade. diff --git a/openspec/changes/approval-policy-v2/specs/netclaw-cli/spec.md b/openspec/changes/approval-policy-v2/specs/netclaw-cli/spec.md new file mode 100644 index 00000000..31c6124c --- /dev/null +++ b/openspec/changes/approval-policy-v2/specs/netclaw-cli/spec.md @@ -0,0 +1,166 @@ +## MODIFIED Requirements + +### Requirement: Operator CLI for persistent tool approvals + +The CLI SHALL provide a `netclaw approvals` command surface for +inspecting, revoking, and adding entries to the persistent approvals +file (`~/.netclaw/config/tool-approvals.json`). The command SHALL +operate on the file directly via `Netclaw.Configuration.ToolApprovalStore` +without requiring the daemon to be running. Bare `netclaw approvals` +(and `netclaw approvals tui`) SHALL launch an interactive Termina TUI +page. Single-shot subcommands SHALL be `list`, `revoke`, `trust-verb`, +and `help`. + +`list` SHALL accept `--audience `, `--tool `, +and `--json`. Without flags it SHALL print every audience and tool group +in a stable order. Each entry SHALL be labeled by its scope: entries +with a non-null `directory` print as ` in `; entries +with `directory: null` print as ` anywhere`. The CLI SHALL NOT +mix verb and directory entries in a single column. + +`revoke ` SHALL remove entries that match ``. The +pattern SHALL accept either of the user-visible forms emitted by +`list`: ` in ` matches a `(verb, directory)` entry +exactly, and ` anywhere` matches a `(verb, null)` entry. +Case-sensitivity SHALL match the daemon's matcher comparer (Ordinal on +POSIX, OrdinalIgnoreCase on Windows). `revoke` SHALL accept `--audience` +and `--tool` to scope the removal. `revoke --tool --all` SHALL +clear every entry for that tool in the targeted audiences. `revoke` of +a pattern that does not match any entry SHALL exit non-zero with a +clear message; the CLI SHALL NOT silently succeed. + +`trust-verb ` SHALL write a new `(verb, null)` entry for the +specified verb chain — the global wildcard. The subcommand SHALL accept +`--audience ` (default `personal`) and +`--tool ` (default `shell_execute`). `trust-verb` SHALL be the +canonical way to pre-approve a verb for unattended/scheduled invocations +where the cwd will vary across firings. If the entry already exists, +`trust-verb` SHALL exit zero with a "no changes" message. + +The CLI SHALL ONLY support adding global wildcards via `trust-verb`. It +SHALL NOT provide a way to add `(verb, directory)` entries from the +CLI; folder-scoped grants SHALL be acquired exclusively through +interactive approval prompts. This is a deliberate friction asymmetry: +prompt-driven grants are the default user path, and the CLI exists to +handle the unattended case and the global-trust case operators +explicitly want. + +When the underlying store has quarantined a malformed v1 file +(`tool-approvals.json.v1.bak` sibling), the CLI SHALL emit a one-line +note before list/revoke output indicating the quarantine and pointing +at the backup file. The CLI SHALL NOT silently swallow the condition. + +Exit codes SHALL be 0 for success and 1 for user errors (bad flag +combos, unknown audience, no match for revoke, `--all` without `--tool`, +etc.). + +#### Scenario: Empty approvals file lists no entries with exit zero + +- **GIVEN** `tool-approvals.json` does not exist or contains an empty + v2 store +- **WHEN** the operator runs `netclaw approvals list` +- **THEN** the CLI prints `No persistent approvals.` +- **AND** exits with code `0` + +#### Scenario: List filters by audience + +- **GIVEN** `tool-approvals.json` contains entries under `personal` + and `team` +- **WHEN** the operator runs `netclaw approvals list --audience personal` +- **THEN** only the `personal` audience entries are printed + +#### Scenario: List labels entries by scope + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"git remote","directory":"/home/user/repos/foo/"}` and + `{"verb":"freshdesk","directory":null}` under `personal/shell_execute` +- **WHEN** the operator runs `netclaw approvals list` +- **THEN** the output includes `git remote in /home/user/repos/foo/` +- **AND** the output includes `freshdesk anywhere` + +#### Scenario: List emits typed JSON + +- **GIVEN** `tool-approvals.json` contains + `{"version":2,"audiences":{"personal":{"shell_execute":[ + {"verb":"git push","directory":null}]}}}` +- **WHEN** the operator runs `netclaw approvals list --json` +- **THEN** the output is valid JSON +- **AND** each entry preserves the `verb`/`directory` shape + +#### Scenario: Revoke removes a folder-scoped entry by user-visible form + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"git remote","directory":"/home/user/repos/foo/"}` and + `{"verb":"freshdesk","directory":null}` +- **WHEN** the operator runs + `netclaw approvals revoke "git remote in /home/user/repos/foo/"` +- **THEN** the `git remote` entry is removed +- **AND** the `freshdesk anywhere` entry remains +- **AND** the CLI exits with code `0` + +#### Scenario: Revoke removes a global wildcard by user-visible form + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"freshdesk","directory":null}` +- **WHEN** the operator runs + `netclaw approvals revoke "freshdesk anywhere"` +- **THEN** the entry is removed +- **AND** the CLI exits with code `0` + +#### Scenario: Revoke with no match exits non-zero + +- **GIVEN** `tool-approvals.json` does not contain `git push` +- **WHEN** the operator runs `netclaw approvals revoke "git push anywhere"` +- **THEN** the CLI prints a no-match message +- **AND** exits with code `1` +- **AND** does not modify the file + +#### Scenario: trust-verb writes a global wildcard entry + +- **GIVEN** `tool-approvals.json` does not yet contain `freshdesk` +- **WHEN** the operator runs `netclaw approvals trust-verb freshdesk` +- **THEN** the file gains entry + `{"verb":"freshdesk","directory":null}` under + `personal/shell_execute` +- **AND** the CLI exits with code `0` + +#### Scenario: trust-verb is idempotent + +- **GIVEN** `tool-approvals.json` already contains + `{"verb":"freshdesk","directory":null}` +- **WHEN** the operator runs `netclaw approvals trust-verb freshdesk` +- **THEN** the file is unchanged +- **AND** the CLI prints a "no changes" message +- **AND** exits with code `0` + +#### Scenario: trust-verb honors --audience and --tool + +- **WHEN** the operator runs + `netclaw approvals trust-verb freshdesk --audience team --tool shell_execute` +- **THEN** the entry is written under `team/shell_execute` +- **AND** the CLI exits with code `0` + +#### Scenario: Quarantined v1 file surfaces a one-line note + +- **GIVEN** `~/.netclaw/config/tool-approvals.json.v1.bak` exists + (the daemon has previously quarantined a v1 file) +- **WHEN** the operator runs `netclaw approvals list` +- **THEN** the CLI emits a one-line note before the listing pointing + at the `.v1.bak` file +- **AND** the listing reflects only v2 entries + +#### Scenario: Daemon picks up CLI-applied trust-verb without restart + +- **GIVEN** the daemon is running +- **WHEN** the operator runs `netclaw approvals trust-verb freshdesk` +- **AND** the agent invokes `freshdesk --since=24h` afterwards +- **THEN** the daemon re-loads the file and observes the new entry +- **AND** the call auto-approves with no prompt +- **AND** the daemon was not restarted + +#### Scenario: Bare invocation launches the TUI + +- **WHEN** the operator runs `netclaw approvals` with no subcommand +- **THEN** the CLI launches the interactive Termina approvals page +- **AND** the page displays entries grouped by audience and tool with + scope labels (` in ` / ` anywhere`) diff --git a/openspec/changes/approval-policy-v2/specs/session-cwd/spec.md b/openspec/changes/approval-policy-v2/specs/session-cwd/spec.md new file mode 100644 index 00000000..c9abc6ea --- /dev/null +++ b/openspec/changes/approval-policy-v2/specs/session-cwd/spec.md @@ -0,0 +1,186 @@ +## ADDED Requirements + +### Requirement: Shell tool cwd defaults to declared safe spaces + +`ShellTool` SHALL resolve the working directory for every invocation +in this priority order: explicit `WorkingDirectory` argument when +provided, else `WorkingContext.ProjectDirectory` when set, else +`session_dir` (the per-session directory under +`~/.netclaw/sessions//`). `ShellTool` SHALL NOT fall +through to `ProcessStartInfo`'s default behavior of inheriting the +daemon process's cwd. + +This guarantees every shell invocation has a known cwd parented under a +declared safe space (or an explicit override), which is the precondition +the approval policy depends on. + +#### Scenario: Cwd defaults to project_dir when set + +- **GIVEN** a session with `WorkingContext.ProjectDirectory` set to + `~/repos/foo/` +- **WHEN** the agent invokes `shell_execute` with command `pwd` and + no `WorkingDirectory` +- **THEN** the command runs with cwd `~/repos/foo/` + +#### Scenario: Cwd defaults to session_dir when project_dir is null + +- **GIVEN** a session with `WorkingContext.ProjectDirectory` null +- **WHEN** the agent invokes `shell_execute` with command `pwd` and + no `WorkingDirectory` +- **THEN** the command runs with cwd `~/.netclaw/sessions//` + +#### Scenario: Explicit WorkingDirectory overrides default + +- **GIVEN** a session with `WorkingContext.ProjectDirectory` set to + `~/repos/foo/` +- **WHEN** the agent invokes `shell_execute` with command `pwd` and + `WorkingDirectory` `/tmp/` +- **THEN** the command runs with cwd `/tmp/` +- **AND** the approval gate evaluates safe-space membership against `/tmp/` + +#### Scenario: Cwd never inherits daemon process cwd + +- **GIVEN** the daemon process was launched with cwd `/var/lib/netclawd/` +- **AND** a session has neither `project_dir` set nor an explicit + `WorkingDirectory` argument +- **WHEN** the agent invokes `shell_execute` +- **THEN** the command does NOT run with cwd `/var/lib/netclawd/` +- **AND** the resolved cwd is `session_dir` + +### Requirement: Shell tool failure-path hint for cwd outside safe spaces + +`ShellTool` SHALL include a one-line hint in the tool result returned +to the model when a call is denied because its cwd is outside both +`session_dir` and `project_dir`. The hint SHALL suggest +`set_working_directory ` with the path that triggered the denial, +in a format recognizable to the agent so it can self-correct without a +roundtrip through the user. + +The hint SHALL only be emitted when the denial reason is "cwd outside +safe spaces" and `set_working_directory` is in the audience's tool +exposure list. The hint SHALL NOT be emitted for hard-deny-list refusals +or for `ToolPathPolicy` denials (those have different remediation paths). + +#### Scenario: Denial in foreign tree includes set_working_directory hint + +- **GIVEN** a Personal session with `project_dir` not set +- **WHEN** the agent invokes `shell_execute` with cwd `~/repos/bar/` +- **AND** the user denies the resulting prompt +- **THEN** the tool result includes a hint pointing at + `set_working_directory ~/repos/bar/` + +#### Scenario: Hint is not emitted for hard-deny refusals + +- **GIVEN** a hard-deny-list block on the command +- **WHEN** `shell_execute` returns the deny error +- **THEN** the result does NOT include a `set_working_directory` hint + +#### Scenario: Hint is not emitted when set_working_directory is unavailable + +- **GIVEN** a Public session where `set_working_directory` is not in + the tool exposure list +- **WHEN** a shell call is denied for cwd-outside-safe-space +- **THEN** the result does NOT include a `set_working_directory` hint + +### Requirement: set_working_directory expands the approval safe space + +Setting `WorkingContext.ProjectDirectory` SHALL expand the approval gate's +safe-space root set for Personal and Team audiences: subsequent shell +invocations whose cwd resolves under the new project directory SHALL +participate in the safe-verb auto-allow short-circuit (subject to the +safe-verbs list and symlink-segment guard). For Public audience, +`set_working_directory` SHALL NOT be available and the safe space SHALL +remain `session_dir` only. + +This requirement formalizes the dependency between session_cwd and +tool-approval-gates: the act of declaring the project root is the act +of opening the approval trust boundary. + +#### Scenario: Setting project_dir relaxes future approval prompts + +- **GIVEN** a Personal session with `project_dir` initially null +- **AND** the agent has previously been denied `grep` calls in + `~/repos/foo/` +- **WHEN** the agent calls `set_working_directory ~/repos/foo/` +- **AND** the agent retries `grep -r "x" .` with cwd `~/repos/foo/` +- **THEN** the approval gate short-circuits (safe verb in safe space) +- **AND** no prompt is rendered + +#### Scenario: Public audience does not get safe-space expansion + +- **GIVEN** a Public session +- **WHEN** the tool exposure list is computed +- **THEN** `set_working_directory` is not included +- **AND** the only safe space remains `session_dir` + +## MODIFIED Requirements + +### Requirement: set_working_directory tool + +The system SHALL provide a `set_working_directory` tool that sets the +session's project directory to a specified path AND expands the +approval gate's safe-space root set for Personal and Team audiences. +The tool SHALL validate that the target path is a real directory, +resolve it to an absolute path, and validate it against the audience +trust profile's read-allowed roots. The tool SHALL be profile-managed +so that audiences without directory navigation privileges (Public, +Team by default) cannot use it. + +The tool description visible to the model SHALL frame the tool as +"declare your project root and expand your trusted scope so shell +commands inside that tree run without per-command approval" rather +than as a `cd`-style cwd change. Calling this tool is the load-bearing +gesture by which the agent signals what it is working on; the agent's +approval friction depends on doing so when the work is project-scoped. + +#### Scenario: set_working_directory updates project directory + +- **GIVEN** a session with no project directory set +- **AND** the audience trust profile allows reads under `/home/user` +- **WHEN** the agent invokes `set_working_directory` with + path `/home/user/workspaces/akadonic` +- **THEN** the session project directory is set to + `/home/user/workspaces/akadonic` +- **AND** the project's identity file is loaded on the next LLM call +- **AND** subsequent shell calls with cwd inside that directory may + participate in the safe-verb auto-allow short-circuit + +#### Scenario: set_working_directory rejected outside allowed roots + +- **GIVEN** a session with audience profile allowing reads only under + `/home/user` +- **WHEN** the agent invokes `set_working_directory` with path `/etc/nginx` +- **THEN** the project directory remains unchanged +- **AND** the tool returns an error indicating the path is outside allowed + roots + +#### Scenario: set_working_directory rejected for nonexistent directory + +- **GIVEN** a session with audience profile allowing reads under `/home/user` +- **WHEN** the agent invokes `set_working_directory` with + path `/home/user/nonexistent` +- **THEN** the project directory remains unchanged +- **AND** the tool returns an error indicating the directory does not exist + +#### Scenario: Personal audience allows any valid directory + +- **GIVEN** a session with personal audience (`ToolFilesystemMode.All`) +- **WHEN** the agent invokes `set_working_directory` with any valid directory +- **THEN** the project directory is updated + +#### Scenario: set_working_directory not exposed to public audience + +- **GIVEN** a session with public audience +- **WHEN** the tool exposure list is computed +- **THEN** `set_working_directory` is not included + +#### Scenario: Switching projects replaces context + +- **GIVEN** a session with project directory `/home/user/workspaces/akadonic` +- **WHEN** the agent invokes `set_working_directory` with + path `/home/user/workspaces/other-project` +- **THEN** the project directory changes to `/home/user/workspaces/other-project` +- **AND** the next LLM call loads identity files from the new project +- **AND** the old project's identity files are no longer injected +- **AND** the approval safe-space root for shell invocations switches + to the new project directory diff --git a/openspec/changes/approval-policy-v2/specs/tool-approval-gates/spec.md b/openspec/changes/approval-policy-v2/specs/tool-approval-gates/spec.md new file mode 100644 index 00000000..8ff39b54 --- /dev/null +++ b/openspec/changes/approval-policy-v2/specs/tool-approval-gates/spec.md @@ -0,0 +1,504 @@ +## ADDED Requirements + +### Requirement: Safe-verb auto-allow short-circuit in declared safe spaces + +The system SHALL maintain a per-OS curated list of demonstrably read-only +verb chains (`safe-verbs.linux.json` and `safe-verbs.windows.json`) shipped +with the daemon and overridable at `~/.netclaw/config/safe-verbs..json`. +A `ScopedShellSafeVerbPolicy` SHALL evaluate each shell invocation against +the safe-verbs list AND the audience-aware safe-space roots resolved by +`ToolAudienceProfileResolver`. When the candidate verb chain is on the +safe-verbs list AND the candidate's cwd resolves under at least one +safe-space root AND the path contains no symlink segments +(`ContainsSymlinkSegment` returns false), the approval gate SHALL +short-circuit to "approved" with no user prompt. Otherwise the existing +approval gate SHALL apply. + +Safe-space roots SHALL be: + +- For Personal and Team audiences: `session_dir` (always) plus + `project_dir` from `WorkingContext` (when set). +- For Public audience: `session_dir` only. Public sessions SHALL NOT + expand their safe space via `project_dir`, mirroring the read-roots + restriction `ScopedFileAccessPolicy` enforces for file_read. + +The hard-deny list (layer 1) SHALL apply unchanged. The safe-verb +short-circuit SHALL only relax the interactive approval gate (layer 2). +`ToolPathPolicy.CommandReferencesDeniedPath` SHALL still block execution +if a denied path is referenced. + +#### Scenario: Read-only verb in project directory auto-runs + +- **GIVEN** a Personal session with `project_dir` set to `~/repos/foo/` +- **AND** `grep` is on the Linux safe-verbs list +- **WHEN** the agent invokes `shell_execute` with command + `grep -r "error" .` and cwd `~/repos/foo/` +- **THEN** the approval gate short-circuits to "approved" +- **AND** no prompt is rendered to the user +- **AND** `tool-approvals.json` is NOT modified + +#### Scenario: Read-only verb in session directory auto-runs + +- **GIVEN** a Personal session with no `project_dir` set +- **AND** `cat` is on the safe-verbs list +- **WHEN** the agent invokes `shell_execute` with command + `cat inbox/notes.md` and cwd `~/.netclaw/sessions//` +- **THEN** the approval gate short-circuits to "approved" +- **AND** no prompt is rendered + +#### Scenario: Read-only verb outside safe spaces still prompts + +- **GIVEN** a Personal session with `project_dir` set to `~/repos/foo/` +- **AND** `grep` is on the safe-verbs list +- **WHEN** the agent invokes `shell_execute` with cwd `/etc/` +- **THEN** the approval gate prompts the user +- **AND** the prompt body shows `/etc/` as the directory header + +#### Scenario: Mutating verb in safe space still prompts + +- **GIVEN** a Personal session with `project_dir` set to `~/repos/foo/` +- **AND** `git push` is NOT on the safe-verbs list +- **WHEN** the agent invokes `shell_execute` with command + `git push origin main` and cwd `~/repos/foo/` +- **THEN** the approval gate prompts the user +- **AND** the user can grant `(git push, ~/repos/foo/)` via "Always here" + +#### Scenario: Public audience cannot use project_dir as safe space + +- **GIVEN** a Public session with `project_dir` set to `~/repos/foo/` +- **AND** `grep` is on the safe-verbs list +- **WHEN** the agent invokes `shell_execute` with cwd `~/repos/foo/` +- **THEN** the approval gate prompts the user +- **AND** Public's only safe space remains `session_dir` + +#### Scenario: Symlink under safe-space root cannot extend safe scope + +- **GIVEN** a Personal session with `project_dir` set to `~/repos/foo/` +- **AND** `~/repos/foo/leak` is a symlink resolving to `/etc` +- **WHEN** the agent invokes `shell_execute` with cwd `~/repos/foo/leak/` + and command `cat passwd` +- **THEN** the safe-verb short-circuit SHALL NOT apply + (`ContainsSymlinkSegment` returns true) +- **AND** the approval gate prompts the user (or `ToolPathPolicy` + hard-denies if the resolved path is protected) + +#### Scenario: User-overridden safe-verbs file extends defaults + +- **GIVEN** the user has written + `~/.netclaw/config/safe-verbs.linux.json` containing the verb `eza` +- **WHEN** the daemon loads safe-verbs configuration +- **THEN** `eza` is treated as a safe verb in addition to the shipped defaults +- **AND** `eza` invocations in safe spaces auto-run without prompting + +### Requirement: Five-button approval prompt with verb-and-directory framing + +When the approval gate prompts the user, the prompt SHALL render five +buttons in one row: `Once`, `This chat`, `Always here`, `Always anywhere`, +`Deny`. The buttons `Always anywhere` and `Deny` SHALL be styled as +danger (Slack `style: "danger"`, Discord `ButtonStyle.Danger`). All +button labels SHALL fit within Slack's 76-character and Discord's +80-character button-text caps. + +The prompt body SHALL show the cwd in the header +(`Approve in ?`) and the extracted verb chains as a bulleted list. +Single-verb commands MAY collapse the list into the header +(`Approve in ?`). The body SHALL NOT render separate +"Patterns" or "Directory Roots" sections. + +Button semantics: + +- `Once` SHALL run the command this one time and persist nothing. +- `This chat` SHALL allow the extracted verbs in the prompt's directory + for the rest of the session, stored in session-scoped memory only. +- `Always here` SHALL persist `(verb, prompt's directory)` entries to + `tool-approvals.json` for each extracted verb. +- `Always anywhere` SHALL persist `(verb, null)` entries for each + extracted verb — the global wildcard. +- `Deny` SHALL refuse this call only. Denying a verb SHALL NOT ban it + for future invocations. + +#### Scenario: Compound command shows verbs as bullets + +- **GIVEN** the agent invokes `shell_execute` with command + `cd ~/repos/foo && git remote -v && git rev-parse HEAD` + and cwd `~/repos/foo/` +- **WHEN** the approval prompt is rendered on Slack +- **THEN** the body header reads `Approve in ~/repos/foo/ ?` +- **AND** the verbs `cd`, `git remote`, `git rev-parse` appear as bullets +- **AND** the action row contains five buttons +- **AND** `Always anywhere` and `Deny` are styled as danger + +#### Scenario: Always here persists folder-scoped entries + +- **GIVEN** an approval prompt for verbs `git remote`, `git rev-parse` + in cwd `~/repos/foo/` +- **WHEN** the user clicks `Always here` +- **THEN** `tool-approvals.json` gains entries + `{"verb": "git remote", "directory": "~/repos/foo/"}` and + `{"verb": "git rev-parse", "directory": "~/repos/foo/"}` +- **AND** the resolution message reads + `Saved: git remote, git rev-parse in ~/repos/foo/` + +#### Scenario: Always anywhere persists global entries + +- **GIVEN** an approval prompt for verb `freshdesk` in cwd `~/.netclaw/sessions//` +- **WHEN** the user clicks `Always anywhere` +- **THEN** `tool-approvals.json` gains entry + `{"verb": "freshdesk", "directory": null}` +- **AND** the resolution message reads `Saved: freshdesk anywhere` + +#### Scenario: This chat persists session-scoped only + +- **GIVEN** an approval prompt for verb `jsonlint` in cwd `~/repos/foo/` +- **WHEN** the user clicks `This chat` +- **THEN** session-scoped memory records `(jsonlint, ~/repos/foo/)` +- **AND** `tool-approvals.json` is NOT modified +- **AND** a new session prompts again + +#### Scenario: Deny refuses only the current call + +- **GIVEN** an approval prompt for verb `git push` +- **WHEN** the user clicks `Deny` +- **THEN** the current call is refused +- **AND** `tool-approvals.json` is NOT modified +- **AND** a later `git push` call still prompts + +### Requirement: Resolution message single-line format + +After an approval response is processed, the channel SHALL render a +single-line resolution message replacing today's separate `Patterns` and +`Directory Roots` sections. The line SHALL identify the verbs and the +scope. Permitted formats: + +- `Saved: in ` — for `Always here`. +- `Saved: anywhere` — for `Always anywhere`. +- `Saved for this chat: in ` — for `This chat`. +- `Approved (no save)` — for `Once`. +- `Denied` — for `Deny`. + +#### Scenario: Resolution shows folder scope for Always here + +- **GIVEN** the user has clicked `Always here` for verbs + `jsonlint, git pull` in `~/repos/foo/` +- **WHEN** the resolution message is rendered +- **THEN** the message reads `Saved: jsonlint, git pull in ~/repos/foo/` +- **AND** no `Patterns` or `Directory Roots` headers are emitted + +#### Scenario: Resolution shows global scope for Always anywhere + +- **GIVEN** the user has clicked `Always anywhere` for verb `freshdesk` +- **WHEN** the resolution message is rendered +- **THEN** the message reads `Saved: freshdesk anywhere` + +### Requirement: Pattern extraction refuses bash control-flow + +`ShellTokenizer.SplitCompoundCommand` SHALL detect bash control-flow +tokens (`for`, `while`, `do`, `done`, `then`, `fi`, `case`, `esac`) and +unbalanced quotes/brackets. When detected, the tokenizer SHALL return an +empty verb-chain list. The approval gate SHALL respond by offering only +the `Once` and `Deny` buttons (no `This chat`, `Always here`, or +`Always anywhere`) and the prompt body SHALL show a hint: "complex +command — only one-shot approval available". No persistent grant SHALL +be possible for unparseable commands. + +#### Scenario: For-loop produces empty verb-chain list + +- **GIVEN** the command + `for pid in $(pgrep netclawd); do echo "$pid"; done` +- **WHEN** `ShellTokenizer.SplitCompoundCommand` runs +- **THEN** the returned verb-chain list is empty + +#### Scenario: Approval prompt for messy command offers only Once and Deny + +- **GIVEN** the agent invokes `shell_execute` with the for-loop above + and cwd outside any safe space +- **WHEN** the approval prompt is rendered +- **THEN** only `Once` and `Deny` buttons are present +- **AND** the body shows the "complex command" hint + +#### Scenario: Unbalanced quotes treated as messy + +- **GIVEN** the command `echo "unterminated` +- **WHEN** the tokenizer runs +- **THEN** the verb-chain list is empty +- **AND** the approval gate offers only `Once` and `Deny` + +## MODIFIED Requirements + +### Requirement: Persistent approval storage + +The system SHALL store persistent approvals in +`~/.netclaw/config/tool-approvals.json` using a `version: 2` typed +schema. Each entry SHALL be an `ApprovalEntry` with a required `verb` +field (the verb chain, e.g. `git remote`) and an optional `directory` +field (an absolute path, or `null` for the global wildcard). The file +SHALL contain per-audience sections with per-tool `ApprovalEntry` lists. +The file SHALL NOT be monitored by `ConfigWatcherService`. + +When the daemon reads a `tool-approvals.json` file that does not have +`version: 2`, the file SHALL be quarantined to +`tool-approvals.json.v1.bak` and an empty v2 store SHALL be returned. +The daemon SHALL write the empty v2 store on the next persist call. No +automatic translation of v1 entries SHALL be performed. + +The matcher SHALL approve a candidate invocation when there exists an +`ApprovalEntry` whose `verb` equals the candidate's extracted verb +chain AND (`directory` is `null` OR the candidate's cwd is under +`directory`). + +The file SHALL also be operator-editable via the `netclaw approvals` +CLI (see the `netclaw-cli` capability). The daemon SHALL pick up +out-of-band edits — whether made by direct file editing or by the +CLI — on the next approval check, without requiring a restart. + +#### Scenario: Always here persists typed (verb, directory) entries + +- **GIVEN** the user clicks `Always here` for verbs `git remote` and + `git rev-parse` in cwd `~/repos/foo/` +- **WHEN** the approval is processed +- **THEN** `tool-approvals.json` contains + `[{"verb":"git remote","directory":"~/repos/foo/"}, + {"verb":"git rev-parse","directory":"~/repos/foo/"}]` +- **AND** the daemon does NOT restart + +#### Scenario: Always anywhere persists null-directory entry + +- **GIVEN** the user clicks `Always anywhere` for verb `freshdesk` +- **WHEN** the approval is processed +- **THEN** `tool-approvals.json` contains + `{"verb":"freshdesk","directory":null}` + +#### Scenario: v1 file quarantined on first read + +- **GIVEN** `tool-approvals.json` exists without a `version` field + (or with `version` other than `2`) +- **WHEN** the daemon loads the file +- **THEN** the file is moved to `tool-approvals.json.v1.bak` +- **AND** `Load()` returns an empty v2 store +- **AND** no v1 entries are translated to v2 + +#### Scenario: Matcher approves under directory entry + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"git remote","directory":"~/repos/foo/"}` +- **WHEN** the agent invokes `git remote -v` with cwd `~/repos/foo/` +- **THEN** the matcher returns approved +- **AND** no prompt is rendered + +#### Scenario: Matcher approves under null-directory entry + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"freshdesk","directory":null}` +- **WHEN** the agent invokes `freshdesk --since=24h` with cwd + `~/.netclaw/sessions//` +- **THEN** the matcher returns approved regardless of cwd + +#### Scenario: Matcher rejects when cwd is outside entry directory + +- **GIVEN** `tool-approvals.json` contains + `{"verb":"git remote","directory":"~/repos/foo/"}` +- **WHEN** the agent invokes `git remote -v` with cwd `~/repos/bar/` +- **THEN** the matcher returns not-approved +- **AND** the approval gate prompts the user + +#### Scenario: Approve once is retry-scoped only + +- **GIVEN** the user clicks `Once` for command `docker build` +- **WHEN** the approval is processed +- **THEN** the blocked `docker build` call is retried immediately +- **AND** a later `docker build` call in the same session prompts again +- **AND** `tool-approvals.json` is NOT modified + +#### Scenario: Operator-applied revocation visible without restart + +- **GIVEN** the daemon is running with a persisted entry + `{"verb":"git push","directory":null}` +- **WHEN** an operator removes that entry via `netclaw approvals revoke` +- **AND** a new approval check evaluates `git push` +- **THEN** the daemon re-loads the file and observes the entry is gone +- **AND** the user is prompted for approval again +- **AND** the daemon was not restarted + +### Requirement: Shell command pattern matching + +The system SHALL extract verb-chain prefix patterns from shell commands +using tokenization. The verb chain SHALL consist of non-flag tokens from +the start of the command until the first flag (`-`), path, or URL +argument. For shell approval units, `&&`, `||`, and `;` SHALL split into +separate units, while `|` SHALL remain inside the current unit. For +`bash -c` or `sh -c` wrappers, the inner command SHALL be extracted and +scanned recursively. + +When `ShellTokenizer.SplitCompoundCommand` detects bash control-flow +tokens or unbalanced quotes/brackets, it SHALL return an empty +verb-chain list. The approval gate SHALL then offer only `Once` and +`Deny`. See the "Pattern extraction refuses bash control-flow" +requirement for details. + +The matcher SHALL operate on `ApprovalEntry` records keyed by +`(verb, directory)`. The "is this string a verb chain or a directory +root?" inspection logic of v1 SHALL NOT be present in the v2 matcher. + +Approval persistence SHALL store one `ApprovalEntry` per extracted verb +chain. Compound commands SHALL produce N entries from one user click on +`Always here` or `Always anywhere`. + +#### Scenario: Verb chain extracted from simple command + +- **GIVEN** the command `git push origin main` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `git push` + +#### Scenario: Verb chain stops at flag + +- **GIVEN** the command `ls -la /tmp` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `ls` +- **AND** the flag and path are not part of the persisted verb chain + +#### Scenario: Multi-level verb chain + +- **GIVEN** the command `docker compose up -d` +- **WHEN** the pattern is extracted +- **THEN** the pattern is `docker compose up` + +#### Scenario: Control operators create separate approval units + +- **GIVEN** the command `git add . && git commit -m "fix" && git push` +- **WHEN** approval is checked +- **THEN** `git add`, `git commit`, and `git push` are checked as + separate approval units against the v2 matcher + +#### Scenario: Compound segments batched in one prompt + +- **GIVEN** none of `git add`, `git commit`, `git push` are approved +- **WHEN** the command `git add . && git commit -m "fix" && git push` + is checked +- **THEN** a single approval prompt lists all three verbs as bullets +- **AND** one click on `Always here` persists three `(verb, cwd)` entries + +#### Scenario: bash -c inner command scanned recursively + +- **GIVEN** the command `bash -c "git push --force"` +- **WHEN** approval and hard deny are checked +- **THEN** the inner command `git push --force` is extracted and scanned +- **AND** verb chain `git push` is checked through the v2 matcher + +### Requirement: Directory-root approvals for shell_execute + +For `shell_execute`, persistent approvals SHALL be stored as typed +`(verb, directory)` `ApprovalEntry` records, NOT as separate verb +patterns and directory-root entries. The matcher SHALL approve a +candidate invocation when an `ApprovalEntry` exists whose `verb` matches +the candidate's verb chain AND (`directory` is `null` OR the candidate's +cwd is under `directory`). + +`Once` SHALL retry only the blocked call; it SHALL NOT create any +session or persistent approval. + +`This chat` SHALL store `(verb, prompt's directory)` entries in +session-scoped memory only. + +`Always here` SHALL persist `(verb, prompt's directory)` entries to +`tool-approvals.json`. + +`Always anywhere` SHALL persist `(verb, null)` entries to +`tool-approvals.json` — the global wildcard. + +The system SHALL enforce path normalization, boundary-safe containment, +path traversal checks, and `ToolPathPolicy` as the safety backstop. +`ToolPathPolicy` SHALL resolve symlinks along every component of a +candidate path so that a planted symlink under an approved directory +cannot be used to reach a protected path that lies outside that +directory. + +The minimum-depth check from v1 (rejecting roots like `/` or `/etc/`) +SHALL still apply to the directory portion of `(verb, directory)` +entries: `Always here` SHALL NOT persist a directory shallower than +two path segments. When the prompt's directory is too shallow, the +prompt SHALL omit the `Always here` button (only `Once`, `This chat`, +`Always anywhere`, `Deny` remain), so the user cannot accidentally +write a too-shallow root. + +#### Scenario: Once retries only the blocked call + +- **GIVEN** a shell command `cat ~/repos/foo/notes.md` requires approval +- **WHEN** the user clicks `Once` +- **THEN** only the current blocked call is retried +- **AND** no `ApprovalEntry` is recorded +- **AND** a later `cat ~/repos/foo/other.md` prompts again + +#### Scenario: Always here stores (verb, directory) entry + +- **GIVEN** a shell command `grep -l "timeout" daemon.log` with cwd + `~/.netclaw/logs/` +- **WHEN** the user clicks `Always here` +- **THEN** `{"verb":"grep","directory":"~/.netclaw/logs/"}` is written + to `tool-approvals.json` +- **AND** a future `wc -l app.log` with cwd `~/.netclaw/logs/` does NOT + match this entry (different verb) +- **AND** a future `grep "info" archive.log` with cwd + `~/.netclaw/logs/` is auto-approved (same verb, same directory) + +#### Scenario: Always anywhere stores (verb, null) entry + +- **GIVEN** a shell command `freshdesk --since=24h` requires approval +- **WHEN** the user clicks `Always anywhere` +- **THEN** `{"verb":"freshdesk","directory":null}` is written to + `tool-approvals.json` +- **AND** a scheduled task firing `freshdesk` in any cwd is + auto-approved on next invocation + +#### Scenario: Boundary-safe matching prevents prefix collisions + +- **GIVEN** `{"verb":"cat","directory":"/home/user/"}` is approved +- **WHEN** the agent runs `cat data.txt` with cwd `/home/usersecret/` +- **THEN** the candidate does NOT match the entry +- **AND** the approval gate prompts the user + +#### Scenario: Symlink in cwd breaks the approval match + +- **GIVEN** `{"verb":"cat","directory":"/home/user/safe/"}` is approved +- **AND** `/home/user/safe/leak` is a directory symlink resolving + to `/etc` +- **WHEN** the agent runs `cat passwd` with cwd `/home/user/safe/leak/` +- **THEN** the symlink-segment check breaks the auto-approval +- **AND** `ToolPathPolicy.CommandReferencesDeniedPath` blocks execution + if the canonical path is protected + +#### Scenario: Shallow directory prevents Always here + +- **GIVEN** an approval prompt for `cat /etc/passwd` (cwd `/etc/`) +- **WHEN** the prompt is rendered +- **THEN** the `Always here` button is omitted +- **AND** only `Once`, `This chat`, `Always anywhere`, `Deny` are shown + +## REMOVED Requirements + +### Requirement: Directory root extraction via IToolApprovalMatcher + +**Reason:** Replaced by the typed `(verb, directory)` `ApprovalEntry` +model. Directory roots are no longer a separate matcher concept; they +are the `directory` field on every entry. `IToolApprovalMatcher` +collapses to verb-chain extraction; the cwd providing the directory +half of the pair comes from `ToolExecutionContext`. + +**Migration:** None — breaking change. Implementation removes +`ExtractDirectoryRoots()` from `IToolApprovalMatcher` and the +corresponding implementations in `ShellApprovalMatcher`, +`DefaultApprovalMatcher`, and `FilePathApprovalMatcher`. Pattern +extraction returns verb chains; the approval gate threads the cwd +through `ToolInteractionRequest.Cwd`. + +### Requirement: Dynamic approval option labels + +**Reason:** PR #937 already reverted dynamic labels to fixed labels to +fit Slack/Discord button caps. The v2 prompt design replaces the +3-button + dynamic-label approach with 5 fixed-label buttons +(`Once`, `This chat`, `Always here`, `Always anywhere`, `Deny`). The +verb-and-directory framing now lives in the prompt body header +(`Approve in ?`) and the verb bullet list, not in button text. + +**Migration:** None — breaking change. The `Always here` and +`Always anywhere` buttons replace the directory-root-aware label +behavior; the cwd is shown in the prompt body, not the button. diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md new file mode 100644 index 00000000..141ac001 --- /dev/null +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -0,0 +1,112 @@ +# Approval Policy v2 — Tasks + +Phasing intent (from design.md): + +- **PR 1** = sections 1–6 (storage, matcher, cwd default, safe-verb policy, CLI, hard-deny audit). No prompt/UI changes; channel adapters keep rendering today's body off the new data. +- **PR 2** = sections 7–10 (prompt redesign, resolution message, agent guidance, schedule-creation flow, evals). + +Both PRs sit under this single OpenSpec change. + +## 1. Storage schema v2 + quarantine + +- [ ] 1.1 Add `ApprovalEntry` record (`Verb` required, `Directory` nullable) to `src/Netclaw.Configuration/`. +- [ ] 1.2 Update `ToolApprovalData` to `Version` (int, default 2) + `Dictionary>>` shape. +- [ ] 1.3 Update `ToolApprovalStore.Load()` to detect `Version != 2` (or absent), move file to `tool-approvals.json.v1.bak`, and return empty v2 store. +- [ ] 1.4 Update `ToolApprovalStore.Save()` to always emit `version: 2`. +- [ ] 1.5 Update `AddApproval` / `RemoveApproval` / `RemoveAllForTool` / `Snapshot` to operate on `ApprovalEntry`. +- [ ] 1.6 Update `ToolApprovalEntryComparer` to compare `(Verb, Directory)` tuples (Ordinal on POSIX, OrdinalIgnoreCase on Windows; null directory compares equal to null directory). +- [ ] 1.7 Unit tests for v1 quarantine on first read; round-trip serialization of folder-scoped and global-wildcard entries; comparer on POSIX vs Windows. + +## 2. Matcher operates on ApprovalEntry + +- [ ] 2.1 Update `IToolApprovalMatcher` to remove `ExtractDirectoryRoots`; pattern extraction returns verb chains only. +- [ ] 2.2 Update `ApprovalPatternMatching` to evaluate `(verb, directory)` containment: candidate matches when verb equals entry's verb AND (entry directory is null OR candidate cwd is under entry directory) AND no symlink segment along the cwd path. +- [ ] 2.3 Plumb `Cwd` through `ToolExecutionContext` / `ToolInteractionRequest` so the matcher always has a concrete cwd to evaluate against. +- [ ] 2.4 Delete the v1 string-shape inspection logic (trailing-slash heuristic) from `ShellApprovalMatcher` and `ApprovalPatternMatching`. +- [ ] 2.5 Unit tests for the four matcher cases: cwd inside entry directory; cwd outside; entry directory null; symlink segment in cwd. + +## 3. ShellTokenizer refuses messy input + +- [ ] 3.1 Add control-flow keyword detection (`for`/`while`/`do`/`done`/`then`/`fi`/`case`/`esac`) to `SplitCompoundCommand`. +- [ ] 3.2 Add unbalanced-quote/bracket detection (cheap structural scan; no full bash parser). +- [ ] 3.3 When detected, return empty verb-chain list. Do not attempt partial extraction. +- [ ] 3.4 Plumb a "messy" flag through to `ToolInteractionRequest` so the prompt builder can show the "complex command" hint and omit `This chat`/`Always here`/`Always anywhere` buttons. +- [ ] 3.5 Unit tests for: `for ... do ... done`; `while ... do ... done`; `case ... esac`; unbalanced quote; unbalanced bracket; well-formed commands still extract normally. + +## 4. ShellTool cwd default + +- [ ] 4.1 In `src/Netclaw.Actors/Tools/ShellTool.cs:81-82`, when `args.WorkingDirectory` is null/whitespace, resolve cwd to `WorkingContext.ProjectDirectory` if set, else `session_dir`. +- [ ] 4.2 Thread `WorkingContext` into `ShellTool` via `ToolExecutionContext` (or constructor; whichever matches existing patterns). +- [ ] 4.3 Unit tests: null arg + project_dir set → uses project_dir; null arg + project_dir null → uses session_dir; explicit arg → uses arg verbatim; assert daemon-process cwd is never the resolved value. + +## 5. Safe-verbs ∩ safe-space short-circuit + +- [ ] 5.1 Create `safe-verbs.linux.json` and `safe-verbs.windows.json` in the daemon's bundled config (alongside other shipped defaults). +- [ ] 5.2 Add a loader that reads bundled defaults and merges `~/.netclaw/config/safe-verbs..json` overrides if present. +- [ ] 5.3 Create `src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs` mirroring `ScopedFileAccessPolicy`. Inputs: candidate verb chain + cwd + `ToolExecutionContext`. Output: short-circuit decision (allow / fall-through). +- [ ] 5.4 Reuse `ToolAudienceProfileResolver` for safe-space root resolution. Personal/Team get `session_dir + project_dir`; Public gets `session_dir` only. +- [ ] 5.5 Reuse `ContainsSymlinkSegment` (or extract to a shared utility) for symlink-segment guard along the cwd path. +- [ ] 5.6 Wire the policy into `ToolAccessPolicy.CheckApprovalGate` so the safe-verb short-circuit runs before the existing approval gate. Hard-deny list (layer 1) still runs first. +- [ ] 5.7 Unit tests covering all four scenarios in the spec: safe verb + project_dir → allow; safe verb + session_dir → allow; safe verb + outside → prompt; mutating verb + safe space → prompt; Public + project_dir → prompt; symlink in cwd → prompt; user override extends defaults. + +## 6. CLI updates (list/revoke/trust-verb) + +- [ ] 6.1 Update `ApprovalsListView` JSON shape to reflect `ApprovalEntry`. +- [ ] 6.2 Update `ApprovalsCommand list` to render entries with scope labels (` in ` / ` anywhere`). +- [ ] 6.3 Update `ApprovalsCommand revoke` to accept the user-visible forms above as the pattern argument; route to `RemoveApproval` with parsed `ApprovalEntry`. +- [ ] 6.4 Add `ApprovalsCommand trust-verb [--audience] [--tool]` subcommand. Idempotent: existing `(verb, null)` entry → exit zero with "no changes". +- [ ] 6.5 Update `ApprovalsManagerPage` (TUI) to show verb + directory columns; revocation + trust-verb both reachable from the TUI. +- [ ] 6.6 Update CLI quarantine-detection note to point at `.v1.bak` (was `.invalid` for v1's malformed-file path; now also fires when v1 is detected during upgrade). +- [ ] 6.7 Tests: `list` stable ordering; `list --json` shape; `revoke` of folder-scoped and global forms; `revoke` no-match exit 1; `trust-verb` adds and is idempotent; `trust-verb` honors audience/tool flags. + +## 7. Prompt redesign (Slack) + +- [ ] 7.1 Add `ApprovalOptionKeys.ApproveEverywhere` constant ("Always anywhere"). +- [ ] 7.2 Update `SlackApprovalBlockBuilder` to render the 5-button row with `Once` / `This chat` / `Always here` / `Always anywhere` / `Deny` and apply `style: "danger"` on `Always anywhere` and `Deny`. +- [ ] 7.3 Update prompt body: header `Approve in ?` (or `Approve in ?` for single-verb), bulleted verbs, no `Patterns` / `Directory Roots` sections. +- [ ] 7.4 When the cwd is too shallow (fails minimum-depth check) or the command is "messy" (per task 3.4), omit `This chat`/`Always here`/`Always anywhere` and emit the "complex command" hint. +- [ ] 7.5 Update `SlackApprovalHandler` to map button clicks to the right persistence path: Once → no-op; This chat → session-scoped store; Always here → `(verb, cwd)` per extracted verb; Always anywhere → `(verb, null)` per extracted verb; Deny → refuse this call. +- [ ] 7.6 Update resolution message to the single-line format from the spec. +- [ ] 7.7 Snapshot tests for prompt body (single-verb + compound + messy) and resolution message (Once / This chat / Always here / Always anywhere / Deny). + +## 8. Prompt redesign (Discord) + +- [ ] 8.1 Update `DiscordApprovalPromptBuilder` to mirror Slack's 5-button row using `ButtonStyle.Danger` on `Always anywhere` and `Deny`. +- [ ] 8.2 Update prompt body to match Slack format. +- [ ] 8.3 Update Discord approval response handler to mirror Slack's mapping. +- [ ] 8.4 Update Discord resolution message to the single-line format. +- [ ] 8.5 Snapshot tests parallel to Slack. + +## 9. Agent guidance (AGENTS.md, tool description, failure path) + +- [ ] 9.1 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` with the new approval flow guidance and the schedule-creation pre-approval suggestion. Bump `metadata.version`. +- [ ] 9.2 Update AGENTS.md (and any other live identity files: `feeds/skills/.system/files/.../AGENTS.md` if present) with the load-bearing `set_working_directory` instruction. Include the consequence framing ("burns the user's attention and your token budget"). +- [ ] 9.3 Update the `set_working_directory` tool description in `src/Netclaw.Actors/Tools/SetWorkingDirectoryTool.cs` to read as "declare your project root and expand your trusted scope." Remove any `cd`-style framing. +- [ ] 9.4 Update `ShellTool` failure-result handling so when the deny reason is "cwd outside safe spaces" AND `set_working_directory` is in the audience's tool exposure list, the result includes the one-line hint pointing at `set_working_directory `. +- [ ] 9.5 Unit test the failure-path hint: emitted on cwd-outside denial; not emitted on hard-deny refusal; not emitted when `set_working_directory` is unavailable to the audience. + +## 10. Schedule-creation flow + evals + +- [ ] 10.1 Document the schedule-creation pre-approval pattern in `feeds/skills/.system/files/netclaw-operations/SKILL.md` (covered in 9.1, but cross-check phrasing covers the "ask the user, then call trust-verb" flow). +- [ ] 10.2 Add eval case (positive): session opens with a user prompt that mentions a specific repo path; assert agent calls `set_working_directory ` before issuing any shell tool call to that tree. +- [ ] 10.3 Add eval case (negative): session opens with no project signal ("what's 2+2?", "explain X"); assert agent does NOT call `set_working_directory` preemptively. +- [ ] 10.4 Add eval case (recovery): in a session where the agent is denied a shell call because cwd was outside both safe spaces, assert agent reads the failure-path hint and calls `set_working_directory ` on its next turn. +- [ ] 10.5 Add eval case (schedule pre-approval): session opens with a user request to schedule an unattended task using a specific verb (e.g. `freshdesk`); assert agent suggests global pre-approval and (on user confirmation) issues the equivalent of `netclaw approvals trust-verb freshdesk` before completing schedule setup. +- [ ] 10.6 Run the eval suite; baseline pass rate documented in PR. + +## 11. Spec sync at archive time + +- [ ] 11.1 Run `/opsx-verify` to confirm implementation matches change artifacts. +- [ ] 11.2 Run `/opsx-sync` to fold delta specs into `openspec/specs/tool-approval-gates/spec.md`, `openspec/specs/session-cwd/spec.md`, and `openspec/specs/netclaw-cli/spec.md`. +- [ ] 11.3 Run `/opsx-archive` to move the change to `openspec/changes/archive/`. + +## Acceptance gates (across all sections) + +- [ ] All unit + integration + snapshot tests green. +- [ ] `dotnet slopwatch analyze` reports no new violations. +- [ ] `./scripts/Add-FileHeaders.ps1 -Verify` passes. +- [ ] Eval suite passes (positive + negative + recovery + schedule-preapproval cases). +- [ ] Manual Slack flow: compound command outside safe space → 5-button prompt → click `Always anywhere` → resolution shows "Saved: ... anywhere" → `tool-approvals.json` contains `(verb, null)` entries. +- [ ] Manual Discord flow: same as Slack with `ButtonStyle.Danger` rendering correctly. +- [ ] Manual: `netclaw approvals trust-verb freshdesk` writes the right entry; `list` labels it `freshdesk anywhere`; `revoke "freshdesk anywhere"` removes it. +- [ ] Manual: legacy v1 `tool-approvals.json` quarantines to `.v1.bak` on first read; CLI surfaces the quarantine note. From d82cf4fdc0facd4a70b56a5543a8cb56482b3365 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 16:28:10 +0000 Subject: [PATCH 02/46] feat(approvals): add ApprovalEntry foundation type for v2 store Foundation for the approval-policy-v2 storage refactor. Adds: - ApprovalEntry record (Verb required, Directory nullable for global wildcard) - ToolApprovalEntryComparer.Equals(ApprovalEntry, ApprovalEntry) overload that delegates to the existing platform-correct string comparison No behavior change: ToolApprovalStore still operates on the v1 string-based API and the existing test suite (274 tests) passes unchanged. The actual storage cutover, matcher refactor, and caller updates land in subsequent commits per openspec/changes/approval-policy-v2/tasks.md sections 1-6. --- src/Netclaw.Configuration/ApprovalEntry.cs | 38 +++++++++++++++++++ .../ToolApprovalEntryComparer.cs | 11 +++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/Netclaw.Configuration/ApprovalEntry.cs diff --git a/src/Netclaw.Configuration/ApprovalEntry.cs b/src/Netclaw.Configuration/ApprovalEntry.cs new file mode 100644 index 00000000..1a3e6a0b --- /dev/null +++ b/src/Netclaw.Configuration/ApprovalEntry.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json.Serialization; + +namespace Netclaw.Configuration; + +/// +/// One persisted tool-approval grant: a verb chain paired with a directory +/// scope. is null for the global wildcard +/// ("approve this verb in any directory"); otherwise it is an absolute path +/// and the entry only matches invocations whose cwd is under that path. +/// +/// This record replaces the v1 flat string list in +/// tool-approvals.json. v1 entries (verbs, normalized commands, +/// directory roots, and bash fragments mingled in one list) are not +/// migrated — see . +/// +public sealed record ApprovalEntry +{ + /// + /// The verb chain (e.g. git remote, freshdesk). For + /// shell_execute this is the prefix of non-flag tokens extracted + /// from a command; for other tools it is the tool name. + /// + [JsonPropertyName("verb")] + public required string Verb { get; init; } + + /// + /// Absolute directory path the grant is scoped to, or null for + /// the global wildcard. Trailing slashes are normalized away by the + /// matcher so /path/ and /path compare equal. + /// + [JsonPropertyName("directory")] + public string? Directory { get; init; } +} diff --git a/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs b/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs index 74796285..b61f483e 100644 --- a/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs +++ b/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs @@ -33,8 +33,17 @@ public static class ToolApprovalEntryComparer OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; /// - /// Equality predicate matching the daemon's approval matcher. + /// Equality predicate for raw strings (verbs or directories). /// public static bool Equals(string? left, string? right) => string.Equals(left, right, Comparison); + + /// + /// Equality predicate matching the daemon's approval matcher: two + /// entries are equal when their verbs match and their directories + /// match (with both null directories considered equal — the + /// global wildcard). + /// + public static bool Equals(ApprovalEntry left, ApprovalEntry right) + => Equals(left.Verb, right.Verb) && Equals(left.Directory, right.Directory); } From 3885f04a985ece5b9d410bcb6d1a3db730cf2ae1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 17:07:34 +0000 Subject: [PATCH 03/46] feat(approvals): cut over tool-approvals storage to v2 schema Section 1 of the approval-policy-v2 OpenSpec change. Refactors ToolApprovalStore to a typed (verb, directory) ApprovalEntry model with a versioned on-disk schema, replacing the v1 flat string list. What changed: - ToolApprovalStore now serializes/deserializes ToolApprovalData with "version": 2 and List per (audience, tool). - Two-step Load(): peek schema version via JsonDocument; quarantine legacy v1 files to tool-approvals.json.v1.bak; quarantine unparseable files to .invalid; in either case, return an empty store. - AddApproval/RemoveApproval/RemoveAllForTool/Snapshot operate on ApprovalEntry. New GetApprovedEntries replaces GetApprovedPatterns. - AddApproval normalizes the directory portion (trims trailing separators while preserving "/" and "C:\") so the on-disk file does not accumulate trailing-slash variants of the same logical entry. - ToolApprovalEntryComparer gains NormalizeDirectory + Normalize(entry) helpers; Equals(ApprovalEntry, ApprovalEntry) normalizes both sides. Caller updates required to compile: - ToolApprovalActor: persistent writes wrap incoming verb strings as ApprovalEntry { Verb=pattern, Directory=null } (interim semantic preserved until section 2 lands the directory-aware matcher). - ApprovalsListView/ApprovalsCommand: list output renders entries as " in " or " anywhere"; --json emits the typed ApprovalEntry shape; --json uses IndentedOmitNull so the CLI shape matches the file shape (nulls omitted). - ApprovalsCommand.WarnIfQuarantined surfaces both .v1.bak and .invalid quarantine paths with distinct remediation guidance. - ApprovalsManagerViewModel/Page: rendering uses entry.DisplayText. - ToolAudienceProfilesDoctorCheck: drops the v1 stale-path-aware pattern detection (irrelevant under v2; v1 contents quarantine on first read). Tests: - ToolApprovalStoreTests rewritten for the v2 API and gain coverage for v1 quarantine, malformed quarantine, fresh-write-after-quarantine, trailing-slash normalization, and idempotent add. - ApprovalsCommand/ApprovalsManagerPage tests rewritten to use ApprovalEntry and the new " in " / " anywhere" rendering. - Stale-pattern doctor test removed. All 3348 tests pass; dotnet slopwatch analyze reports no new violations; file-header verification passes. --- openspec/changes/approval-policy-v2/tasks.md | 14 +- src/Netclaw.Actors/Tools/ToolApprovalActor.cs | 21 +- .../Approvals/ApprovalsCommandTests.cs | 78 ++++--- .../ToolAudienceProfilesDoctorCheckTests.cs | 47 ---- .../Tui/ApprovalsManagerPageTests.cs | 44 ++-- src/Netclaw.Cli/Approvals/ApprovalsCommand.cs | 77 +++++-- .../Approvals/ApprovalsListView.cs | 8 +- .../Doctor/ToolAudienceProfilesDoctorCheck.cs | 18 +- src/Netclaw.Cli/Json/JsonDefaults.cs | 12 + src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs | 6 +- .../Tui/ApprovalsManagerViewModel.cs | 24 +- .../ToolApprovalStoreTests.cs | 197 +++++++++++++--- .../ToolApprovalEntryComparer.cs | 64 +++++- .../ToolApprovalStore.cs | 212 +++++++++++++----- 14 files changed, 576 insertions(+), 246 deletions(-) diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index 141ac001..3b540f9d 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -9,13 +9,13 @@ Both PRs sit under this single OpenSpec change. ## 1. Storage schema v2 + quarantine -- [ ] 1.1 Add `ApprovalEntry` record (`Verb` required, `Directory` nullable) to `src/Netclaw.Configuration/`. -- [ ] 1.2 Update `ToolApprovalData` to `Version` (int, default 2) + `Dictionary>>` shape. -- [ ] 1.3 Update `ToolApprovalStore.Load()` to detect `Version != 2` (or absent), move file to `tool-approvals.json.v1.bak`, and return empty v2 store. -- [ ] 1.4 Update `ToolApprovalStore.Save()` to always emit `version: 2`. -- [ ] 1.5 Update `AddApproval` / `RemoveApproval` / `RemoveAllForTool` / `Snapshot` to operate on `ApprovalEntry`. -- [ ] 1.6 Update `ToolApprovalEntryComparer` to compare `(Verb, Directory)` tuples (Ordinal on POSIX, OrdinalIgnoreCase on Windows; null directory compares equal to null directory). -- [ ] 1.7 Unit tests for v1 quarantine on first read; round-trip serialization of folder-scoped and global-wildcard entries; comparer on POSIX vs Windows. +- [x] 1.1 Add `ApprovalEntry` record (`Verb` required, `Directory` nullable) to `src/Netclaw.Configuration/`. +- [x] 1.2 Update `ToolApprovalData` to `Version` (int, default 2) + `Dictionary>>` shape. +- [x] 1.3 Update `ToolApprovalStore.Load()` to detect `Version != 2` (or absent), move file to `tool-approvals.json.v1.bak`, and return empty v2 store. +- [x] 1.4 Update `ToolApprovalStore.Save()` to always emit `version: 2`. +- [x] 1.5 Update `AddApproval` / `RemoveApproval` / `RemoveAllForTool` / `Snapshot` to operate on `ApprovalEntry`. +- [x] 1.6 Update `ToolApprovalEntryComparer` to compare `(Verb, Directory)` tuples (Ordinal on POSIX, OrdinalIgnoreCase on Windows; null directory compares equal to null directory). +- [x] 1.7 Unit tests for v1 quarantine on first read; round-trip serialization of folder-scoped and global-wildcard entries; comparer on POSIX vs Windows. ## 2. Matcher operates on ApprovalEntry diff --git a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs index 1e401558..e0969105 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs @@ -40,7 +40,17 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) AddSessionApproval(msg.SessionId, msg.Audience, msg.ToolName, pattern); if (msg.Persistent) - _persistentStore?.AddApproval(msg.Audience, msg.ToolName.Value, pattern); + { + // Until section 2 lands the v2 directory-aware matcher, the + // runtime treats every persisted grant as a global wildcard: + // verb-only, directory null. The new prompt UX in section 7 + // will route folder-scoped clicks through a different add + // path that supplies a concrete directory. + _persistentStore?.AddApproval( + msg.Audience, + msg.ToolName.Value, + new ApprovalEntry { Verb = pattern, Directory = null }); + } } Sender.Tell(ToolApprovalRecorded.Instance); @@ -58,7 +68,14 @@ private bool IsApproved(SessionId? sessionId, TrustAudience audience, ToolName t if (_persistentStore is null) return false; - return MatchesApprovedEntry(toolName, pattern, _persistentStore.GetApprovedPatterns(audience, toolName.Value)); + // Section 1 interim: surface verb chains from the typed store so the + // existing string-based matcher keeps working. Section 2 swaps this + // for a directory-aware matcher that consumes ApprovalEntry directly + // and consults the candidate's cwd from ToolExecutionContext. + var persistedVerbs = _persistentStore + .GetApprovedEntries(audience, toolName.Value) + .Select(e => e.Verb); + return MatchesApprovedEntry(toolName, pattern, persistedVerbs); } private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, ToolName toolName, string pattern) diff --git a/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs b/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs index b38f7e82..5ba97c59 100644 --- a/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs @@ -31,13 +31,16 @@ public void Dispose() _dir.Dispose(); } + private static ApprovalEntry Verb(string verb) => new() { Verb = verb, Directory = null }; + private static ApprovalEntry InDir(string verb, string dir) => new() { Verb = verb, Directory = dir }; + private void SeedDefault() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "/home/user/logs/"); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "npm install"); - _store.AddApproval(TrustAudience.Personal, "file_write", "/tmp/scratch/"); - _store.AddApproval(TrustAudience.Public, "shell_execute", "ls"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("npm install")); + _store.AddApproval(TrustAudience.Personal, "file_write", InDir("file_write", "/tmp/scratch")); + _store.AddApproval(TrustAudience.Public, "shell_execute", Verb("ls")); } [Fact] @@ -61,12 +64,12 @@ public async Task List_with_entries_groups_by_audience_and_tool() Assert.Contains("personal / shell_execute", text); Assert.Contains("personal / file_write", text); Assert.Contains("public / shell_execute", text); - Assert.Contains("git push", text); - Assert.Contains("/tmp/scratch/", text); + Assert.Contains("git push anywhere", text); + Assert.Contains("grep in /home/user/logs", text); } [Fact] - public async Task List_json_emits_audience_tool_pattern_shape() + public async Task List_json_emits_typed_entry_shape() { SeedDefault(); @@ -75,10 +78,17 @@ public async Task List_json_emits_audience_tool_pattern_shape() using var doc = JsonDocument.Parse(_output.ToString()); var audiences = doc.RootElement.GetProperty("audiences"); var personalShell = audiences.GetProperty("personal").GetProperty("shell_execute"); - var patterns = personalShell.EnumerateArray().Select(e => e.GetString()).ToList(); - Assert.Contains("git push", patterns); - Assert.Contains("/home/user/logs/", patterns); - Assert.Contains("npm install", patterns); + var verbs = personalShell.EnumerateArray().Select(e => e.GetProperty("verb").GetString()).ToList(); + Assert.Contains("git push", verbs); + Assert.Contains("grep", verbs); + Assert.Contains("npm install", verbs); + + // The grep entry should carry its directory; "git push" should not. + var grep = personalShell.EnumerateArray().Single(e => e.GetProperty("verb").GetString() == "grep"); + Assert.Equal("/home/user/logs", grep.GetProperty("directory").GetString()); + + var gitPush = personalShell.EnumerateArray().Single(e => e.GetProperty("verb").GetString() == "git push"); + Assert.False(gitPush.TryGetProperty("directory", out _)); } [Fact] @@ -97,31 +107,32 @@ await ApprovalsCommand.RunAsync( } [Fact] - public async Task Revoke_exact_match_removes_entry_and_returns_zero() + public async Task Revoke_global_wildcard_by_anywhere_form_removes_entry() { SeedDefault(); var exit = await ApprovalsCommand.RunAsync( - ["approvals", "revoke", "git push", "--audience", "personal", "--tool", "shell_execute"], + ["approvals", "revoke", "git push anywhere", "--audience", "personal", "--tool", "shell_execute"], _paths, _output); Assert.Equal(0, exit); - Assert.DoesNotContain("git push", _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Contains("Removed 'git push'", _output.ToString()); + var remaining = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.DoesNotContain(remaining, e => e.Verb == "git push" && e.Directory is null); + Assert.Contains("Removed 'git push anywhere'", _output.ToString()); } [Fact] public async Task Revoke_no_match_exits_one_and_does_not_modify_file() { SeedDefault(); - var beforeCount = _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute").Count; + var beforeCount = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute").Count; var exit = await ApprovalsCommand.RunAsync( - ["approvals", "revoke", "git pull", "--audience", "personal", "--tool", "shell_execute"], + ["approvals", "revoke", "git pull anywhere", "--audience", "personal", "--tool", "shell_execute"], _paths, _output); Assert.Equal(1, exit); - Assert.Equal(beforeCount, _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute").Count); + Assert.Equal(beforeCount, _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute").Count); Assert.Contains("No matching approval found.", _output.ToString()); } @@ -135,9 +146,9 @@ public async Task Revoke_tool_all_clears_every_entry_for_tool() _paths, _output); Assert.Equal(0, exit); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Public, "shell_execute")); - Assert.Single(_store.GetApprovedPatterns(TrustAudience.Personal, "file_write")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Public, "shell_execute")); + Assert.Single(_store.GetApprovedEntries(TrustAudience.Personal, "file_write")); } [Fact] @@ -150,22 +161,25 @@ public async Task Revoke_tool_all_scoped_by_audience_leaves_others_alone() _paths, _output); Assert.Equal(0, exit); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Equal(["ls"], _store.GetApprovedPatterns(TrustAudience.Public, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + + var publicShell = _store.GetApprovedEntries(TrustAudience.Public, "shell_execute"); + Assert.Single(publicShell); + Assert.Equal("ls", publicShell[0].Verb); } [Fact] public async Task Revoke_all_without_tool_exits_one_and_does_not_modify_file() { SeedDefault(); - var beforeCount = _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute").Count; + var beforeCount = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute").Count; var exit = await ApprovalsCommand.RunAsync( ["approvals", "revoke", "--all"], _paths, _output); Assert.Equal(1, exit); - Assert.Equal(beforeCount, _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute").Count); + Assert.Equal(beforeCount, _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute").Count); Assert.Contains("--all requires --tool", _output.ToString()); } @@ -216,16 +230,16 @@ public async Task Help_subcommand_exits_zero_and_prints_usage() [Fact] public async Task Revoke_unscoped_removes_match_across_audiences() { - // Same pattern stored under two audiences; unscoped revoke should hit both. - _store.AddApproval(TrustAudience.Personal, "shell_execute", "ls"); - _store.AddApproval(TrustAudience.Public, "shell_execute", "ls"); + // Same global wildcard stored under two audiences; unscoped revoke should hit both. + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("ls")); + _store.AddApproval(TrustAudience.Public, "shell_execute", Verb("ls")); var exit = await ApprovalsCommand.RunAsync( - ["approvals", "revoke", "ls"], + ["approvals", "revoke", "ls anywhere"], _paths, _output); Assert.Equal(0, exit); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Public, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Public, "shell_execute")); } } diff --git a/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs index a231d36c..c1907018 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ToolAudienceProfilesDoctorCheckTests.cs @@ -446,53 +446,6 @@ public async Task MissingApprovalWarning_IsWarningSeverityNotError() Assert.Contains("approval default on Personal", result.Message); } - [Fact] - public async Task StalePathAwareShellApprovals_WarnWithResetInstructions() - { - WriteConfig( - """ - { - "configVersion": 1, - "Tools": { - "ShellMode": "HostAllowed", - "AudienceProfiles": { - "Personal": { - "ToolsMode": "All", - "McpServersMode": "All", - "ApprovalPolicy": { - "ToolOverrides": { "shell_execute": "Approval" } - }, - "ReadFiles": { "Mode": "All" }, - "WriteFiles": { "Mode": "All" }, - "AttachFiles": { "Mode": "All" } - } - } - } - } - """); - - File.WriteAllText( - _paths.ToolApprovalsPath, - """ - { - "audiences": { - "personal": { - "shell_execute": ["cat", "git push"] - } - } - } - """); - - var check = new ToolAudienceProfilesDoctorCheck(_paths); - var result = await check.RunAsync(TestContext.Current.CancellationToken); - - Assert.Equal(DoctorSeverity.Warning, result.Severity); - Assert.Contains("bare path-aware command patterns", result.Message); - Assert.Contains("cat", result.Message); - Assert.Contains("Delete", result.Message); - Assert.Contains(_paths.ToolApprovalsPath, result.Message); - } - private void WriteConfig(object config) { File.WriteAllText( diff --git a/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs index 9a04ed0d..77b5777e 100644 --- a/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ApprovalsManagerPageTests.cs @@ -51,12 +51,15 @@ public async Task EmptyStore_RendersEmptyMessageInTerminal() $"Expected empty-state message. Screen:\n{terminal}"); } + private static ApprovalEntry Verb(string verb) => new() { Verb = verb, Directory = null }; + private static ApprovalEntry InDir(string verb, string dir) => new() { Verb = verb, Directory = dir }; + [Fact] public async Task SeededEntries_RenderedInList() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "file_write", "/tmp/scratch/"); - _store.AddApproval(TrustAudience.Public, "shell_execute", "ls"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "file_write", InDir("file_write", "/tmp/scratch")); + _store.AddApproval(TrustAudience.Public, "shell_execute", Verb("ls")); var (terminal, app, _) = CreateHeadlessApp(out var input); input.EnqueueKey(ConsoleKey.Q, false, false, true); @@ -66,24 +69,24 @@ public async Task SeededEntries_RenderedInList() Assert.True(terminal.Contains("personal"), $"Expected audience 'personal'. Screen:\n{terminal}"); - Assert.True(terminal.Contains("git push"), - $"Expected pattern 'git push'. Screen:\n{terminal}"); - Assert.True(terminal.Contains("/tmp/scratch/"), - $"Expected pattern '/tmp/scratch/'. Screen:\n{terminal}"); - Assert.True(terminal.Contains("ls"), - $"Expected pattern 'ls' (public audience). Screen:\n{terminal}"); + Assert.True(terminal.Contains("git push anywhere"), + $"Expected entry 'git push anywhere'. Screen:\n{terminal}"); + Assert.True(terminal.Contains("/tmp/scratch"), + $"Expected directory '/tmp/scratch'. Screen:\n{terminal}"); + Assert.True(terminal.Contains("ls anywhere"), + $"Expected entry 'ls anywhere' (public audience). Screen:\n{terminal}"); } [Fact] public async Task PressingR_OnSelection_TransitionsToConfirmAndRevokesOnEnter() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "npm install"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("npm install")); var (_, app, vm) = CreateHeadlessApp(out var input); - // First displayed entry is sorted alphabetically by audience+tool+pattern, - // so the selection at index 0 is "personal / shell_execute / git push". + // First displayed entry is sorted alphabetically by audience+tool+verb, + // so the selection at index 0 is "personal / shell_execute / git push anywhere". input.EnqueueKey(ConsoleKey.R); // Open revoke confirm. input.EnqueueKey(ConsoleKey.Enter); // Confirm "Yes, revoke". input.EnqueueKey(ConsoleKey.Q, false, false, true); @@ -91,16 +94,16 @@ public async Task PressingR_OnSelection_TransitionsToConfirmAndRevokesOnEnter() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); - var remaining = _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute"); - Assert.DoesNotContain("git push", remaining); - Assert.Contains("npm install", remaining); + var remaining = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.DoesNotContain(remaining, e => e.Verb == "git push"); + Assert.Contains(remaining, e => e.Verb == "npm install"); Assert.Equal(ApprovalsManagerState.List, vm.CurrentState.Value); } [Fact] public async Task EscOnConfirm_CancelsRevoke() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); var (_, app, _) = CreateHeadlessApp(out var input); @@ -111,14 +114,15 @@ public async Task EscOnConfirm_CancelsRevoke() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); - Assert.Equal(["git push"], - _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); + var entries = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(entries); + Assert.Equal("git push", entries[0].Verb); } [Fact] public async Task RevokingLastEntry_TransitionsListIntoEmptyState() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); var (terminal, app, vm) = CreateHeadlessApp(out var input); diff --git a/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs b/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs index 891ea5ef..1b0e45bd 100644 --- a/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs +++ b/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs @@ -51,7 +51,7 @@ private static int RunList(string[] args, NetclawPaths paths, TextWriter writer) if (opts.EmitJson) { - writer.WriteLine(JsonSerializer.Serialize(view, JsonDefaults.Indented)); + writer.WriteLine(JsonSerializer.Serialize(view, JsonDefaults.IndentedOmitNull)); return 0; } @@ -64,12 +64,12 @@ private static int RunList(string[] args, NetclawPaths paths, TextWriter writer) var first = true; foreach (var (audienceKey, tools) in view.Audiences) { - foreach (var (toolName, patterns) in tools) + foreach (var (toolName, entries) in tools) { if (!first) writer.WriteLine(); writer.WriteLine($"{audienceKey} / {toolName}"); - foreach (var pattern in patterns) - writer.WriteLine($" {pattern}"); + foreach (var entry in entries) + writer.WriteLine($" {FormatEntryForList(entry)}"); first = false; } } @@ -77,6 +77,17 @@ private static int RunList(string[] args, NetclawPaths paths, TextWriter writer) return 0; } + /// + /// Renders an as the user-visible scope label: + /// <verb> in <dir> for folder-scoped grants, or + /// <verb> anywhere for the global wildcard. Section 6 will + /// extend this to also accept the same forms as inputs to revoke. + /// + internal static string FormatEntryForList(ApprovalEntry entry) + => entry.Directory is null + ? $"{entry.Verb} anywhere" + : $"{entry.Verb} in {entry.Directory}"; + private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter writer) { if (TryParseRevokeFlags(args, writer) is not { } opts) @@ -102,6 +113,12 @@ private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter write return 1; } + // Section 1 interim: revoke parses the legacy single-string form as a + // verb-only global-wildcard lookup so the command compiles and the + // existing test corpus continues to round-trip. Section 6 replaces + // this with a parser for the user-visible forms ("verb in /dir/" and + // "verb anywhere") emitted by `list` above. + var lookup = ParseRevokePatternInterim(opts.Pattern); var snapshot = store.Snapshot(); var removedAny = false; @@ -117,7 +134,7 @@ private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter write if (opts.Tool is not null && !string.Equals(toolName, opts.Tool, StringComparison.Ordinal)) continue; - if (store.RemoveApproval(audience, toolName, opts.Pattern)) + if (store.RemoveApproval(audience, toolName, lookup)) { writer.WriteLine($"Removed '{opts.Pattern}' from {audienceKey} / {toolName}."); removedAny = true; @@ -134,6 +151,22 @@ private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter write return 0; } + private static ApprovalEntry ParseRevokePatternInterim(string pattern) + { + // Accepts the global-wildcard form " anywhere" emitted by the + // section 1 list rendering, and falls back to treating the entire + // pattern as a verb (directory: null). Folder-scoped revoke parsing + // (" in ") lands in section 6. + const string Suffix = " anywhere"; + if (pattern.EndsWith(Suffix, StringComparison.Ordinal)) + { + var verb = pattern[..^Suffix.Length].TrimEnd(); + if (verb.Length > 0) + return new ApprovalEntry { Verb = verb, Directory = null }; + } + return new ApprovalEntry { Verb = pattern, Directory = null }; + } + private static int RunRevokeAll(RevokeOptions opts, ToolApprovalStore store, TextWriter writer) { IEnumerable audiences = opts.Audience is { } only ? [only] : TrustAudiences.All; @@ -285,7 +318,7 @@ private static FlagOutcome TryConsumeSharedFlag( } private static ApprovalsListView BuildView( - IReadOnlyDictionary>> snapshot, + IReadOnlyDictionary>> snapshot, TrustAudience? audienceFilter, string? toolFilter) { @@ -299,13 +332,17 @@ private static ApprovalsListView BuildView( if (parsed != audienceFilter.Value) continue; } - var filteredTools = new SortedDictionary>(StringComparer.Ordinal); - foreach (var (toolName, patterns) in tools) + var filteredTools = new SortedDictionary>(StringComparer.Ordinal); + foreach (var (toolName, entries) in tools) { if (toolFilter is not null && !string.Equals(toolName, toolFilter, StringComparison.Ordinal)) continue; - if (patterns.Count == 0) continue; - filteredTools[toolName] = [.. patterns.OrderBy(p => p, StringComparer.Ordinal)]; + if (entries.Count == 0) continue; + filteredTools[toolName] = + [ + .. entries.OrderBy(static e => e.Verb, StringComparer.Ordinal) + .ThenBy(static e => e.Directory ?? string.Empty, StringComparer.Ordinal) + ]; } if (filteredTools.Count > 0) @@ -317,11 +354,21 @@ private static ApprovalsListView BuildView( private static void WarnIfQuarantined(ToolApprovalStore store, TextWriter writer) { - if (!File.Exists(store.QuarantinePath)) - return; + // Two quarantine paths exist after the v2 cutover: + // - .v1.bak : legacy v1 file detected and moved aside on upgrade + // - .invalid : malformed (unparseable) file moved aside as fail-closed + // Operators see different remediation guidance for each. + if (File.Exists(store.V1QuarantinePath)) + { + writer.WriteLine($"Note: Your previous approvals were quarantined to '{store.V1QuarantinePath}' during the v2 schema upgrade."); + writer.WriteLine(" Inspect or restore manually if needed; the daemon started with an empty v2 store."); + } - writer.WriteLine($"Warning: A quarantined approvals file exists at '{store.QuarantinePath}'."); - writer.WriteLine(" The active file was reset to empty after a parse failure."); - writer.WriteLine(" Inspect the .invalid copy before restoring grants."); + if (File.Exists(store.MalformedQuarantinePath)) + { + writer.WriteLine($"Warning: A malformed approvals file was quarantined to '{store.MalformedQuarantinePath}'."); + writer.WriteLine(" The active file was reset to empty after a parse failure."); + writer.WriteLine(" Inspect the .invalid copy before restoring grants."); + } } } diff --git a/src/Netclaw.Cli/Approvals/ApprovalsListView.cs b/src/Netclaw.Cli/Approvals/ApprovalsListView.cs index 1708b2da..f2023868 100644 --- a/src/Netclaw.Cli/Approvals/ApprovalsListView.cs +++ b/src/Netclaw.Cli/Approvals/ApprovalsListView.cs @@ -4,17 +4,19 @@ // // ----------------------------------------------------------------------- using System.Text.Json.Serialization; +using Netclaw.Configuration; namespace Netclaw.Cli.Approvals; /// /// Stable JSON output shape for netclaw approvals list --json. Uses the -/// same audience/tool/patterns layout as tool-approvals.json so scripts -/// can reuse parsers across both surfaces. +/// same audience/tool/entries layout as tool-approvals.json so scripts +/// can reuse parsers across both surfaces. Entries preserve the typed +/// shape (verb + nullable directory). /// internal sealed class ApprovalsListView { [JsonPropertyName("audiences")] - public SortedDictionary>> Audiences { get; } + public SortedDictionary>> Audiences { get; } = new(StringComparer.Ordinal); } diff --git a/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs index 861eca11..88617e86 100644 --- a/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ToolAudienceProfilesDoctorCheck.cs @@ -315,7 +315,9 @@ private static void CheckStaleApprovals(ToolConfig toolConfig, NetclawPaths netc foreach (var (audienceKey, tools) in data.Audiences) { - if (!tools.TryGetValue(ShellTool.ToolName, out var patterns)) + if (!tools.TryGetValue(ShellTool.ToolName, out var entries)) + continue; + if (entries.Count == 0) continue; if (toolConfig.ShellMode == ShellExecutionMode.Off) @@ -324,20 +326,6 @@ private static void CheckStaleApprovals(ToolConfig toolConfig, NetclawPaths netc $"Persistent approvals exist for {audienceKey}.{ShellTool.ToolName} " + "but shell is disabled."); } - - var stalePathAwarePatterns = patterns - .Where(ShellTokenizer.IsSingleTokenPathAwarePattern) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (stalePathAwarePatterns.Length == 0) - continue; - - warnings.Add( - $"Persistent shell approvals for audience '{audienceKey}' contain bare path-aware command patterns " + - $"({string.Join(", ", stalePathAwarePatterns)}). These no longer pre-approve path-targeting shell commands. " + - $"Delete '{approvalsPath}' and restart the daemon to rebuild approvals under the stricter matcher."); } } catch (Exception ex) diff --git a/src/Netclaw.Cli/Json/JsonDefaults.cs b/src/Netclaw.Cli/Json/JsonDefaults.cs index e76a1bfd..20f0ab13 100644 --- a/src/Netclaw.Cli/Json/JsonDefaults.cs +++ b/src/Netclaw.Cli/Json/JsonDefaults.cs @@ -39,6 +39,18 @@ internal static class JsonDefaults WriteIndented = true, }; + /// + /// Pretty-printed terminal output with nulls omitted. Used by surfaces + /// (e.g. netclaw approvals list --json) that round-trip nullable + /// fields to a file format that also omits nulls, so the CLI output and + /// the on-disk file have a matching shape. + /// + internal static readonly JsonSerializerOptions IndentedOmitNull = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + /// /// Config and secrets file serialization: pretty-printed with enum values as strings. /// diff --git a/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs b/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs index d65de80d..ff59d123 100644 --- a/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ApprovalsManagerPage.cs @@ -127,7 +127,7 @@ private ILayoutNode BuildEmptyView() private ILayoutNode BuildListView() { var rows = ViewModel.DisplayApprovals - .Select(item => $"{item.AudienceWire,-10} {item.ToolName,-20} {item.Pattern}") + .Select(item => $"{item.AudienceWire,-10} {item.ToolName,-20} {item.DisplayText}") .ToList(); _approvalList = Layouts.SelectionList(rows) @@ -149,7 +149,7 @@ private ILayoutNode BuildListView() .DisposeWith(_stepSubs); return Layouts.Vertical() - .WithChild(new TextNode($" {"Audience",-10} {"Tool",-20} Pattern") + .WithChild(new TextNode($" {"Audience",-10} {"Tool",-20} Approval") .WithForeground(Color.White).Bold()) .WithChild(_approvalList); } @@ -159,7 +159,7 @@ private ILayoutNode BuildRevokeConfirmView() var target = ViewModel.PendingRevoke; var summary = target is null ? " Revoke entry?" - : $" Revoke '{target.Pattern}' from {target.AudienceWire} / {target.ToolName}?"; + : $" Revoke '{target.DisplayText}' from {target.AudienceWire} / {target.ToolName}?"; var items = new List { "Yes, revoke", "No, cancel" }; _confirmList = Layouts.SelectionList(items) diff --git a/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs b/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs index a5081d2f..0d93a04e 100644 --- a/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ApprovalsManagerViewModel.cs @@ -21,7 +21,17 @@ public sealed record ApprovalDisplayItem( TrustAudience Audience, string AudienceWire, string ToolName, - string Pattern); + ApprovalEntry Entry) +{ + /// + /// User-visible scope label for the entry: <verb> in <dir> + /// for folder-scoped grants, <verb> anywhere for the global + /// wildcard. + /// + public string DisplayText => Entry.Directory is null + ? $"{Entry.Verb} anywhere" + : $"{Entry.Verb} in {Entry.Directory}"; +} /// /// ViewModel for the netclaw approvals interactive TUI. The page is @@ -66,8 +76,12 @@ public void Refresh() var tools = snapshot[audienceKey]; foreach (var toolName in tools.Keys.OrderBy(k => k, StringComparer.Ordinal)) { - foreach (var pattern in tools[toolName].OrderBy(p => p, StringComparer.Ordinal)) - DisplayApprovals.Add(new ApprovalDisplayItem(audience, audienceKey, toolName, pattern)); + foreach (var entry in tools[toolName] + .OrderBy(static e => e.Verb, StringComparer.Ordinal) + .ThenBy(static e => e.Directory ?? string.Empty, StringComparer.Ordinal)) + { + DisplayApprovals.Add(new ApprovalDisplayItem(audience, audienceKey, toolName, entry)); + } } } @@ -99,9 +113,9 @@ public void ConfirmRevoke() return; } - var removed = _store.RemoveApproval(target.Audience, target.ToolName, target.Pattern); + var removed = _store.RemoveApproval(target.Audience, target.ToolName, target.Entry); StatusMessage.Value = removed - ? $"✔ Removed '{target.Pattern}' from {target.AudienceWire} / {target.ToolName}." + ? $"✔ Removed '{target.DisplayText}' from {target.AudienceWire} / {target.ToolName}." : $"⚠ Entry not found (may have been removed elsewhere)."; PendingRevoke = null; diff --git a/src/Netclaw.Configuration.Tests/ToolApprovalStoreTests.cs b/src/Netclaw.Configuration.Tests/ToolApprovalStoreTests.cs index aff61912..20aea0dd 100644 --- a/src/Netclaw.Configuration.Tests/ToolApprovalStoreTests.cs +++ b/src/Netclaw.Configuration.Tests/ToolApprovalStoreTests.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using Netclaw.Configuration; using Xunit; namespace Netclaw.Configuration.Tests; @@ -22,60 +21,65 @@ public ToolApprovalStoreTests() public void Dispose() { if (File.Exists(_file)) File.Delete(_file); - var invalid = _file + ".invalid"; - if (File.Exists(invalid)) File.Delete(invalid); + if (File.Exists(_store.MalformedQuarantinePath)) File.Delete(_store.MalformedQuarantinePath); + if (File.Exists(_store.V1QuarantinePath)) File.Delete(_store.V1QuarantinePath); } + private static ApprovalEntry Verb(string verb) => new() { Verb = verb, Directory = null }; + private static ApprovalEntry InDir(string verb, string dir) => new() { Verb = verb, Directory = dir }; + [Fact] public void RemoveApproval_returns_false_when_file_is_empty() { - Assert.False(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", "git push")); + Assert.False(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("git push"))); } [Fact] public void RemoveApproval_removes_exact_match_and_returns_true() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "/home/user/logs/"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs/")); - Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", "git push")); + Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("git push"))); - var remaining = _store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute"); - Assert.Equal(["/home/user/logs/"], remaining); + var remaining = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(remaining); + Assert.Equal("grep", remaining[0].Verb); + Assert.Equal("/home/user/logs", remaining[0].Directory); } [Fact] - public void RemoveApproval_returns_false_for_unknown_pattern() + public void RemoveApproval_returns_false_for_unknown_entry() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - Assert.False(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", "git pull")); - Assert.Single(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + Assert.False(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("git pull"))); + Assert.Single(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); } [Fact] public void RemoveApproval_uses_platform_case_sensitivity() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); - var caseDifferent = _store.RemoveApproval(TrustAudience.Personal, "shell_execute", "GIT PUSH"); + var caseDifferent = _store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("GIT PUSH")); if (OperatingSystem.IsWindows()) { Assert.True(caseDifferent); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); } else { Assert.False(caseDifferent); - Assert.Single(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); + Assert.Single(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); } } [Fact] public void RemoveApproval_prunes_empty_tool_and_audience_sections() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", "git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("git push"))); var snapshot = _store.Snapshot(); Assert.Empty(snapshot); @@ -84,29 +88,35 @@ public void RemoveApproval_prunes_empty_tool_and_audience_sections() [Fact] public void RemoveApproval_does_not_disturb_other_audiences_or_tools() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Public, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "file_write", "/tmp/scratch/"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Public, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "file_write", InDir("file_write", "/tmp/scratch/")); + + Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", Verb("git push"))); - Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", "git push")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Equal(["git push"], _store.GetApprovedPatterns(TrustAudience.Public, "shell_execute")); - Assert.Equal(["/tmp/scratch/"], _store.GetApprovedPatterns(TrustAudience.Personal, "file_write")); + var publicShell = _store.GetApprovedEntries(TrustAudience.Public, "shell_execute"); + Assert.Single(publicShell); + Assert.Equal("git push", publicShell[0].Verb); + + var personalFileWrite = _store.GetApprovedEntries(TrustAudience.Personal, "file_write"); + Assert.Single(personalFileWrite); + Assert.Equal("/tmp/scratch", personalFileWrite[0].Directory); } [Fact] public void RemoveAllForTool_clears_every_entry_and_returns_count() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "/home/user/logs/"); - _store.AddApproval(TrustAudience.Personal, "file_write", "/tmp/scratch/"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs/")); + _store.AddApproval(TrustAudience.Personal, "file_write", InDir("file_write", "/tmp/scratch/")); var removed = _store.RemoveAllForTool(TrustAudience.Personal, "shell_execute"); Assert.Equal(2, removed); - Assert.Empty(_store.GetApprovedPatterns(TrustAudience.Personal, "shell_execute")); - Assert.Equal(["/tmp/scratch/"], _store.GetApprovedPatterns(TrustAudience.Personal, "file_write")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + Assert.Single(_store.GetApprovedEntries(TrustAudience.Personal, "file_write")); } [Fact] @@ -118,12 +128,131 @@ public void RemoveAllForTool_returns_zero_when_tool_absent() [Fact] public void Snapshot_returns_deep_clone_independent_of_subsequent_writes() { - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git push"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); var snapshot = _store.Snapshot(); - _store.AddApproval(TrustAudience.Personal, "shell_execute", "git pull"); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git pull")); var personalShell = snapshot["personal"]["shell_execute"]; - Assert.Equal(["git push"], personalShell); + Assert.Single(personalShell); + Assert.Equal("git push", personalShell[0].Verb); + } + + [Fact] + public void AddApproval_is_idempotent_for_equal_entries() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git push")); + + var entries = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(entries); + } + + [Fact] + public void AddApproval_normalizes_trailing_slash_in_directory() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs/")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs")); + + var entries = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(entries); + Assert.Equal("/home/user/logs", entries[0].Directory); + } + + [Fact] + public void RemoveApproval_normalizes_trailing_slash_in_pattern_input() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs")); + Assert.True(_store.RemoveApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs/"))); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + } + + [Fact] + public void Save_emits_version_two_and_typed_entries() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("freshdesk")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs")); + + var json = File.ReadAllText(_file); + Assert.Contains("\"version\": 2", json); + Assert.Contains("\"verb\": \"freshdesk\"", json); + Assert.Contains("\"directory\": \"/home/user/logs\"", json); + // Global wildcard omits the directory field via WhenWritingNull. + Assert.DoesNotContain("\"directory\": null", json); + } + + [Fact] + public void Load_quarantines_v1_file_and_returns_empty() + { + const string V1Json = """ + { + "audiences": { + "personal": { + "shell_execute": [ "git push", "/home/user/logs/" ] + } + } + } + """; + File.WriteAllText(_file, V1Json); + + var data = _store.Load(); + + Assert.Empty(data.Audiences); + Assert.False(File.Exists(_file)); + Assert.True(File.Exists(_store.V1QuarantinePath)); + Assert.Equal(V1Json, File.ReadAllText(_store.V1QuarantinePath)); + } + + [Fact] + public void Load_quarantines_file_with_wrong_version_number() + { + File.WriteAllText(_file, """{"version":1,"audiences":{}}"""); + + var data = _store.Load(); + + Assert.Empty(data.Audiences); + Assert.False(File.Exists(_file)); + Assert.True(File.Exists(_store.V1QuarantinePath)); + } + + [Fact] + public void Load_quarantines_malformed_file_to_invalid_path() + { + File.WriteAllText(_file, "not valid json {{{"); + + var data = _store.Load(); + + Assert.Empty(data.Audiences); + Assert.False(File.Exists(_file)); + Assert.True(File.Exists(_store.MalformedQuarantinePath)); + Assert.False(File.Exists(_store.V1QuarantinePath)); + } + + [Fact] + public void Load_after_quarantine_writes_fresh_v2_file_on_next_persist() + { + File.WriteAllText(_file, """{"audiences":{"personal":{"shell_execute":["git push"]}}}"""); + + // First read quarantines and returns empty. + Assert.Empty(_store.Load().Audiences); + + // Next add writes a fresh v2 file at the original path. + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("freshdesk")); + Assert.True(File.Exists(_file)); + Assert.Contains("\"version\": 2", File.ReadAllText(_file)); + Assert.True(File.Exists(_store.V1QuarantinePath)); + } + + [Fact] + public void V2_file_round_trips_global_wildcard_and_folder_scoped_entries() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("freshdesk")); + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("grep", "/home/user/logs")); + + var reloaded = new ToolApprovalStore(_file).GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + + Assert.Equal(2, reloaded.Count); + Assert.Contains(reloaded, e => e.Verb == "freshdesk" && e.Directory is null); + Assert.Contains(reloaded, e => e.Verb == "grep" && e.Directory == "/home/user/logs"); } } diff --git a/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs b/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs index b61f483e..5227d79d 100644 --- a/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs +++ b/src/Netclaw.Configuration/ToolApprovalEntryComparer.cs @@ -33,17 +33,71 @@ public static class ToolApprovalEntryComparer OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; /// - /// Equality predicate for raw strings (verbs or directories). + /// Equality predicate for raw strings (verbs or directories). Does NOT + /// normalize trailing path separators; callers comparing directory paths + /// should pass values through first or use + /// which normalizes the + /// directory half automatically. /// public static bool Equals(string? left, string? right) => string.Equals(left, right, Comparison); /// /// Equality predicate matching the daemon's approval matcher: two - /// entries are equal when their verbs match and their directories - /// match (with both null directories considered equal — the - /// global wildcard). + /// entries are equal when their verbs match and their normalized + /// directories match (with both null directories considered equal — + /// the global wildcard). /// public static bool Equals(ApprovalEntry left, ApprovalEntry right) - => Equals(left.Verb, right.Verb) && Equals(left.Directory, right.Directory); + => Equals(left.Verb, right.Verb) + && Equals(NormalizeDirectory(left.Directory), NormalizeDirectory(right.Directory)); + + /// + /// Canonicalizes a directory path for storage and comparison. Trims + /// surrounding whitespace, collapses null/empty/whitespace to null + /// (the global-wildcard sentinel), and strips a trailing path separator so + /// /path/ and /path compare equal. Preserves filesystem + /// roots (/, C:\) intact. + /// + public static string? NormalizeDirectory(string? directory) + { + if (directory is null) + return null; + + var trimmed = directory.Trim(); + if (trimmed.Length == 0) + return null; + + // Preserve POSIX filesystem root. + if (trimmed == "/") + return trimmed; + + // Preserve Windows drive roots like "C:\" — TrimEnd would otherwise + // leave "C:" which means "the drive's current directory," a different + // location. + if (OperatingSystem.IsWindows() + && trimmed.Length == 3 + && trimmed[1] == ':' + && (trimmed[2] == '\\' || trimmed[2] == '/')) + { + return trimmed; + } + + var stripped = trimmed.TrimEnd('/', '\\'); + return stripped.Length == 0 ? trimmed : stripped; + } + + /// + /// Returns with its directory normalized via + /// . Used by the store at write time so + /// the on-disk file never accumulates trailing-slash variants of the same + /// logical entry. + /// + public static ApprovalEntry Normalize(ApprovalEntry entry) + { + var normalized = NormalizeDirectory(entry.Directory); + if (string.Equals(normalized, entry.Directory, StringComparison.Ordinal)) + return entry; + return entry with { Directory = normalized }; + } } diff --git a/src/Netclaw.Configuration/ToolApprovalStore.cs b/src/Netclaw.Configuration/ToolApprovalStore.cs index 3a2fb892..22d28859 100644 --- a/src/Netclaw.Configuration/ToolApprovalStore.cs +++ b/src/Netclaw.Configuration/ToolApprovalStore.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -9,22 +9,46 @@ namespace Netclaw.Configuration; /// -/// Reads and writes persistent tool approval patterns from +/// Reads and writes persistent tool approval entries from /// ~/.netclaw/config/tool-approvals.json. This file is NOT monitored /// by — writes do not trigger daemon restart. /// Thread-safe for concurrent reads and writes. +/// +/// The on-disk schema is version 2: a typed list +/// per (audience, tool). Files lacking "version": 2 at the root are +/// treated as legacy v1 and quarantined to ; an +/// empty v2 store is returned in their place. Files that fail to parse as +/// JSON at all are quarantined to . In +/// both cases the daemon fails closed (no approvals) instead of silently +/// dropping every persisted grant. /// public sealed class ToolApprovalStore { + /// + /// On-disk schema version emitted by and required by + /// . Files with any other value (including absent) are + /// quarantined to on first read. + /// + public const int CurrentSchemaVersion = 2; + private readonly string _filePath; private readonly object _lock = new(); /// - /// Path to the quarantine sibling file. Set by - /// when a malformed file is moved aside; consumers can check - /// on this path to detect a recent quarantine. + /// Path to the malformed-file quarantine sibling, used when the file + /// cannot be parsed as JSON at all. Distinct from + /// so operators can tell a corrupted file + /// apart from a legacy-version file. /// - public string QuarantinePath => _filePath + ".invalid"; + public string MalformedQuarantinePath => _filePath + ".invalid"; + + /// + /// Path to the legacy-v1 quarantine sibling, used when the file parses as + /// JSON but does not declare schema version 2. The v1 file is preserved + /// here untouched so operators who hand-curated v1 entries can mine them + /// for ideas before writing fresh v2 grants. + /// + public string V1QuarantinePath => _filePath + ".v1.bak"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -39,12 +63,11 @@ public ToolApprovalStore(string filePath) } /// - /// Loads all persistent approvals from disk. Returns an empty store if the - /// file does not exist. If the file is malformed, the corrupt file is - /// quarantined to tool-approvals.json.invalid and an empty store is - /// returned — operators can inspect or restore the quarantined copy and - /// the system fails closed (no approvals) instead of silently dropping - /// every persisted grant. + /// Loads all persistent approvals from disk. Returns an empty store when + /// the file does not exist, parses as JSON but lacks + /// "version": 2 (the file is moved aside to + /// ), or fails to parse as JSON at all (the + /// file is moved aside to ). /// public ToolApprovalData Load() { @@ -53,43 +76,100 @@ public ToolApprovalData Load() if (!File.Exists(_filePath)) return new ToolApprovalData(); + var json = File.ReadAllText(_filePath); + + // Two-step parse so we can distinguish three failure modes: + // (1) unparseable JSON → quarantine to .invalid + // (2) parseable JSON but wrong schema version → quarantine to .v1.bak + // (3) parseable v2 JSON with deserialization error → quarantine to .invalid + // Step 1 looks at the version field via JsonDocument; step 2 binds + // the strongly-typed model only after the version gate passes. + try + { + using var document = JsonDocument.Parse(json); + if (!IsCurrentSchema(document.RootElement)) + { + QuarantineV1File(); + return new ToolApprovalData(); + } + } + catch (JsonException ex) + { + QuarantineMalformedFile(ex); + return new ToolApprovalData(); + } + try { - var json = File.ReadAllText(_filePath); return JsonSerializer.Deserialize(json, JsonOptions) ?? new ToolApprovalData(); } catch (JsonException ex) { - QuarantineCorruptFile(ex); + QuarantineMalformedFile(ex); return new ToolApprovalData(); } } } - private void QuarantineCorruptFile(JsonException cause) + private static bool IsCurrentSchema(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + return false; + + if (!root.TryGetProperty("version", out var versionElem)) + return false; + + if (versionElem.ValueKind != JsonValueKind.Number) + return false; + + return versionElem.TryGetInt32(out var version) && version == CurrentSchemaVersion; + } + + private void QuarantineMalformedFile(JsonException cause) { try { - if (File.Exists(QuarantinePath)) - File.Delete(QuarantinePath); - File.Move(_filePath, QuarantinePath); + if (File.Exists(MalformedQuarantinePath)) + File.Delete(MalformedQuarantinePath); + File.Move(_filePath, MalformedQuarantinePath); } catch (Exception moveEx) { throw new InvalidDataException( - $"Tool approvals file at '{_filePath}' is malformed and could not be quarantined to '{QuarantinePath}'. Inspect the file manually before restarting.", + $"Tool approvals file at '{_filePath}' is malformed and could not be quarantined to '{MalformedQuarantinePath}'. Inspect the file manually before restarting.", new AggregateException(cause, moveEx)); } } + private void QuarantineV1File() + { + try + { + if (File.Exists(V1QuarantinePath)) + File.Delete(V1QuarantinePath); + File.Move(_filePath, V1QuarantinePath); + } + catch (Exception moveEx) + { + throw new InvalidDataException( + $"Tool approvals file at '{_filePath}' uses a legacy schema and could not be quarantined to '{V1QuarantinePath}'. Inspect the file manually before restarting.", + moveEx); + } + } + /// - /// Adds an approved pattern for a tool in the given audience. - /// For shell_execute, the pattern is a verb chain (e.g., "git push"). - /// For other tools, pass the tool name to approve tool-level access. + /// Adds an approved for a tool in the given + /// audience. The directory portion is normalized before storage so the + /// on-disk file never accumulates trailing-slash variants of the same + /// logical entry. Idempotent: an entry equal under + /// + /// is silently dropped. /// - public void AddApproval(TrustAudience audience, string toolName, string pattern) + public void AddApproval(TrustAudience audience, string toolName, ApprovalEntry entry) { + var normalized = ToolApprovalEntryComparer.Normalize(entry); + lock (_lock) { var data = Load(); @@ -97,31 +177,31 @@ public void AddApproval(TrustAudience audience, string toolName, string pattern) if (!data.Audiences.TryGetValue(audienceKey, out var audienceApprovals)) { - audienceApprovals = new Dictionary>(StringComparer.Ordinal); + audienceApprovals = new Dictionary>(StringComparer.Ordinal); data.Audiences[audienceKey] = audienceApprovals; } - if (!audienceApprovals.TryGetValue(toolName, out var patterns)) + if (!audienceApprovals.TryGetValue(toolName, out var entries)) { - patterns = []; - audienceApprovals[toolName] = patterns; + entries = []; + audienceApprovals[toolName] = entries; } - // Use the same comparer the daemon gate and the operator CLI use, - // otherwise on Windows "Git Push" and "git push" would dedupe as - // distinct on add but both get wiped by a single revoke. - if (!patterns.Contains(pattern, ToolApprovalEntryComparer.Comparer)) + foreach (var existing in entries) { - patterns.Add(pattern); - Save(data); + if (ToolApprovalEntryComparer.Equals(existing, normalized)) + return; } + + entries.Add(normalized); + Save(data); } } /// - /// Returns the approved patterns for a specific tool and audience. + /// Returns the approved entries for a specific tool and audience. /// - public IReadOnlyList GetApprovedPatterns(TrustAudience audience, string toolName) + public IReadOnlyList GetApprovedEntries(TrustAudience audience, string toolName) { var data = Load(); var audienceKey = audience.ToWireValue(); @@ -129,22 +209,24 @@ public IReadOnlyList GetApprovedPatterns(TrustAudience audience, string if (!data.Audiences.TryGetValue(audienceKey, out var audienceApprovals)) return []; - if (!audienceApprovals.TryGetValue(toolName, out var patterns)) + if (!audienceApprovals.TryGetValue(toolName, out var entries)) return []; - return patterns; + return entries; } /// - /// Removes an approved pattern for a tool in the given audience. Comparison - /// uses so the CLI and - /// the daemon agree on what "the same entry" means. Empty per-tool and - /// per-audience maps are pruned so the file does not retain hollow - /// sections after a revoke. + /// Removes an approved entry for a tool in the given audience. Comparison + /// uses + /// so the CLI and the daemon agree on what "the same entry" means. Empty + /// per-tool and per-audience maps are pruned so the file does not retain + /// hollow sections after a revoke. /// /// true if an entry was removed; false otherwise. - public bool RemoveApproval(TrustAudience audience, string toolName, string pattern) + public bool RemoveApproval(TrustAudience audience, string toolName, ApprovalEntry entry) { + var normalized = ToolApprovalEntryComparer.Normalize(entry); + lock (_lock) { var data = Load(); @@ -153,13 +235,13 @@ public bool RemoveApproval(TrustAudience audience, string toolName, string patte if (!data.Audiences.TryGetValue(audienceKey, out var audienceApprovals)) return false; - if (!audienceApprovals.TryGetValue(toolName, out var patterns)) + if (!audienceApprovals.TryGetValue(toolName, out var entries)) return false; var index = -1; - for (var i = 0; i < patterns.Count; i++) + for (var i = 0; i < entries.Count; i++) { - if (ToolApprovalEntryComparer.Equals(patterns[i], pattern)) + if (ToolApprovalEntryComparer.Equals(entries[i], normalized)) { index = i; break; @@ -169,7 +251,7 @@ public bool RemoveApproval(TrustAudience audience, string toolName, string patte if (index < 0) return false; - patterns.RemoveAt(index); + entries.RemoveAt(index); CleanupEmptySections(data, audienceKey, toolName); Save(data); return true; @@ -190,14 +272,14 @@ public int RemoveAllForTool(TrustAudience audience, string toolName) if (!data.Audiences.TryGetValue(audienceKey, out var audienceApprovals)) return 0; - if (!audienceApprovals.TryGetValue(toolName, out var patterns)) + if (!audienceApprovals.TryGetValue(toolName, out var entries)) return 0; - var removed = patterns.Count; + var removed = entries.Count; if (removed == 0) return 0; - patterns.Clear(); + entries.Clear(); CleanupEmptySections(data, audienceKey, toolName); Save(data); return removed; @@ -209,15 +291,15 @@ public int RemoveAllForTool(TrustAudience audience, string toolName) /// audience wire value then tool name. The snapshot is decoupled from the /// underlying file — subsequent mutations are not reflected. /// - public IReadOnlyDictionary>> Snapshot() + public IReadOnlyDictionary>> Snapshot() { var data = Load(); - var result = new Dictionary>>(StringComparer.Ordinal); + var result = new Dictionary>>(StringComparer.Ordinal); foreach (var (audienceKey, tools) in data.Audiences) { - var clonedTools = new Dictionary>(StringComparer.Ordinal); - foreach (var (toolName, patterns) in tools) - clonedTools[toolName] = patterns.ToArray(); + var clonedTools = new Dictionary>(StringComparer.Ordinal); + foreach (var (toolName, entries) in tools) + clonedTools[toolName] = entries.ToArray(); result[audienceKey] = clonedTools; } return result; @@ -228,7 +310,7 @@ private static void CleanupEmptySections(ToolApprovalData data, string audienceK if (!data.Audiences.TryGetValue(audienceKey, out var audienceApprovals)) return; - if (audienceApprovals.TryGetValue(toolName, out var patterns) && patterns.Count == 0) + if (audienceApprovals.TryGetValue(toolName, out var entries) && entries.Count == 0) audienceApprovals.Remove(toolName); if (audienceApprovals.Count == 0) @@ -237,6 +319,12 @@ private static void CleanupEmptySections(ToolApprovalData data, string audienceK private void Save(ToolApprovalData data) { + // Always emit current schema version on write, even if Load returned + // a default-constructed data object whose Version is also already 2. + // Centralizing the write keeps the contract obvious and resilient to + // future default-value changes on the data model. + data.Version = CurrentSchemaVersion; + var dir = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); @@ -251,10 +339,18 @@ private void Save(ToolApprovalData data) /// public sealed class ToolApprovalData { + /// + /// On-disk schema version. Set to + /// by . Files lacking this value are + /// quarantined as legacy on first read. + /// + [JsonPropertyName("version")] + public int Version { get; set; } = ToolApprovalStore.CurrentSchemaVersion; + /// /// Per-audience approval sections. Keys are audience wire values - /// ("personal", "team", "public"). Values are per-tool pattern lists. + /// ("personal", "team", "public"). Values are per-tool entry lists. /// [JsonPropertyName("audiences")] - public Dictionary>> Audiences { get; set; } = new(StringComparer.Ordinal); + public Dictionary>> Audiences { get; set; } = new(StringComparer.Ordinal); } From ad95fab2a8f2ec59d4a894e7de4bbd14a32adf09 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 17:37:16 +0000 Subject: [PATCH 04/46] feat(approvals): matcher operates on (verb, directory) ApprovalEntry Section 2 of the approval-policy-v2 OpenSpec change. Refactors the approval matcher and gate to consume v2 typed ApprovalEntry records, plumbs the candidate cwd through the execution context, and deletes the v1 string-shape inspection logic. Matcher contract changes: - IToolApprovalMatcher.ExtractDirectoryRoots is removed; the v2 matcher has no concept of "directory roots extracted from arguments." The directory half of every (verb, directory) pair is the candidate's cwd from ToolExecutionContext. - ExtractApprovalEntries renamed to ExtractCandidateVerbs and now returns pure verb chains. The v1 fallback to normalized commands or bare directory roots is gone. - IsApproved signature: now takes (toolName, args, IReadOnlyList, cwd) and dispatches to ApprovalPatternMatching.MatchesShellApproval which enforces verb equality + (directory null || cwd under directory) + no-symlink-segment. Cwd plumbing: - ToolExecutionContext gains a Cwd property the session pipeline sets from candidate args / WorkingContext.ProjectDirectory / session_dir (sections 4 + 5 cover the resolution side). - IToolApprovalService.GetUnapprovedPatternsAsync and RecordApprovalAsync take a cwd parameter; AkkaToolApprovalService threads it through GetUnapprovedPatterns and RecordToolApproval actor messages. - ToolApprovalContext: ApprovalEntries field renamed to CandidateVerbs; DirectoryRoots stays but is always populated empty by the gate (section 7's prompt redesign removes the field). SessionOutput, SessionOutputDto, ParentSessionApprovalBridge, PendingToolInteraction, and the protocol mapper rename consistently. Shared symlink-segment guard: - PathUtility.ContainsSymlinkSegment hoisted from ScopedFileAccessPolicy so the matcher and the file-access policy share one implementation. Tests: - Configuration.Tests, Cli.Tests, Daemon.Tests, MemoryRetrievalPoC.Tests, Search.Tests, Security.Tests (397 incl new matcher cases), and Actors.Tests (1483) all pass. - ShellApprovalMatcherTests rewritten to assert the v2 (verb, cwd, entries) semantics: global-wildcard matches anywhere, folder-scoped matches when cwd under directory, requires concrete cwd, recurses into bash -c. - ToolApprovalGateTests' v1 directory-roots assertions replaced with v2 candidate-verb assertions; DirectoryRoots is asserted empty. - ToolApprovalActor's session HashSet now uses ToolApprovalEntryComparer.Comparer so session approvals follow the same platform-correct case rules as the persistent store. - Test plumbing across the codebase passes cwd: null where the invocation isn't directory-anchored. Slopwatch clean; file headers verified. --- openspec/changes/approval-policy-v2/tasks.md | 10 +- .../ParentSessionApprovalBridgeTests.cs | 6 +- .../SessionToolExecutionPipelineTests.cs | 2 +- .../Tools/DispatchingToolExecutorTests.cs | 4 +- .../Tools/ToolApprovalActorTests.cs | 70 +++--- .../Tools/ToolApprovalGateTests.cs | 55 +++-- src/Netclaw.Actors/Protocol/SessionOutput.cs | 20 +- .../Protocol/SessionOutputDto.cs | 3 +- .../Protocol/SessionOutputDtoMapper.cs | 6 +- .../Sessions/LlmSessionActor.cs | 13 +- .../Sessions/ParentSessionApprovalBridge.cs | 4 +- .../Pipelines/SessionToolExecutionPipeline.cs | 2 +- src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 2 +- .../Tools/AkkaToolApprovalService.cs | 6 +- .../Tools/DispatchingToolExecutor.cs | 3 +- .../Tools/FilePathApprovalMatcher.cs | 28 +-- .../Tools/ScopedFileAccessPolicy.cs | 38 +--- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 37 +-- src/Netclaw.Actors/Tools/ToolApprovalActor.cs | 56 ++--- .../Tools/ToolApprovalMessages.cs | 8 +- .../Cli/DaemonClientMappingTests.cs | 6 +- .../ShellApprovalMatcherTests.cs | 212 ++++++++---------- .../ApprovalPatternMatching.cs | 135 +++++------ src/Netclaw.Security/IToolApprovalMatcher.cs | 166 ++++++-------- src/Netclaw.Security/IToolApprovalService.cs | 10 + src/Netclaw.Security/PathUtility.cs | 48 ++++ .../IParentApprovalBridge.cs | 11 +- .../ToolExecutionContext.cs | 12 + 28 files changed, 469 insertions(+), 504 deletions(-) diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index 3b540f9d..cb96b2a7 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -19,11 +19,11 @@ Both PRs sit under this single OpenSpec change. ## 2. Matcher operates on ApprovalEntry -- [ ] 2.1 Update `IToolApprovalMatcher` to remove `ExtractDirectoryRoots`; pattern extraction returns verb chains only. -- [ ] 2.2 Update `ApprovalPatternMatching` to evaluate `(verb, directory)` containment: candidate matches when verb equals entry's verb AND (entry directory is null OR candidate cwd is under entry directory) AND no symlink segment along the cwd path. -- [ ] 2.3 Plumb `Cwd` through `ToolExecutionContext` / `ToolInteractionRequest` so the matcher always has a concrete cwd to evaluate against. -- [ ] 2.4 Delete the v1 string-shape inspection logic (trailing-slash heuristic) from `ShellApprovalMatcher` and `ApprovalPatternMatching`. -- [ ] 2.5 Unit tests for the four matcher cases: cwd inside entry directory; cwd outside; entry directory null; symlink segment in cwd. +- [x] 2.1 Update `IToolApprovalMatcher` to remove `ExtractDirectoryRoots`; pattern extraction returns verb chains only. +- [x] 2.2 Update `ApprovalPatternMatching` to evaluate `(verb, directory)` containment: candidate matches when verb equals entry's verb AND (entry directory is null OR candidate cwd is under entry directory) AND no symlink segment along the cwd path. +- [x] 2.3 Plumb `Cwd` through `ToolExecutionContext` / `ToolInteractionRequest` so the matcher always has a concrete cwd to evaluate against. +- [x] 2.4 Delete the v1 string-shape inspection logic (trailing-slash heuristic) from `ShellApprovalMatcher` and `ApprovalPatternMatching`. +- [x] 2.5 Unit tests for the four matcher cases: cwd inside entry directory; cwd outside; entry directory null; symlink segment in cwd. ## 3. ShellTokenizer refuses messy input diff --git a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs index fd5ac653..8ff86c97 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs @@ -48,7 +48,11 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() Assert.True(emitted.PersistedAdoptedContext); Assert.Equal(["user-123", "user-456"], emitted.AdoptedSpeakerIds); Assert.Equal(["grep timeout logs/app.log | wc -l"], emitted.Patterns); - Assert.Equal(["/tmp/work/logs/"], emitted.ApprovalEntries); + // The bridge passes the verb-chain candidate list it was given verbatim; + // in this test the second positional argument supplied "/tmp/work/logs/" + // as a synthetic candidate list, so we assert it round-trips into + // CandidateVerbs (the v2 field replacing v1's ApprovalEntries). + Assert.Equal(["/tmp/work/logs/"], emitted.CandidateVerbs); Assert.Equal(["logs/"], emitted.DirectoryRoots); Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, emitted.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, emitted.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs index dd62b006..d26a3d14 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs @@ -148,7 +148,7 @@ public Task ExecuteAsync(FunctionCallContent toolCall, ToolExecutionCont ToolName: toolCall.Name, DisplayText: "git push origin dev", Patterns: ["git push origin dev"], - ApprovalEntries: ["git push origin dev"], + CandidateVerbs: ["git push origin dev"], Options: [ new ToolApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 9793a7f7..19354edb 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -622,6 +622,7 @@ await approvalService.RecordApprovalAsync( new ToolName("shell_execute"), ["pwd"], persistent: false, + cwd: null, TestContext.Current.CancellationToken); var call = new FunctionCallContent( @@ -712,8 +713,9 @@ await approvalService.RecordApprovalAsync( "signalr/thread-1", TrustAudience.Personal, new ToolName(toolCall.Name), - firstAttempt.ApprovalContext.ApprovalEntries, + firstAttempt.ApprovalContext.CandidateVerbs, persistent: false, + cwd: null, TestContext.Current.CancellationToken); var sameSessionResult = await executor.ExecuteAsync(toolCall, firstContext, TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index 7b82c62b..4005d2b2 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -42,8 +42,8 @@ public async Task Session_approval_is_found() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct); Assert.Empty(unapproved); } @@ -55,7 +55,7 @@ public async Task Unapproved_pattern_not_found() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct); Assert.Equal(["git push"], unapproved); } @@ -67,10 +67,10 @@ public async Task Per_audience_isolation() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); - Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Team, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); + Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Team, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); } [Fact] @@ -80,10 +80,10 @@ public async Task Per_tool_isolation() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); - Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("file_write"), ["git push"], ct)); + Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); + Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("file_write"), ["git push"], cwd: null, ct)); } [Fact] @@ -93,11 +93,11 @@ public async Task Single_token_approval_requires_exact_match() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["gh"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["gh"], persistent: false, cwd: null, ct); // Single-token "gh" should NOT match "gh pr" — prevents approving // "gh --help" from also approving "gh pr create" - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["gh pr"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["gh pr"], cwd: null, ct); Assert.Equal(["gh pr"], unapproved); } @@ -108,9 +108,9 @@ public async Task Shell_exact_approval_does_not_prefix_match() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push origin"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push origin"], cwd: null, ct); Assert.Equal(["git push origin"], unapproved); } @@ -124,13 +124,14 @@ public async Task Shell_directory_root_approval_covers_other_verbs_under_same_ro var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, cwd: null, ct); Assert.Empty(await service.GetUnapprovedPatternsAsync( "session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], + cwd: null, ct)); } @@ -142,13 +143,14 @@ public async Task Shell_directory_root_approval_requires_all_roots_to_be_covered var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot], persistent: false, cwd: null, ct); var unapproved = await service.GetUnapprovedPatternsAsync( "session-a", TrustAudience.Personal, new ToolName("shell_execute"), [approvedRoot, otherRoot], + cwd: null, ct); Assert.Equal([expectedUnapproved], unapproved); @@ -165,13 +167,13 @@ public async Task Persistent_approval_survives_new_service_instance() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: true, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: true, cwd: null, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); var actor2 = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); var service2 = CreateService(actor2); - Assert.Empty(await service2.GetUnapprovedPatternsAsync("different-session", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Empty(await service2.GetUnapprovedPatternsAsync("different-session", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); } finally { @@ -193,8 +195,8 @@ public async Task Approval_match_follows_host_filesystem_case_rules() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["Git Push"], persistent: false, ct); - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["Git Push"], persistent: false, cwd: null, ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct); if (OperatingSystem.IsWindows()) Assert.Empty(unapproved); @@ -209,10 +211,10 @@ public async Task Session_approvals_do_not_leak_across_sessions() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps()); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); - Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-b", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); + Assert.Equal(["git push"], await service.GetUnapprovedPatternsAsync("session-b", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); } [Fact] @@ -226,13 +228,13 @@ public async Task Non_persistent_approval_is_session_scoped_only() var actor = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); var service = CreateService(actor); - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); - Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Empty(await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); var actor2 = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); var service2 = CreateService(actor2); - Assert.Equal(["git push"], await service2.GetUnapprovedPatternsAsync("different-session", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct)); + Assert.Equal(["git push"], await service2.GetUnapprovedPatternsAsync("different-session", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct)); } finally { @@ -248,11 +250,11 @@ public async Task SubAgent_inherits_parent_session_approval() var service = CreateService(actor); // Parent session approves "git push" - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); // Sub-agent queries with hierarchical scope ID — should inherit parent approval var subAgentScope = "session-a/subagent/researcher/abc123"; - var unapproved = await service.GetUnapprovedPatternsAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct); Assert.Empty(unapproved); } @@ -266,10 +268,10 @@ public async Task SubAgent_approval_does_not_leak_upward() // Sub-agent records its own approval var subAgentScope = "session-a/subagent/researcher/abc123"; - await service.RecordApprovalAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["curl"], persistent: false, ct); + await service.RecordApprovalAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["curl"], persistent: false, cwd: null, ct); // Parent session should NOT see sub-agent's approval - var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["curl"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["curl"], cwd: null, ct); Assert.Equal(["curl"], unapproved); } @@ -281,11 +283,11 @@ public async Task Nested_subagent_inherits_through_chain() var service = CreateService(actor); // Parent session approves "git status" - await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git status"], persistent: false, ct); + await service.RecordApprovalAsync("session-a", TrustAudience.Personal, new ToolName("shell_execute"), ["git status"], persistent: false, cwd: null, ct); // Nested sub-agent (sub-agent spawned by sub-agent) should still inherit var nestedScope = "session-a/subagent/orchestrator/def456/subagent/worker/ghi789"; - var unapproved = await service.GetUnapprovedPatternsAsync(nestedScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git status"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync(nestedScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git status"], cwd: null, ct); Assert.Empty(unapproved); } @@ -298,11 +300,11 @@ public async Task SubAgent_does_not_inherit_from_unrelated_session() var service = CreateService(actor); // Session B approves "git push" - await service.RecordApprovalAsync("session-b", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, ct); + await service.RecordApprovalAsync("session-b", TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], persistent: false, cwd: null, ct); // Sub-agent of session A should NOT inherit session B's approval var subAgentScope = "session-a/subagent/researcher/abc123"; - var unapproved = await service.GetUnapprovedPatternsAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], ct); + var unapproved = await service.GetUnapprovedPatternsAsync(subAgentScope, TrustAudience.Personal, new ToolName("shell_execute"), ["git push"], cwd: null, ct); Assert.Equal(["git push"], unapproved); } diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index 7b323e86..4fbee8ca 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -757,10 +757,10 @@ private sealed class FakeShellTrustZonePolicy : IShellTrustZonePolicy public IReadOnlyList GetTrustZoneRoots(ToolExecutionContext context) => _roots; } - // ── Directory-root shell approvals ── + // ── v2 candidate-verb extraction (replaces v1 directory-root extraction) ── [Fact] - public void Shell_path_command_populates_directory_roots_and_root_entries() + public void Shell_path_command_extracts_path_aware_verb_chain_with_no_directory_roots() { var policy = CreatePolicy(ToolApprovalMode.Approval); var args = ToolInput.Create("Command", "cat /home/user/.netclaw/logs/crash.log"); @@ -769,13 +769,20 @@ public void Shell_path_command_populates_directory_roots_and_root_entries() Assert.True(decision.NeedsApproval); Assert.NotNull(decision.ApprovalContext); - Assert.NotEmpty(decision.ApprovalContext!.DirectoryRoots); - Assert.Contains("/home/user/.netclaw/logs/", decision.ApprovalContext.DirectoryRoots.Select(p => p.Replace('\\', '/'))); - Assert.Contains("/home/user/.netclaw/logs/", decision.ApprovalContext.ApprovalEntries.Select(p => p.Replace('\\', '/'))); + // v2 cutover: DirectoryRoots is always empty; the candidate's cwd is + // the directory half of any (verb, directory) approval pair, supplied + // via ToolExecutionContext rather than extracted from arguments. + Assert.Empty(decision.ApprovalContext!.DirectoryRoots); + // ExtractVerbChain appends the first positional argument for + // path-aware verbs (cat, grep, etc.) so the candidate captures what + // the command operates on. + Assert.Contains( + decision.ApprovalContext.CandidateVerbs, + v => v.Replace('\\', '/').Equals("cat /home/user/.netclaw/logs/crash.log", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void Shell_path_command_uses_fixed_labels_with_root_in_directory_roots() + public void Shell_path_command_uses_fixed_labels() { var policy = CreatePolicy(ToolApprovalMode.Approval); var args = ToolInput.Create("Command", "grep 'error' /home/user/.netclaw/logs/app.log"); @@ -788,9 +795,9 @@ public void Shell_path_command_uses_fixed_labels_with_root_in_directory_roots() var alwaysOption = options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, alwaysOption.Label); - // Directory scope is preserved on the context for the channel adapter - // to render in the message body. - Assert.NotEmpty(decision.ApprovalContext.DirectoryRoots); + // v2 cutover: DirectoryRoots is always empty; section 7 redesigns the + // prompt body to show the cwd in the header instead of in a section. + Assert.Empty(decision.ApprovalContext.DirectoryRoots); } [Fact] @@ -805,11 +812,11 @@ public void Shell_multi_root_command_uses_fixed_labels() var options = decision.ApprovalContext!.Options; Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); Assert.Equal(ApprovalOptionKeys.ApproveAlwaysLabel, options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways).Label); - Assert.True(decision.ApprovalContext.DirectoryRoots.Count > 1); + Assert.Empty(decision.ApprovalContext.DirectoryRoots); } [Fact] - public void Shell_relative_path_command_records_relative_root_and_absolute_entry() + public void Shell_relative_path_command_extracts_verb_chain_without_directory_roots() { var policy = CreatePolicy(ToolApprovalMode.Approval); var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); @@ -825,15 +832,13 @@ public void Shell_relative_path_command_records_relative_root_and_absolute_entry var decision = policy.AuthorizeInvocation(ShellTool(), PersonalContext(), args); Assert.True(decision.NeedsApproval); - // DirectoryRoots keeps the relative display form for channel-body - // rendering. ApprovalEntries gets the absolute root because that's - // what gets persisted and matched against on retry. - Assert.Equal(["logs/"], decision.ApprovalContext!.DirectoryRoots); - var absoluteRoot = PathUtility.Normalize(logs) + Path.DirectorySeparatorChar; - Assert.Contains(absoluteRoot, decision.ApprovalContext.ApprovalEntries); - // Button labels are fixed regardless of relative/absolute path - // form — Slack's 76-char and Discord's 80-char button caps make - // dynamic labels structurally unsafe. + // v2 cutover: no v1 directory-root extraction. Pipelines stay + // inside one approval unit, so the candidate is the verb chain of + // the unit's first command (path-aware "grep "). + Assert.Empty(decision.ApprovalContext!.DirectoryRoots); + Assert.Contains(decision.ApprovalContext.CandidateVerbs, v => v.StartsWith("grep", StringComparison.Ordinal)); + // Button labels are fixed; Slack's 76-char and Discord's 80-char + // button caps make dynamic labels structurally unsafe. Assert.Equal( ApprovalOptionKeys.ApproveSessionLabel, decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession).Label); @@ -848,7 +853,7 @@ public void Shell_relative_path_command_records_relative_root_and_absolute_entry } [Fact] - public void Non_path_command_uses_default_labels() + public void Non_path_command_extracts_two_token_verb_chain() { var policy = CreatePolicy(ToolApprovalMode.Approval); var args = ToolInput.Create("Command", "git push origin main"); @@ -857,7 +862,8 @@ public void Non_path_command_uses_default_labels() Assert.True(decision.NeedsApproval); Assert.Empty(decision.ApprovalContext!.DirectoryRoots); - Assert.Equal(["git push origin main"], decision.ApprovalContext.ApprovalEntries); + // ExtractVerbChain caps at depth 2, dropping positional arguments. + Assert.Equal(["git push"], decision.ApprovalContext.CandidateVerbs); var sessionOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveSession); var alwaysOption = decision.ApprovalContext.Options.Single(o => o.Key == ApprovalOptionKeys.ApproveAlways); Assert.Equal(ApprovalOptionKeys.ApproveSessionLabel, sessionOption.Label); @@ -879,7 +885,10 @@ public void Shell_command_with_long_directory_path_keeps_labels_within_button_ca Assert.True(decision.NeedsApproval); var options = decision.ApprovalContext!.Options; - Assert.NotEmpty(decision.ApprovalContext.DirectoryRoots); + // v2: DirectoryRoots is always empty after section 2 — the cwd lives + // on ToolExecutionContext and section 7 renders it in the prompt + // header. The label-cap regression is independent of that. + Assert.Empty(decision.ApprovalContext.DirectoryRoots); Assert.All(options, option => Assert.True( option.Label.Length <= ApprovalOptionKeys.MaxLabelLength, $"Option '{option.Key}' label '{option.Label}' is {option.Label.Length} chars; must stay within {ApprovalOptionKeys.MaxLabelLength}.")); diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index 5b85250b..5dc9189e 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -364,18 +364,26 @@ public sealed record ToolInteractionRequest : SessionOutput public IReadOnlyList Patterns { get; init; } = []; /// - /// Entries checked against the accumulated session/persistent approval - /// state. For shell commands these are reusable directory roots when local - /// roots can be extracted, and exact fallback units otherwise. + /// Candidate verb chains extracted from this invocation. The approval gate + /// evaluates each verb against persisted ApprovalEntry records + /// using as the directory half of the + /// (verb, directory) pair. /// - public IReadOnlyList ApprovalEntries { get; init; } = []; + public IReadOnlyList CandidateVerbs { get; init; } = []; /// - /// Human-visible reusable roots extracted from the current invocation so the - /// prompt can explain what broader B/C approvals would cover. + /// Always empty after the v2 cutover; section 7's prompt redesign removes + /// the field as part of the new cwd-in-header layout. /// public IReadOnlyList DirectoryRoots { get; init; } = []; + /// + /// Resolved working directory for this invocation, used by the approval + /// gate to evaluate folder-scoped ApprovalEntry records. May be + /// null for tools whose approvals are not directory-anchored. + /// + public string? Cwd { get; init; } + /// Available response options (e.g., approve once, approve for this chat, approve always, deny). public required IReadOnlyList Options { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index 1d3c25ba..48d98b77 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -106,8 +106,9 @@ public sealed record SessionOutputDto public string? InteractionDisplayText { get; init; } public string? RequesterSenderId { get; init; } public List? InteractionPatterns { get; init; } - public List? InteractionApprovalEntries { get; init; } + public List? InteractionCandidateVerbs { get; init; } public List? InteractionDirectoryRoots { get; init; } + public string? InteractionCwd { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index dff0a990..683600b7 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -179,8 +179,9 @@ public static class SessionOutputDtoMapper InteractionDisplayText = msg.DisplayText, RequesterSenderId = msg.RequesterSenderId, InteractionPatterns = [.. msg.Patterns], - InteractionApprovalEntries = [.. msg.ApprovalEntries], + InteractionCandidateVerbs = [.. msg.CandidateVerbs], InteractionDirectoryRoots = [.. msg.DirectoryRoots], + InteractionCwd = msg.Cwd, InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] @@ -338,8 +339,9 @@ public static SessionOutput FromDto(SessionOutputDto dto) HasAdoptedContext = dto.InteractionHasAdoptedContext ?? false, AdoptedSpeakerIds = dto.InteractionAdoptedSpeakerIds ?? [], Patterns = dto.InteractionPatterns ?? [], - ApprovalEntries = dto.InteractionApprovalEntries ?? [], + CandidateVerbs = dto.InteractionCandidateVerbs ?? [], DirectoryRoots = dto.InteractionDirectoryRoots ?? [], + Cwd = dto.InteractionCwd, Options = dto.InteractionOptions ?? [] }, _ => new ErrorOutput diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index eb94c7af..160d25d5 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -750,13 +750,14 @@ private void Processing() _pendingToolInteractions[msg.CallId] = new PendingToolInteraction( msg.ToolName, msg.Patterns, - msg.ApprovalEntries, + msg.CandidateVerbs, msg.DirectoryRoots, CurrentTurnAudience(), msg.RequesterSenderId, msg.RequesterPrincipal, msg.HasAdoptedContext, - msg.AdoptedSpeakerIds); + msg.AdoptedSpeakerIds, + msg.Cwd); PauseToolExecutionWatchdogForApprovalWait(msg.CallId); @@ -805,8 +806,9 @@ await _approvalService.RecordApprovalAsync( _sessionId.Value, pending.Audience, new ToolName(pending.ToolName), - pending.ApprovalEntries, + pending.CandidateVerbs, persistent: decision == ApprovalDecision.ApprovedAlways, + pending.Cwd, CancellationToken.None); } @@ -3051,13 +3053,14 @@ private void EmitOutput(SessionOutput output, OutputFilter requiredFlag = Output private sealed record PendingToolInteraction( string ToolName, IReadOnlyList Patterns, - IReadOnlyList ApprovalEntries, + IReadOnlyList CandidateVerbs, IReadOnlyList DirectoryRoots, TrustAudience Audience, string? RequesterSenderId, PrincipalClassification? RequesterPrincipal, bool HasAdoptedContext, - IReadOnlyList AdoptedSpeakerIds); + IReadOnlyList AdoptedSpeakerIds, + string? Cwd); private void PersistAdoptedContextIfNeeded(MessageSource? source) { diff --git a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs index 7589dbd0..f971b7a7 100644 --- a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs +++ b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs @@ -47,7 +47,7 @@ public async Task RequestApprovalAsync( string toolName, string displayText, IReadOnlyList patterns, - IReadOnlyList approvalEntries, + IReadOnlyList candidateVerbs, IReadOnlyList directoryRoots, CancellationToken ct) { @@ -65,7 +65,7 @@ public async Task RequestApprovalAsync( RequesterSenderId = _requesterSenderId, RequesterPrincipal = _requesterPrincipal, Patterns = patterns, - ApprovalEntries = approvalEntries, + CandidateVerbs = candidateVerbs, DirectoryRoots = directoryRoots, HasAdoptedContext = _hasAdoptedContext, AdoptedSpeakerIds = _adoptedSpeakerIds, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 58a7dc4d..095ad9f9 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -284,7 +284,7 @@ public static async Task ExecuteSingleToolAsync( AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], PersistedAdoptedContext = source?.HasAdoptedContext ?? false, Patterns = ctx.Patterns, - ApprovalEntries = ctx.ApprovalEntries, + CandidateVerbs = ctx.CandidateVerbs, DirectoryRoots = ctx.DirectoryRoots, Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 3f8017ed..ebfde098 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -426,7 +426,7 @@ private static async Task ExecuteToolsAsync( ctx.ToolName, ctx.DisplayText, ctx.Patterns, - ctx.ApprovalEntries, + ctx.CandidateVerbs, ctx.DirectoryRoots, ct); diff --git a/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs b/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs index 6125f12d..589da485 100644 --- a/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs +++ b/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs @@ -27,11 +27,12 @@ public async Task> GetUnapprovedPatternsAsync( TrustAudience audience, ToolName toolName, IReadOnlyList patterns, + string? cwd, CancellationToken ct = default) { var actor = await _actorProvider.GetAsync(ct); var response = await actor.Ask( - new GetUnapprovedPatterns(sessionId is not null ? (SessionId)sessionId : null, audience, toolName, patterns), + new GetUnapprovedPatterns(sessionId is not null ? (SessionId)sessionId : null, audience, toolName, patterns, cwd), TimeSpan.FromSeconds(5), ct); @@ -44,11 +45,12 @@ public async Task RecordApprovalAsync( ToolName toolName, IReadOnlyList patterns, bool persistent, + string? cwd, CancellationToken ct = default) { var actor = await _actorProvider.GetAsync(ct); await actor.Ask( - new RecordToolApproval((SessionId)sessionId, audience, toolName, patterns, persistent), + new RecordToolApproval((SessionId)sessionId, audience, toolName, patterns, persistent, cwd), TimeSpan.FromSeconds(5), ct); } diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 0497c40f..8180f3b8 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -112,7 +112,8 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall context?.SessionId, audience, new ToolName(toolCall.Name), - approvalContext.ApprovalEntries, + approvalContext.CandidateVerbs, + context?.Cwd, ct); accessDecision = unapproved.Count == 0 diff --git a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs index c620f6ea..8ee26389 100644 --- a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs +++ b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Configuration; using Netclaw.Security; using Netclaw.Tools; @@ -43,25 +44,19 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) + public IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionary? arguments) => ExtractPatterns(toolName, arguments); - public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) + public bool IsApproved( + ToolName toolName, + IDictionary? arguments, + IReadOnlyList approvedEntries, + string? cwd) { - var patterns = ExtractPatterns(toolName, arguments); - foreach (var pattern in patterns) + var verbs = ExtractCandidateVerbs(toolName, arguments); + foreach (var verb in verbs) { - var matched = false; - foreach (var approved in approvedPatterns) - { - if (string.Equals(pattern, approved, StringComparison.OrdinalIgnoreCase)) - { - matched = true; - break; - } - } - - if (!matched) + if (!ApprovalPatternMatching.MatchesAny(verb, approvedEntries)) return false; } @@ -76,9 +71,6 @@ public string FormatForDisplay(ToolName toolName, IDictionary? return toolName.Value; } - public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) - => []; - private bool TryGetControlPlaneRelativePath( IDictionary? arguments, out string relativePath) diff --git a/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs b/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs index 65d77af7..f740d2d1 100644 --- a/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ScopedFileAccessPolicy.cs @@ -89,7 +89,7 @@ private bool TryResolvePath( if (!PathUtility.IsWithinRoot(fullPath, root)) continue; - if (ContainsSymlinkSegment(root, fullPath)) + if (PathUtility.ContainsSymlinkSegment(root, fullPath)) { error = $"Error: {label} trust context may not access files through symlinked paths inside the current session directory or configured roots."; return false; @@ -153,42 +153,6 @@ private static TrustAudience ResolveAudience(ToolExecutionContext context) _ => "Public" }; - private static bool ContainsSymlinkSegment(string allowedRoot, string fullPath) - { - var relativePath = Path.GetRelativePath(allowedRoot, fullPath); - if (string.IsNullOrWhiteSpace(relativePath) || relativePath == ".") - return false; - - var segments = relativePath.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries); - var currentPath = allowedRoot; - - foreach (var segment in segments) - { - currentPath = Path.Combine(currentPath, segment); - if (!File.Exists(currentPath) && !Directory.Exists(currentPath)) - continue; - - try - { - var attributes = File.GetAttributes(currentPath); - if ((attributes & FileAttributes.ReparsePoint) != 0) - return true; - } - catch (IOException) - { - return true; - } - catch (UnauthorizedAccessException) - { - return true; - } - } - - return false; - } - internal enum AccessKind { Read, diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 99544a7a..d233b729 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -297,35 +297,35 @@ private ToolAccessDecision CheckApprovalGate( return ToolAccessDecision.Allow(); } - // Approval prompts carry three related views of the invocation: + // Approval prompts carry two views of the invocation: // - `patterns`: the exact blocked units shown to the user and reused by // approve-once retries. - // - `approvalEntries`: what broader B/C approvals actually record and - // later compare against. For shell this prefers reusable directory - // roots and falls back to the exact unit when no reusable roots exist. - // - `directoryRoots`: the human-facing root list, surfaced to the user - // in the channel-specific message body (Slack section block, Discord - // summary line). Button labels stay fixed; runtime values like paths - // never enter button text because Slack caps button text at 76 chars - // and Discord at 80, and channel-agnostic policy cannot enforce - // channel-specific length budgets. + // - `candidateVerbs`: the verb chains evaluated against persisted + // ApprovalEntry records by the gate. The directory half of each + // (verb, directory) pair comes from ToolExecutionContext.Cwd and is + // not extracted from arguments. Button labels stay fixed; runtime + // values like paths never enter button text because Slack caps + // button text at 76 chars and Discord at 80. + // + // DirectoryRoots is left empty for the section 1–6 commit chain. + // Section 7's prompt redesign removes the field entirely as part of + // the new "Approve in ?" header. var patterns = matcher.ExtractPatterns(toolName, arguments); - var approvalEntries = matcher.ExtractApprovalEntries(toolName, arguments); - var directoryRoots = matcher.ExtractDirectoryRoots(toolName, arguments); + var candidateVerbs = matcher.ExtractCandidateVerbs(toolName, arguments); var displayText = matcher.FormatForDisplay(toolName, arguments); var approvalContext = new ToolApprovalContext( toolName.Value, displayText, patterns, - approvalEntries, + candidateVerbs, [ new ToolApprovalOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ], - [.. directoryRoots.Select(static x => x.DisplayPath)]); + DirectoryRoots: []); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -472,10 +472,13 @@ public sealed record ToolApprovalContext( string ToolName, string DisplayText, IReadOnlyList Patterns, - IReadOnlyList ApprovalEntries, + // Candidate verb chains evaluated against persisted ApprovalEntry records. + // The directory half of each (verb, directory) pair comes from the + // candidate's cwd in ToolExecutionContext, not from extraction. + IReadOnlyList CandidateVerbs, IReadOnlyList Options, - // Human-facing roots shown in the prompt. Approval lookups use - // ApprovalEntries instead so display formatting never leaks into matching. + // Always empty after the v2 cutover; section 7's prompt redesign removes + // the field as part of the new cwd-in-header layout. IReadOnlyList DirectoryRoots); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs index e0969105..681dfc8c 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -26,7 +26,7 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) foreach (var pattern in msg.Patterns) { - if (!IsApproved(msg.SessionId, msg.Audience, msg.ToolName, pattern)) + if (!IsApproved(msg.SessionId, msg.Audience, msg.ToolName, pattern, msg.Cwd)) unapproved.Add(pattern); } @@ -41,11 +41,13 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) if (msg.Persistent) { - // Until section 2 lands the v2 directory-aware matcher, the - // runtime treats every persisted grant as a global wildcard: - // verb-only, directory null. The new prompt UX in section 7 - // will route folder-scoped clicks through a different add - // path that supplies a concrete directory. + // Until section 7's prompt redesign supplies an explicit + // scope from the user's button click, the runtime persists + // every "Always"-style grant as a global wildcard + // (verb, null). Section 7 plumbs the per-button scope and + // produces folder-scoped (verb, cwd) entries for the + // "Always here" path while keeping (verb, null) for + // "Always anywhere". _persistentStore?.AddApproval( msg.Audience, msg.ToolName.Value, @@ -60,25 +62,19 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) public static Props CreateProps(ToolApprovalStore? persistentStore = null) => Props.Create(() => new ToolApprovalActor(persistentStore)); - private bool IsApproved(SessionId? sessionId, TrustAudience audience, ToolName toolName, string pattern) + private bool IsApproved(SessionId? sessionId, TrustAudience audience, ToolName toolName, string candidateVerb, string? cwd) { - if (sessionId.HasValue && IsSessionApproved(sessionId.Value, audience, toolName, pattern)) + if (sessionId.HasValue && IsSessionApproved(sessionId.Value, audience, toolName, candidateVerb)) return true; if (_persistentStore is null) return false; - // Section 1 interim: surface verb chains from the typed store so the - // existing string-based matcher keeps working. Section 2 swaps this - // for a directory-aware matcher that consumes ApprovalEntry directly - // and consults the candidate's cwd from ToolExecutionContext. - var persistedVerbs = _persistentStore - .GetApprovedEntries(audience, toolName.Value) - .Select(e => e.Verb); - return MatchesApprovedEntry(toolName, pattern, persistedVerbs); + var approved = _persistentStore.GetApprovedEntries(audience, toolName.Value); + return MatchesPersistedEntry(toolName, candidateVerb, cwd, approved); } - private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, ToolName toolName, string pattern) + private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, ToolName toolName, string candidateVerb) { // Walk up the scope chain: sub-agent scopes inherit parent session approvals. // Scope format: "{parentSessionId}/subagent/{name}/{runId}" — parent is the prefix before "/subagent/". @@ -87,8 +83,8 @@ private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, Tool { var sessionKey = BuildSessionKey((SessionId)scopeId, audience); if (_sessionApprovals.TryGetValue(sessionKey, out var toolMap) - && toolMap.TryGetValue(toolName.Value, out var patterns) - && MatchesApprovedEntry(toolName, pattern, patterns)) + && toolMap.TryGetValue(toolName.Value, out var verbs) + && verbs.Contains(candidateVerb)) { return true; } @@ -103,7 +99,7 @@ private bool IsSessionApproved(SessionId sessionId, TrustAudience audience, Tool return false; } - private void AddSessionApproval(SessionId sessionId, TrustAudience audience, ToolName toolName, string pattern) + private void AddSessionApproval(SessionId sessionId, TrustAudience audience, ToolName toolName, string candidateVerb) { var sessionKey = BuildSessionKey(sessionId, audience); if (!_sessionApprovals.TryGetValue(sessionKey, out var toolMap)) @@ -112,19 +108,23 @@ private void AddSessionApproval(SessionId sessionId, TrustAudience audience, Too _sessionApprovals[sessionKey] = toolMap; } - if (!toolMap.TryGetValue(toolName.Value, out var patterns)) + if (!toolMap.TryGetValue(toolName.Value, out var verbs)) { - patterns = new HashSet(StringComparer.OrdinalIgnoreCase); - toolMap[toolName.Value] = patterns; + // Session approvals use the same platform-correct comparer as the + // persistent store (Ordinal on POSIX, OrdinalIgnoreCase on Windows) + // so a grant for `git` cannot be redeemed by a planted `Git` + // earlier in $PATH on case-sensitive filesystems. + verbs = new HashSet(ToolApprovalEntryComparer.Comparer); + toolMap[toolName.Value] = verbs; } - patterns.Add(pattern); + verbs.Add(candidateVerb); } - private static bool MatchesApprovedEntry(ToolName toolName, string candidate, IEnumerable approvedEntries) + private static bool MatchesPersistedEntry(ToolName toolName, string candidateVerb, string? cwd, IReadOnlyList approved) => string.Equals(toolName.Value, ShellTool.ToolName, StringComparison.Ordinal) - ? ApprovalPatternMatching.MatchesShellApprovalEntry(candidate, approvedEntries) - : ApprovalPatternMatching.MatchesAny(candidate, approvedEntries); + ? ApprovalPatternMatching.MatchesShellApproval(candidateVerb, cwd, approved) + : ApprovalPatternMatching.MatchesAny(candidateVerb, approved); private static string BuildSessionKey(SessionId sessionId, TrustAudience audience) => $"{sessionId.Value}|{audience.ToWireValue()}"; diff --git a/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs b/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs index 579004e6..ab7db027 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -13,7 +13,8 @@ internal sealed record GetUnapprovedPatterns( SessionId? SessionId, TrustAudience Audience, ToolName ToolName, - IReadOnlyList Patterns); + IReadOnlyList Patterns, + string? Cwd); internal sealed record UnapprovedPatternsResponse(IReadOnlyList Patterns); @@ -22,4 +23,5 @@ internal sealed record RecordToolApproval( TrustAudience Audience, ToolName ToolName, IReadOnlyList Patterns, - bool Persistent); + bool Persistent, + string? Cwd); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index 2a343ff1..1988a5d9 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -266,7 +266,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() DisplayText = "git push origin main", RequesterSenderId = "device-1", Patterns = ["git push"], - ApprovalEntries = ["git push"], + CandidateVerbs = ["git push"], DirectoryRoots = [], Options = [ @@ -282,7 +282,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("approval", dto.InteractionKind); Assert.Equal("git push origin main", dto.InteractionDisplayText); Assert.Equal("device-1", dto.RequesterSenderId); - Assert.Equal(["git push"], dto.InteractionApprovalEntries); + Assert.Equal(["git push"], dto.InteractionCandidateVerbs); Assert.Equal([], dto.InteractionDirectoryRoots ?? []); var roundTripped = DaemonClient.FromDto(dto); @@ -292,7 +292,7 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("git push origin main", result.DisplayText); Assert.Equal("device-1", result.RequesterSenderId); Assert.Equal(["git push"], result.Patterns); - Assert.Equal(["git push"], result.ApprovalEntries); + Assert.Equal(["git push"], result.CandidateVerbs); Assert.Equal([], result.DirectoryRoots); Assert.Equal(4, result.Options.Count); } diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index 142549eb..e18c6b4a 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -1,8 +1,9 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Configuration; using Netclaw.Tools; using Xunit; @@ -12,20 +13,6 @@ public sealed class ShellApprovalMatcherTests { private readonly ShellApprovalMatcher _matcher = ShellApprovalMatcher.Instance; - public static TheoryData PlatformDirectoryMatchCases - { - get - { - var data = new TheoryData(); - if (OperatingSystem.IsWindows()) - data.Add(@"C:\Users\petabridge\.netclaw\logs\", @"type C:\Users\petabridge\.netclaw\logs\crash.log", true); - else - data.Add("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/logs/crash.log", true); - - return data; - } - } - private static Dictionary Args(string command) => new() { ["Command"] = command }; private static Dictionary Args(string command, string workingDirectory) @@ -35,6 +22,9 @@ public static TheoryData PlatformDirectoryMatchCases ["WorkingDirectory"] = workingDirectory }; + private static ApprovalEntry Verb(string verb) => new() { Verb = verb, Directory = null }; + private static ApprovalEntry InDir(string verb, string dir) => new() { Verb = verb, Directory = dir }; + [Fact] public void ExtractPatterns_simple_command() { @@ -54,16 +44,6 @@ public void ExtractPatterns_compound_command() Assert.Contains("git push", patterns); } - [Fact] - public void ExtractPatterns_deduplicates() - { - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), - Args("git push && git push --tags")); - Assert.Equal(2, patterns.Count); - Assert.Contains("git push", patterns); - Assert.Contains("git push --tags", patterns); - } - [Fact] public void ExtractPatterns_recurses_into_bash_c_wrapper() { @@ -73,16 +53,6 @@ public void ExtractPatterns_recurses_into_bash_c_wrapper() Assert.Equal("git push --force", patterns[0]); } - [Fact] - public void ExtractPatterns_batches_outer_and_inner_segments() - { - var patterns = _matcher.ExtractPatterns(new ToolName("shell_execute"), Args("echo ok && bash -c \"git push --force\"")); - - Assert.Equal(2, patterns.Count); - Assert.Contains("echo ok", patterns); - Assert.Contains("git push --force", patterns); - } - [Fact] public void ExtractPatterns_empty_command() { @@ -91,130 +61,116 @@ public void ExtractPatterns_empty_command() } [Fact] - public void IsApproved_all_patterns_approved() - { - var approved = new[] { "git add .", "git commit -m fix", "git push" }; - Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), - Args("git add . && git commit -m fix && git push"), approved)); - } - - [Fact] - public void IsApproved_one_pattern_unapproved() - { - var approved = new[] { "git add .", "git push" }; - Assert.False(_matcher.IsApproved(new ToolName("shell_execute"), - Args("git add . && git commit -m fix && git push"), approved)); - } - - [Theory] - [InlineData("git push", "git push", true)] - [InlineData("git push", "git push origin main", false)] - [InlineData("gh", "gh --help", false)] - [InlineData("/home/user/.netclaw/logs/", "grep timeout /home/user/.netclaw/logs/crash.log", true)] - [InlineData("/home/user/.netclaw/logs/", "cat /home/user/.netclaw/config/secret.json", false)] - public void IsApproved_pattern_matching(string pattern, string command, bool expected) - { - var approved = new[] { pattern }; - Assert.Equal(expected, _matcher.IsApproved(new ToolName("shell_execute"), Args(command), approved)); - } - - [Theory] - [MemberData(nameof(PlatformDirectoryMatchCases))] - public void IsApproved_platform_specific_directory_root_matching(string pattern, string command, bool expected) + public void ExtractCandidateVerbs_collapses_to_verb_chains_only() { - var approved = new[] { pattern }; - Assert.Equal(expected, _matcher.IsApproved(new ToolName("shell_execute"), Args(command), approved)); + // Pure verb chains, no normalized commands or directory roots — the + // v2 matcher leaves the directory half of approval pairs to the cwd. + var verbs = _matcher.ExtractCandidateVerbs( + new ToolName("shell_execute"), + Args("git add . && git commit -m fix && git push")); + Assert.Equal(3, verbs.Count); + Assert.Contains("git add", verbs); + Assert.Contains("git commit", verbs); + Assert.Contains("git push", verbs); } [Fact] - public void IsApproved_recurses_into_bash_c_wrapper() + public void ExtractCandidateVerbs_path_aware_verb_appends_first_argument() { - var approved = new[] { "git push --force" }; - - Assert.True(_matcher.IsApproved(new ToolName("shell_execute"), - Args("bash -c \"git push --force\""), approved)); + var verbs = _matcher.ExtractCandidateVerbs( + new ToolName("shell_execute"), + Args("cat /home/user/.netclaw/logs/crash.log")); + Assert.Single(verbs); + Assert.Contains( + verbs, + v => v.Replace('\\', '/').Equals("cat /home/user/.netclaw/logs/crash.log", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void ExtractPatterns_normalize_paths_but_keep_full_unit_shape() + public void IsApproved_global_wildcard_matches_anywhere() { - var patterns = _matcher.ExtractPatterns( + var approved = new[] { Verb("git push"), Verb("git add"), Verb("git commit") }; + Assert.True(_matcher.IsApproved( new ToolName("shell_execute"), - Args("cat /etc/hosts && git push origin main")); - Assert.Equal(2, patterns.Count); - Assert.Contains("cat /etc/hosts", patterns); - Assert.Contains("git push origin main", patterns); + Args("git add . && git commit -m fix && git push"), + approved, + cwd: "/anywhere")); } [Fact] - public void ExtractPatterns_keep_pipeline_together_as_one_unit() + public void IsApproved_one_verb_unapproved_returns_false() { - var patterns = _matcher.ExtractPatterns( + var approved = new[] { Verb("git add"), Verb("git push") }; + Assert.False(_matcher.IsApproved( new ToolName("shell_execute"), - Args("cat /var/log/syslog | grep error")); - Assert.Single(patterns); - Assert.Equal("cat /var/log/syslog | grep error", patterns[0]); + Args("git add . && git commit -m fix && git push"), + approved, + cwd: null)); } [Fact] - public void FormatForDisplay_returns_command() + public void IsApproved_folder_scoped_entry_matches_when_cwd_is_under_directory() { - var display = _matcher.FormatForDisplay(new ToolName("shell_execute"), Args("git push origin main")); - Assert.Equal("git push origin main", display); - } - - // ── ExtractDirectoryRoots / ExtractApprovalEntries ── - - [Theory] - [MemberData(nameof(PlatformDirectoryMatchCases))] - public void ExtractDirectoryRoots_simple_path_command(string expectedRoot, string command, bool _) - { - var roots = _matcher.ExtractDirectoryRoots( - new ToolName("shell_execute"), - Args(command)); - Assert.Single(roots); - Assert.Equal(expectedRoot, roots[0].ComparisonRoot); + var tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var sub = Path.Combine(tempRoot, "sub"); + Directory.CreateDirectory(sub); + try + { + // Use a non-path-aware verb so the candidate stays a pure verb + // chain ("git status"); path-aware verbs (cat, grep, etc.) append + // their first positional argument which would not match a bare + // verb in the approved entry. + var approved = new[] { InDir("git status", tempRoot) }; + Assert.True(_matcher.IsApproved( + new ToolName("shell_execute"), + Args("git status"), + approved, + cwd: sub)); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } } [Fact] - public void ExtractDirectoryRoots_pipeline_and_multiple_verbs_share_same_root() + public void IsApproved_folder_scoped_entry_does_not_match_when_cwd_is_outside() { - var roots = _matcher.ExtractDirectoryRoots( + var approved = new[] { InDir("grep", "/home/user/repos/foo") }; + Assert.False(_matcher.IsApproved( new ToolName("shell_execute"), - Args("grep 'error' /home/user/.netclaw/logs/app.log | wc -l")); - Assert.Single(roots); - Assert.Equal("/home/user/.netclaw/logs/", roots[0].ComparisonRoot.Replace('\\', '/')); + Args("grep error file.log"), + approved, + cwd: "/etc")); } [Fact] - public void ExtractDirectoryRoots_returns_empty_when_no_reusable_roots_exist() + public void IsApproved_folder_scoped_entry_requires_concrete_cwd() { - var roots = _matcher.ExtractDirectoryRoots( + var approved = new[] { InDir("grep", "/home/user/repos/foo") }; + Assert.False(_matcher.IsApproved( new ToolName("shell_execute"), - Args("git push origin main")); - Assert.Empty(roots); + Args("grep error file.log"), + approved, + cwd: null)); } [Fact] - public void ExtractApprovalEntries_use_roots_when_available() + public void IsApproved_recurses_into_bash_c_wrapper() { - var entries = _matcher.ExtractApprovalEntries( + var approved = new[] { Verb("git push") }; + Assert.True(_matcher.IsApproved( new ToolName("shell_execute"), - Args("grep 'error' /home/user/.netclaw/logs/app.log | wc -l")); - - Assert.Single(entries); - Assert.Equal("/home/user/.netclaw/logs/", entries[0].Replace('\\', '/')); + Args("bash -c \"git push --force\""), + approved, + cwd: null)); } [Fact] - public void ExtractApprovalEntries_fall_back_to_exact_unit_when_no_roots_exist() + public void FormatForDisplay_returns_command() { - var entries = _matcher.ExtractApprovalEntries( - new ToolName("shell_execute"), - Args("cat /home/user/.netclaw/logs/crash.log && git push origin main")); - Assert.Equal(2, entries.Count); - Assert.Contains("/home/user/.netclaw/logs/", entries.Select(p => p.Replace('\\', '/'))); - Assert.Contains("git push origin main", entries); + var display = _matcher.FormatForDisplay(new ToolName("shell_execute"), Args("git push origin main")); + Assert.Equal("git push origin main", display); } } @@ -222,6 +178,8 @@ public sealed class DefaultApprovalMatcherTests { private readonly DefaultApprovalMatcher _matcher = DefaultApprovalMatcher.Instance; + private static ApprovalEntry Verb(string verb) => new() { Verb = verb, Directory = null }; + [Fact] public void ExtractPatterns_returns_tool_name() { @@ -233,12 +191,20 @@ public void ExtractPatterns_returns_tool_name() [Fact] public void IsApproved_matches_exact_tool_name() { - Assert.True(_matcher.IsApproved(new ToolName("mcp:memorizer:store"), null, ["mcp:memorizer:store"])); + Assert.True(_matcher.IsApproved( + new ToolName("mcp:memorizer:store"), + null, + [Verb("mcp:memorizer:store")], + cwd: null)); } [Fact] public void IsApproved_no_match() { - Assert.False(_matcher.IsApproved(new ToolName("mcp:memorizer:store"), null, ["mcp:memorizer:get"])); + Assert.False(_matcher.IsApproved( + new ToolName("mcp:memorizer:store"), + null, + [Verb("mcp:memorizer:get")], + cwd: null)); } } diff --git a/src/Netclaw.Security/ApprovalPatternMatching.cs b/src/Netclaw.Security/ApprovalPatternMatching.cs index a79977de..462c579a 100644 --- a/src/Netclaw.Security/ApprovalPatternMatching.cs +++ b/src/Netclaw.Security/ApprovalPatternMatching.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -8,10 +8,11 @@ namespace Netclaw.Security; /// -/// Shared approval matching helpers. Shell approvals use -/// , which only compares exact approval -/// units and normalized directory roots. Other tools continue to use -/// for exact and prefix-style matching. +/// Approval matching helpers that consume the v2 typed +/// store. Shell approvals use +/// which evaluates the candidate's verb +/// chain together with its cwd against each entry's (verb, directory) +/// pair. Other tools use for verb-only matching. /// public static class ApprovalPatternMatching { @@ -20,100 +21,70 @@ public static class ApprovalPatternMatching // ToolApprovalEntryComparer for the rationale. private static StringComparison ApprovalEntryComparison => ToolApprovalEntryComparer.Comparison; - public static bool MatchesShellApprovalEntry(string candidate, IEnumerable approvedEntries) + /// + /// Returns true when contains an entry + /// whose verb equals AND whose directory + /// is either null (the global wildcard) or an ancestor of + /// with no symlink segments along the path between + /// the two. + /// + /// The symlink-segment guard prevents a planted symlink under an approved + /// directory from being used to redirect the cwd to a path outside that + /// directory: walks each + /// component from the approved root toward the cwd and refuses the match + /// if any segment is a reparse point. + /// + public static bool MatchesShellApproval( + string candidateVerb, + string? cwd, + IEnumerable approvedEntries) { - // Shell approvals never widen by verb prefix here. Reusable entries are - // either exact normalized units or normalized directory roots. - foreach (var approved in approvedEntries) + foreach (var entry in approvedEntries) { - if (string.Equals(candidate, approved, ApprovalEntryComparison)) - return true; - - if (IsDirectoryRootEntry(candidate) && IsDirectoryRootEntry(approved) && MatchesDirectoryRoot(candidate, approved)) - return true; - } - - return false; - } + if (!string.Equals(entry.Verb, candidateVerb, ApprovalEntryComparison)) + continue; - public static bool MatchesAny(string candidate, IEnumerable approvedPatterns) - { - foreach (var approved in approvedPatterns) - { - if (string.Equals(candidate, approved, ApprovalEntryComparison)) + // Global wildcard: matches any cwd by definition. + if (entry.Directory is null) return true; - if (!approved.Contains(' ', StringComparison.Ordinal)) + // Folder-scoped entry requires a concrete cwd to evaluate. + if (string.IsNullOrEmpty(cwd)) continue; - // Directory-scoped patterns: "verb /dir/" matches "verb /dir/file.txt" - if (approved.EndsWith('/') && MatchesDirectoryScope(candidate, approved)) - return true; + try + { + if (!PathUtility.IsWithinRoot(cwd, entry.Directory)) + continue; + + if (PathUtility.ContainsSymlinkSegment(entry.Directory, cwd)) + continue; - // Multi-token patterns prefix-match on a space boundary. Single-token - // patterns remain exact-only so grants do not silently widen from - // "cat" to every path-bearing cat invocation. - if (candidate.Length > approved.Length - && candidate[approved.Length] == ' ' - && candidate.StartsWith(approved, ApprovalEntryComparison)) return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException) + { + continue; + } } return false; } - private static bool MatchesDirectoryScope(string candidate, string approvedDirPattern) - { - var approvedSpaceIdx = approvedDirPattern.IndexOf(' ', StringComparison.Ordinal); - if (approvedSpaceIdx < 0) - return false; - - var approvedVerb = approvedDirPattern[..approvedSpaceIdx]; - var approvedDir = approvedDirPattern[(approvedSpaceIdx + 1)..].TrimEnd('/'); - - var candidateSpaceIdx = candidate.IndexOf(' ', StringComparison.Ordinal); - if (candidateSpaceIdx < 0) - return false; - - var candidateVerb = candidate[..candidateSpaceIdx]; - var candidatePath = candidate[(candidateSpaceIdx + 1)..]; - - if (!string.Equals(approvedVerb, candidateVerb, ApprovalEntryComparison)) - return false; - - try - { - return PathUtility.IsWithinRoot(candidatePath, approvedDir); - } - catch (Exception ex) when (ex is ArgumentException or IOException) - { - return false; - } - } - - private static bool MatchesDirectoryRoot(string candidateRoot, string approvedRoot) + /// + /// Returns true when contains an entry + /// whose verb equals . Used by non-shell + /// matchers where the directory half of an entry is not meaningful — the + /// candidate is the tool name and a verb match alone authorizes. + /// + public static bool MatchesAny(string candidate, IEnumerable approvedEntries) { - try - { - return PathUtility.IsWithinRoot( - candidateRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - approvedRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - } - catch (Exception ex) when (ex is ArgumentException or IOException) + foreach (var approved in approvedEntries) { - return false; + if (string.Equals(approved.Verb, candidate, ApprovalEntryComparison)) + return true; } - } - private static bool IsDirectoryRootEntry(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - if (!(value.EndsWith(Path.DirectorySeparatorChar) || value.EndsWith(Path.AltDirectorySeparatorChar))) - return false; - - var trimmed = value.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return Path.IsPathRooted(trimmed); + return false; } } diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 4d061541..17340754 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -1,8 +1,9 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Configuration; using Netclaw.Tools; namespace Netclaw.Security; @@ -33,36 +34,47 @@ public interface IToolApprovalMatcher bool IsFailClosedOnPersonal(ToolName toolName, IDictionary? arguments); /// - /// Extracts the exact approval patterns shown to the user. - /// For shell: normalized approval units. For other tools: the tool name. + /// Returns the exact display patterns shown to the user in the approval + /// prompt body. For shell these are normalized approval units (verb + /// chain plus any path-aware first argument); for other tools the tool + /// name. Reused as the retry-exact key for one-shot approvals. /// IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary? arguments); /// - /// Extracts the reusable approval entries consulted for session and persistent - /// approval checks. + /// Returns the candidate verb chains evaluated against persisted + /// records by the gate. The directory half of + /// each (verb, directory) pair comes from the candidate's + /// , not from extraction. For shell + /// these are pure verb chains (e.g., git push, grep); for + /// other tools typically [toolName.Value]. /// - IReadOnlyList ExtractApprovalEntries(ToolName toolName, IDictionary? arguments); + IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionary? arguments); /// - /// Checks if the tool call matches any approved pattern. + /// Returns true when every candidate verb chain finds a matching + /// under the supplied . + /// A folder-scoped entry matches when its directory contains the cwd and + /// no symlink segments exist between the two; a global-wildcard entry + /// (directory: null) matches any cwd. /// - bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns); + bool IsApproved( + ToolName toolName, + IDictionary? arguments, + IReadOnlyList approvedEntries, + string? cwd); /// - /// Formats the tool call for display in the approval prompt. + /// Formats the tool call for display in the approval prompt header. /// string FormatForDisplay(ToolName toolName, IDictionary? arguments); - - /// - /// Extracts reusable directory approval roots for the invocation. - /// - IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments); } /// -/// Shell-specific approval matcher using approval units bounded by &&, ||, and ;. -/// Pipelines remain inside the same approval unit. +/// Shell-specific approval matcher. Verb-chain extraction stops at the first +/// flag, path, or URL token; && / || / ; split +/// approval units while | stays inside one unit; bash -c / +/// sh -c wrappers recurse into the inner command. /// public sealed class ShellApprovalMatcher : IToolApprovalMatcher { @@ -91,61 +103,43 @@ public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) + public IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionary? arguments) { var command = GetCommand(arguments); if (string.IsNullOrWhiteSpace(command)) return []; - // Shell approvals intentionally keep two parallel views of the same - // invocation: - // - // 1. `Patterns` are the exact normalized approval units shown in the - // prompt and reused only for approve-once retries. - // 2. `ApprovalEntries` are the broader entries consulted for session - // and persistent approval reuse. - // - // The directory-scoping algorithm starts here. We first break the - // command into approval units: &&, ||, and ; split into separate units, - // while pipelines joined by | stay together as one piece of work. - // `bash -c` / `sh -c` wrappers recurse into the inner command and feed - // those inner units back through the same logic. - // - // For each unit we try to derive reusable local directory roots. If we - // can do that safely, those roots become the approval entries recorded - // for B/C approvals. If we cannot, we fall back to the exact normalized - // unit. That keeps approve-once exact while letting broader approvals - // reuse local directory access without introducing verb allowlists. - var workingDirectory = GetWorkingDirectory(arguments); - var entries = new HashSet(StringComparer.OrdinalIgnoreCase); + // v2 candidate extraction: verb chains only. The directory half of + // each (verb, directory) approval pair is the candidate's cwd from + // ToolExecutionContext, evaluated by the gate. v1's mingling of verb + // chains, normalized commands, and bare directory roots in this same + // list was the source of the unreviewable approval store the v2 + // schema set out to fix; we no longer fall back to anything other + // than the verb chain. + var verbs = new HashSet(StringComparer.OrdinalIgnoreCase); TraverseApprovalUnits(command, unit => { - var roots = ShellTokenizer.ExtractDirectoryRoots(unit, workingDirectory); - if (roots.Count > 0) - { - foreach (var root in roots) - entries.Add(root.ComparisonRoot); - return; - } - - var normalized = ShellTokenizer.NormalizeApprovalUnit(unit, workingDirectory); - if (!string.IsNullOrEmpty(normalized)) - entries.Add(normalized); + var verb = ShellTokenizer.ExtractVerbChain(unit); + if (!string.IsNullOrEmpty(verb)) + verbs.Add(verb); }); - return entries.ToList(); + return verbs.ToList(); } - public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) + public bool IsApproved( + ToolName toolName, + IDictionary? arguments, + IReadOnlyList approvedEntries, + string? cwd) { - var approvalEntries = ExtractApprovalEntries(toolName, arguments); - if (approvalEntries.Count == 0) - return true; // Empty command, nothing to approve + var verbs = ExtractCandidateVerbs(toolName, arguments); + if (verbs.Count == 0) + return true; // empty command, nothing to approve - var approvedList = approvedPatterns as IReadOnlyList ?? approvedPatterns.ToList(); - foreach (var entry in approvalEntries) + foreach (var verb in verbs) { - if (!ApprovalPatternMatching.MatchesShellApprovalEntry(entry, approvedList)) + if (!ApprovalPatternMatching.MatchesShellApproval(verb, cwd, approvedEntries)) return false; } @@ -153,29 +147,7 @@ public bool IsApproved(ToolName toolName, IDictionary? argument } public string FormatForDisplay(ToolName toolName, IDictionary? arguments) - { - return GetCommand(arguments) ?? "(empty command)"; - } - - public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) - { - var command = GetCommand(arguments); - if (string.IsNullOrWhiteSpace(command)) - return []; - - var roots = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - TraverseApprovalUnits(command, unit => - { - foreach (var root in ShellTokenizer.ExtractDirectoryRoots(unit, GetWorkingDirectory(arguments))) - { - if (seen.Add(root.ComparisonRoot)) - roots.Add(root); - } - }); - - return roots; - } + => GetCommand(arguments) ?? "(empty command)"; private static string? GetCommand(IDictionary? arguments) { @@ -222,7 +194,8 @@ private static void TraverseApprovalUnits(string command, Action visitUn /// /// Default approval matcher for non-shell tools. Approval is at the tool-name -/// level — either the tool is approved or it isn't. +/// level — either the tool is approved or it isn't. Directory scoping does +/// not apply. /// public sealed class DefaultApprovalMatcher : IToolApprovalMatcher { @@ -235,29 +208,18 @@ public bool IsFailClosedOnPersonal(ToolName toolName, IDictionary false; public IReadOnlyList ExtractPatterns(ToolName toolName, IDictionary? arguments) - { - return [toolName.Value]; - } + => [toolName.Value]; - public IReadOnlyList ExtractApprovalEntries(ToolName toolName, IDictionary? arguments) - => ExtractPatterns(toolName, arguments); + public IReadOnlyList ExtractCandidateVerbs(ToolName toolName, IDictionary? arguments) + => [toolName.Value]; - public bool IsApproved(ToolName toolName, IDictionary? arguments, IEnumerable approvedPatterns) - { - foreach (var approved in approvedPatterns) - { - if (string.Equals(toolName.Value, approved, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } + public bool IsApproved( + ToolName toolName, + IDictionary? arguments, + IReadOnlyList approvedEntries, + string? cwd) + => ApprovalPatternMatching.MatchesAny(toolName.Value, approvedEntries); public string FormatForDisplay(ToolName toolName, IDictionary? arguments) - { - return toolName.Value; - } - - public IReadOnlyList ExtractDirectoryRoots(ToolName toolName, IDictionary? arguments) - => []; + => toolName.Value; } diff --git a/src/Netclaw.Security/IToolApprovalService.cs b/src/Netclaw.Security/IToolApprovalService.cs index 0ac26696..b74a285b 100644 --- a/src/Netclaw.Security/IToolApprovalService.cs +++ b/src/Netclaw.Security/IToolApprovalService.cs @@ -10,11 +10,20 @@ namespace Netclaw.Security; public interface IToolApprovalService { + /// + /// Returns the subset of (candidate verb chains) + /// that are not approved for the given audience and tool. The + /// is the candidate's resolved working directory; it + /// is used by the v2 matcher to evaluate folder-scoped + /// records. May be null + /// for tools whose approvals are not directory-anchored. + /// Task> GetUnapprovedPatternsAsync( string? sessionId, TrustAudience audience, ToolName toolName, IReadOnlyList patterns, + string? cwd, CancellationToken ct = default); Task RecordApprovalAsync( @@ -23,5 +32,6 @@ Task RecordApprovalAsync( ToolName toolName, IReadOnlyList patterns, bool persistent, + string? cwd, CancellationToken ct = default); } diff --git a/src/Netclaw.Security/PathUtility.cs b/src/Netclaw.Security/PathUtility.cs index 10f16663..7768d1bf 100644 --- a/src/Netclaw.Security/PathUtility.cs +++ b/src/Netclaw.Security/PathUtility.cs @@ -147,4 +147,52 @@ public static string ExpandHome(string path) var expanded = ExpandHome(path); return TryNormalize(expanded, workingDirectory, out var normalized) ? normalized : null; } + + /// + /// Returns true when any segment of , walking from + /// outward, is a filesystem reparse point + /// (symbolic link, junction, or other reparse target). Used by the approval + /// gate and file-access policy to refuse to honor a grant when the candidate + /// path's resolution depends on a symlink that could redirect the I/O outside + /// the granted root. + /// + /// Errors reading attributes are conservatively treated as a positive + /// detection: if we cannot determine whether a segment is a symlink, we + /// assume it is. + /// + public static bool ContainsSymlinkSegment(string allowedRoot, string fullPath) + { + var relativePath = Path.GetRelativePath(allowedRoot, fullPath); + if (string.IsNullOrWhiteSpace(relativePath) || relativePath == ".") + return false; + + var segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + var currentPath = allowedRoot; + + foreach (var segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!File.Exists(currentPath) && !Directory.Exists(currentPath)) + continue; + + try + { + var attributes = File.GetAttributes(currentPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + return true; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + } + + return false; + } } diff --git a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs index a59e16da..1b17dfac 100644 --- a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs +++ b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs @@ -28,17 +28,18 @@ public interface IParentApprovalBridge /// /// Emits an approval request to the parent session and waits for the user's decision. /// are the exact blocked units shown in the - /// prompt and reused for approve-once retries. - /// are the broader entries the parent session should record for B/C - /// decisions, and are the human-facing - /// roots used to explain those broader approvals in the prompt. + /// prompt and reused for approve-once retries. + /// are the verb chains the parent session should record for B/C decisions, + /// evaluated against persisted ApprovalEntry records using the + /// candidate's cwd. is empty after the + /// v2 cutover and is removed in section 7. /// Task RequestApprovalAsync( ToolCallId callId, string toolName, string displayText, IReadOnlyList patterns, - IReadOnlyList approvalEntries, + IReadOnlyList candidateVerbs, IReadOnlyList directoryRoots, CancellationToken ct); } diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index c941321e..e7c4997a 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -128,6 +128,18 @@ public IReadOnlySet OneTimeApprovedPatterns /// public string? SessionDirectory { get; } + /// + /// Resolved absolute working directory for the in-flight tool call. Set + /// by the session pipeline from the candidate tool arguments, + /// WorkingContext.ProjectDirectory, or + /// — whichever resolves first. The approval gate uses this as the directory + /// half of the candidate (verb, directory) pair when evaluating + /// folder-scoped records. + /// Null when the tool call is not directory-anchored (e.g. an in-process + /// tool like store_memory). + /// + public string? Cwd { get; set; } + /// /// File attachments registered by tools during execution. /// From beace4ae2f7b54f375314b8bd9895d8259b984a2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 17:49:27 +0000 Subject: [PATCH 05/46] feat(approvals): refuse pattern extraction for messy shell commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 3 of the approval-policy-v2 OpenSpec change. Adds a cheap structural scan to ShellTokenizer that detects bash control-flow keywords and unbalanced quotes/brackets, refuses verb-chain extraction in those cases, and plumbs an IsMessy flag through the gate and protocol so the section 7 prompt builder can show "complex command" hints and omit persistent-grant buttons. Detection (ShellTokenizer.IsMessyCompoundCommand): - Single-pass scan that tracks quote state and (), [], {} balance. - Flags any unquoted standalone token equal to one of: for, while, do, done, then, fi, case, esac. - Flags unbalanced quotes (open without close) and unbalanced brackets (close without open OR open without close). - Cheap structural only — no semantic bash parsing. Heredocs, command substitution, and process substitution are not analyzed beyond bracket balance. SplitCompoundCommand: - Returns an empty list when IsMessyCompoundCommand returns true. The matcher's ExtractCandidateVerbs and ExtractPatterns therefore both return empty for messy commands, and ShellApprovalMatcher.IsApproved short-circuits to false (cannot auto-approve what we cannot extract). Gate / protocol plumbing: - IToolApprovalMatcher gains IsMessy(toolName, args). Default-false for DefaultApprovalMatcher and FilePathApprovalMatcher; ShellApprovalMatcher delegates to ShellTokenizer.IsMessyCompoundCommand. - ToolApprovalContext gains an IsMessy bool field. - ToolInteractionRequest, SessionOutputDto (InteractionIsMessy), PendingToolInteraction, IParentApprovalBridge.RequestApprovalAsync, and ParentSessionApprovalBridge all carry the flag through. - DispatchingToolExecutor short-circuits messy invocations to RequiresApproval regardless of empty CandidateVerbs, so the user always sees the prompt for messy input. Trade-off accepted: a bare standalone `done`/`fi`/`esac` token at the end of a command (e.g. `git fetch && echo done`) is a false positive for the cheap heuristic — the user gets the "complex command" prompt (Once/Deny only) instead of the full 4-button row. The mitigation if this bites real usage is a smarter detector that requires the keyword to appear in a syntactically meaningful position; for now the trade favors a clean approval store over coverage of edge bash idioms. One existing test (SplitCompound_preserves_quoted_operators) updated accordingly to use a different sentinel word. Tests: - ShellTokenizerTests: positive cases (for/while/if/case/unbalanced quote/unbalanced bracket), negative cases (well-formed compounds, command substitution, brace expansion, trailing commands), and guards against keyword-substring false positives ("format", "fido"). SplitCompoundCommand returns empty for messy input; still splits well-formed compounds. - ShellApprovalMatcherTests: IsMessy true for control-flow, IsMessy false for well-formed; IsApproved returns false for messy commands even when every conceivable verb is approved. - All 3367 tests pass; slopwatch clean; file headers verified. --- openspec/changes/approval-policy-v2/tasks.md | 10 +- .../ParentSessionApprovalBridgeTests.cs | 1 + .../SubAgents/SubAgentActorTests.cs | 3 +- src/Netclaw.Actors/Protocol/SessionOutput.cs | 10 ++ .../Protocol/SessionOutputDto.cs | 1 + .../Protocol/SessionOutputDtoMapper.cs | 2 + .../Sessions/LlmSessionActor.cs | 6 +- .../Sessions/ParentSessionApprovalBridge.cs | 2 + .../Pipelines/SessionToolExecutionPipeline.cs | 1 + src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 1 + .../Tools/DispatchingToolExecutor.cs | 41 ++++--- .../Tools/FilePathApprovalMatcher.cs | 3 + src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 11 +- .../ShellApprovalMatcherTests.cs | 30 +++++ .../ShellTokenizerTests.cs | 80 ++++++++++++- src/Netclaw.Security/IToolApprovalMatcher.cs | 29 ++++- src/Netclaw.Security/ShellTokenizer.cs | 111 +++++++++++++++++- .../IParentApprovalBridge.cs | 1 + 18 files changed, 314 insertions(+), 29 deletions(-) diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index cb96b2a7..3d900a51 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -27,11 +27,11 @@ Both PRs sit under this single OpenSpec change. ## 3. ShellTokenizer refuses messy input -- [ ] 3.1 Add control-flow keyword detection (`for`/`while`/`do`/`done`/`then`/`fi`/`case`/`esac`) to `SplitCompoundCommand`. -- [ ] 3.2 Add unbalanced-quote/bracket detection (cheap structural scan; no full bash parser). -- [ ] 3.3 When detected, return empty verb-chain list. Do not attempt partial extraction. -- [ ] 3.4 Plumb a "messy" flag through to `ToolInteractionRequest` so the prompt builder can show the "complex command" hint and omit `This chat`/`Always here`/`Always anywhere` buttons. -- [ ] 3.5 Unit tests for: `for ... do ... done`; `while ... do ... done`; `case ... esac`; unbalanced quote; unbalanced bracket; well-formed commands still extract normally. +- [x] 3.1 Add control-flow keyword detection (`for`/`while`/`do`/`done`/`then`/`fi`/`case`/`esac`) to `SplitCompoundCommand`. +- [x] 3.2 Add unbalanced-quote/bracket detection (cheap structural scan; no full bash parser). +- [x] 3.3 When detected, return empty verb-chain list. Do not attempt partial extraction. +- [x] 3.4 Plumb a "messy" flag through to `ToolInteractionRequest` so the prompt builder can show the "complex command" hint and omit `This chat`/`Always here`/`Always anywhere` buttons. +- [x] 3.5 Unit tests for: `for ... do ... done`; `while ... do ... done`; `case ... esac`; unbalanced quote; unbalanced bracket; well-formed commands still extract normally. ## 4. ShellTool cwd default diff --git a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs index 8ff86c97..812fd12b 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ParentSessionApprovalBridgeTests.cs @@ -38,6 +38,7 @@ public async Task Bridge_preserves_requester_identity_and_adopted_context() ["grep timeout logs/app.log | wc -l"], ["/tmp/work/logs/"], ["logs/"], + isMessy: false, TestContext.Current.CancellationToken); Assert.Equal(ParentApprovalDecision.ApprovedOnce, decision); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 11ddc7e9..3d27599c 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -534,8 +534,9 @@ public Task RequestApprovalAsync( string toolName, string displayText, IReadOnlyList patterns, - IReadOnlyList approvalEntries, + IReadOnlyList candidateVerbs, IReadOnlyList directoryRoots, + bool isMessy, CancellationToken ct) { RequestCount++; diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index 5dc9189e..d4e2c0b7 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -384,6 +384,16 @@ public sealed record ToolInteractionRequest : SessionOutput /// public string? Cwd { get; init; } + /// + /// True when the invocation cannot be cleanly split into verb-chain + /// approval units — for shell, when the command contains bash control-flow + /// keywords (for/while/do/done/then/ + /// fi/case/esac) or unbalanced quotes/brackets. + /// Channel adapters render only the Once/Deny buttons and + /// surface a "complex command" hint when this is true. + /// + public bool IsMessy { get; init; } + /// Available response options (e.g., approve once, approve for this chat, approve always, deny). public required IReadOnlyList Options { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index 48d98b77..e1d016bb 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -109,6 +109,7 @@ public sealed record SessionOutputDto public List? InteractionCandidateVerbs { get; init; } public List? InteractionDirectoryRoots { get; init; } public string? InteractionCwd { get; init; } + public bool? InteractionIsMessy { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index 683600b7..4eefe239 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -182,6 +182,7 @@ public static class SessionOutputDtoMapper InteractionCandidateVerbs = [.. msg.CandidateVerbs], InteractionDirectoryRoots = [.. msg.DirectoryRoots], InteractionCwd = msg.Cwd, + InteractionIsMessy = msg.IsMessy, InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] @@ -342,6 +343,7 @@ public static SessionOutput FromDto(SessionOutputDto dto) CandidateVerbs = dto.InteractionCandidateVerbs ?? [], DirectoryRoots = dto.InteractionDirectoryRoots ?? [], Cwd = dto.InteractionCwd, + IsMessy = dto.InteractionIsMessy ?? false, Options = dto.InteractionOptions ?? [] }, _ => new ErrorOutput diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 160d25d5..63aa587a 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -757,7 +757,8 @@ private void Processing() msg.RequesterPrincipal, msg.HasAdoptedContext, msg.AdoptedSpeakerIds, - msg.Cwd); + msg.Cwd, + msg.IsMessy); PauseToolExecutionWatchdogForApprovalWait(msg.CallId); @@ -3060,7 +3061,8 @@ private sealed record PendingToolInteraction( PrincipalClassification? RequesterPrincipal, bool HasAdoptedContext, IReadOnlyList AdoptedSpeakerIds, - string? Cwd); + string? Cwd, + bool IsMessy); private void PersistAdoptedContextIfNeeded(MessageSource? source) { diff --git a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs index f971b7a7..08c99e3e 100644 --- a/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs +++ b/src/Netclaw.Actors/Sessions/ParentSessionApprovalBridge.cs @@ -49,6 +49,7 @@ public async Task RequestApprovalAsync( IReadOnlyList patterns, IReadOnlyList candidateVerbs, IReadOnlyList directoryRoots, + bool isMessy, CancellationToken ct) { var waitTask = _channel.WaitForApprovalAsync(callId, Timeout.InfiniteTimeSpan, ct); @@ -67,6 +68,7 @@ public async Task RequestApprovalAsync( Patterns = patterns, CandidateVerbs = candidateVerbs, DirectoryRoots = directoryRoots, + IsMessy = isMessy, HasAdoptedContext = _hasAdoptedContext, AdoptedSpeakerIds = _adoptedSpeakerIds, PersistedAdoptedContext = _hasAdoptedContext, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 095ad9f9..ec51279f 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -285,6 +285,7 @@ public static async Task ExecuteSingleToolAsync( PersistedAdoptedContext = source?.HasAdoptedContext ?? false, Patterns = ctx.Patterns, CandidateVerbs = ctx.CandidateVerbs, + IsMessy = ctx.IsMessy, DirectoryRoots = ctx.DirectoryRoots, Options = ctx.Options .Select(o => new ToolInteractionOption(o.Key, o.Label)) diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index ebfde098..41f1c621 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -428,6 +428,7 @@ private static async Task ExecuteToolsAsync( ctx.Patterns, ctx.CandidateVerbs, ctx.DirectoryRoots, + ctx.IsMessy, ct); if (decision is ParentApprovalDecision.ApprovedOnce diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 8180f3b8..e5a62011 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -105,20 +105,33 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall { var approvalContext = accessDecision.ApprovalContext ?? throw new InvalidOperationException("Approval decision missing approval context."); - var audience = SecurityPolicyDefaults.TryParseAudience(context?.Audience, out var parsed) - ? parsed - : SecurityPolicyDefaults.ResolveAudienceFromSessionId(context?.SessionId); - var unapproved = await _approvalService.GetUnapprovedPatternsAsync( - context?.SessionId, - audience, - new ToolName(toolCall.Name), - approvalContext.CandidateVerbs, - context?.Cwd, - ct); - - accessDecision = unapproved.Count == 0 - ? ToolAccessDecision.Allow() - : ToolAccessDecision.RequiresApproval(approvalContext); + + // Messy commands cannot be persistently approved — the matcher + // refuses to extract verb chains we could match a future + // invocation against. Always round-trip through the user, even if + // the candidate-verbs list happens to be empty for unrelated + // reasons (which would otherwise short-circuit to allow). + if (approvalContext.IsMessy) + { + accessDecision = ToolAccessDecision.RequiresApproval(approvalContext); + } + else + { + var audience = SecurityPolicyDefaults.TryParseAudience(context?.Audience, out var parsed) + ? parsed + : SecurityPolicyDefaults.ResolveAudienceFromSessionId(context?.SessionId); + var unapproved = await _approvalService.GetUnapprovedPatternsAsync( + context?.SessionId, + audience, + new ToolName(toolCall.Name), + approvalContext.CandidateVerbs, + context?.Cwd, + ct); + + accessDecision = unapproved.Count == 0 + ? ToolAccessDecision.Allow() + : ToolAccessDecision.RequiresApproval(approvalContext); + } } if (accessDecision.NeedsApproval diff --git a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs index 8ee26389..6869bd2c 100644 --- a/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs +++ b/src/Netclaw.Actors/Tools/FilePathApprovalMatcher.cs @@ -63,6 +63,9 @@ public bool IsApproved( return true; } + public bool IsMessy(ToolName toolName, IDictionary? arguments) + => false; + public string FormatForDisplay(ToolName toolName, IDictionary? arguments) { if (TryGetPath(arguments, out var path)) diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index d233b729..c81167c2 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -313,6 +313,7 @@ private ToolAccessDecision CheckApprovalGate( var patterns = matcher.ExtractPatterns(toolName, arguments); var candidateVerbs = matcher.ExtractCandidateVerbs(toolName, arguments); var displayText = matcher.FormatForDisplay(toolName, arguments); + var isMessy = matcher.IsMessy(toolName, arguments); var approvalContext = new ToolApprovalContext( toolName.Value, @@ -325,7 +326,8 @@ private ToolAccessDecision CheckApprovalGate( new ToolApprovalOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), new ToolApprovalOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) ], - DirectoryRoots: []); + DirectoryRoots: [], + IsMessy: isMessy); return ToolAccessDecision.RequiresApproval(approvalContext); } @@ -479,7 +481,12 @@ public sealed record ToolApprovalContext( IReadOnlyList Options, // Always empty after the v2 cutover; section 7's prompt redesign removes // the field as part of the new cwd-in-header layout. - IReadOnlyList DirectoryRoots); + IReadOnlyList DirectoryRoots, + // True when the invocation cannot be cleanly split into verb-chain + // approval units (bash control-flow, unbalanced quotes/brackets). Section 7 + // uses this to omit the persistent-grant buttons and surface the + // "complex command" hint. + bool IsMessy = false); public sealed record ToolApprovalOption(string Key, string Label); diff --git a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs index e18c6b4a..4fec16be 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs @@ -172,6 +172,36 @@ public void FormatForDisplay_returns_command() var display = _matcher.FormatForDisplay(new ToolName("shell_execute"), Args("git push origin main")); Assert.Equal("git push origin main", display); } + + [Fact] + public void IsMessy_true_for_bash_control_flow() + { + Assert.True(_matcher.IsMessy( + new ToolName("shell_execute"), + Args("for pid in $(pgrep netclawd); do echo $pid; done"))); + } + + [Fact] + public void IsMessy_false_for_well_formed_compound() + { + Assert.False(_matcher.IsMessy( + new ToolName("shell_execute"), + Args("git add . && git commit -m fix && git push"))); + } + + [Fact] + public void IsApproved_returns_false_for_messy_command_even_with_global_wildcards() + { + // Even if every conceivable verb is approved, a messy command never + // auto-runs: the matcher cannot extract verb chains to evaluate, and + // the prompt must offer Once/Deny only. + var approved = new[] { Verb("for"), Verb("do"), Verb("done"), Verb("echo") }; + Assert.False(_matcher.IsApproved( + new ToolName("shell_execute"), + Args("for x in 1 2 3; do echo $x; done"), + approved, + cwd: null)); + } } public sealed class DefaultApprovalMatcherTests diff --git a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs index cdcea322..e50fd987 100644 --- a/src/Netclaw.Security.Tests/ShellTokenizerTests.cs +++ b/src/Netclaw.Security.Tests/ShellTokenizerTests.cs @@ -157,10 +157,14 @@ public void SplitCompound_handles_multiple_operators() [Fact] public void SplitCompound_preserves_quoted_operators() { - var segments = ShellTokenizer.SplitCompoundCommand("echo \"a && b\" && echo done"); + // Avoid trailing "done"/"fi"/"esac" in unquoted positions — the + // section 3 messy detector flags those as control-flow keywords and + // SplitCompoundCommand returns empty. The token "finished" is a + // close stand-in that exercises the same splitter behavior. + var segments = ShellTokenizer.SplitCompoundCommand("echo \"a && b\" && echo finished"); Assert.Equal(2, segments.Count); Assert.Equal("echo \"a && b\"", segments[0]); - Assert.Equal("echo done", segments[1]); + Assert.Equal("echo finished", segments[1]); } [Fact] @@ -432,4 +436,76 @@ public void ExtractDirectoryRoots_returns_empty_when_no_reusable_roots_exist(str { Assert.Empty(ShellTokenizer.ExtractDirectoryRoots(command)); } + + // ── IsMessyCompoundCommand ── + + [Theory] + [InlineData("for pid in $(pgrep netclawd); do echo \"$pid\"; done")] + [InlineData("while read line; do echo $line; done < input.txt")] + [InlineData("if [ -f x ]; then echo y; fi")] + [InlineData("case $x in 1) echo one ;; 2) echo two ;; esac")] + [InlineData("for f in *.log; do grep ERROR \"$f\"; done")] + public void IsMessyCompoundCommand_flags_bash_control_flow(string command) + { + Assert.True(ShellTokenizer.IsMessyCompoundCommand(command)); + } + + [Theory] + [InlineData("echo \"unterminated")] + [InlineData("echo 'still open")] + [InlineData("echo $(unclosed")] + [InlineData("ls [unclosed")] + [InlineData("echo too )many close parens")] + [InlineData("echo too ]many close brackets")] + public void IsMessyCompoundCommand_flags_unbalanced_quotes_or_brackets(string command) + { + Assert.True(ShellTokenizer.IsMessyCompoundCommand(command)); + } + + [Theory] + [InlineData("git push origin main")] + [InlineData("grep error /var/log/syslog")] + [InlineData("git add . && git commit -m fix && git push")] + [InlineData("cat file.log | grep error | wc -l")] + [InlineData("echo $(date)")] + [InlineData("ls ${HOME}")] + [InlineData("find . -name '*.log' -type f")] + [InlineData("")] + [InlineData(" ")] + public void IsMessyCompoundCommand_passes_well_formed_commands(string command) + { + Assert.False(ShellTokenizer.IsMessyCompoundCommand(command)); + } + + [Fact] + public void IsMessyCompoundCommand_does_not_flag_keywords_inside_quotes() + { + // A literal "done" inside a quoted string is not a control-flow token. + Assert.False(ShellTokenizer.IsMessyCompoundCommand("echo \"done\"")); + } + + [Fact] + public void IsMessyCompoundCommand_does_not_flag_keyword_substrings() + { + // "format" contains "for" but is not the for-loop opener. + Assert.False(ShellTokenizer.IsMessyCompoundCommand("python format.py")); + // "fido" contains "fi" but is not the if-block closer. + Assert.False(ShellTokenizer.IsMessyCompoundCommand("echo fido")); + } + + [Theory] + [InlineData("for pid in $(pgrep netclawd); do echo \"$pid\"; done")] + [InlineData("while read line; do echo $line; done")] + [InlineData("if [ -f x ]; then echo y; fi")] + public void SplitCompoundCommand_returns_empty_for_messy_input(string command) + { + Assert.Empty(ShellTokenizer.SplitCompoundCommand(command)); + } + + [Fact] + public void SplitCompoundCommand_still_splits_well_formed_compounds() + { + var segments = ShellTokenizer.SplitCompoundCommand("git add . && git push"); + Assert.Equal(2, segments.Count); + } } diff --git a/src/Netclaw.Security/IToolApprovalMatcher.cs b/src/Netclaw.Security/IToolApprovalMatcher.cs index 17340754..1e9dcfbf 100644 --- a/src/Netclaw.Security/IToolApprovalMatcher.cs +++ b/src/Netclaw.Security/IToolApprovalMatcher.cs @@ -64,6 +64,16 @@ bool IsApproved( IReadOnlyList approvedEntries, string? cwd); + /// + /// Returns true when the invocation cannot be cleanly split into + /// verb-chain approval units — for shell, when the command contains bash + /// control-flow keywords or unbalanced quotes/brackets. Approval prompts + /// for messy invocations omit persistent-grant buttons and surface a + /// "complex command" hint; the user can still grant a single retry via + /// Once. Non-shell matchers SHALL return false. + /// + bool IsMessy(ToolName toolName, IDictionary? arguments); + /// /// Formats the tool call for display in the approval prompt header. /// @@ -133,9 +143,20 @@ public bool IsApproved( IReadOnlyList approvedEntries, string? cwd) { + var command = GetCommand(arguments); + if (string.IsNullOrWhiteSpace(command)) + return true; // empty command, nothing to approve + + // Messy commands cannot be auto-approved: the matcher cannot extract a + // candidate verb-chain to evaluate against the persisted store, so + // every messy invocation must round-trip through the user. The prompt + // builder offers only Once/Deny in this case (see IsMessy). + if (ShellTokenizer.IsMessyCompoundCommand(command)) + return false; + var verbs = ExtractCandidateVerbs(toolName, arguments); if (verbs.Count == 0) - return true; // empty command, nothing to approve + return true; foreach (var verb in verbs) { @@ -146,6 +167,9 @@ public bool IsApproved( return true; } + public bool IsMessy(ToolName toolName, IDictionary? arguments) + => ShellTokenizer.IsMessyCompoundCommand(GetCommand(arguments)); + public string FormatForDisplay(ToolName toolName, IDictionary? arguments) => GetCommand(arguments) ?? "(empty command)"; @@ -220,6 +244,9 @@ public bool IsApproved( string? cwd) => ApprovalPatternMatching.MatchesAny(toolName.Value, approvedEntries); + public bool IsMessy(ToolName toolName, IDictionary? arguments) + => false; + public string FormatForDisplay(ToolName toolName, IDictionary? arguments) => toolName.Value; } diff --git a/src/Netclaw.Security/ShellTokenizer.cs b/src/Netclaw.Security/ShellTokenizer.cs index afd8387c..588b8fd7 100644 --- a/src/Netclaw.Security/ShellTokenizer.cs +++ b/src/Netclaw.Security/ShellTokenizer.cs @@ -84,10 +84,117 @@ public static IEnumerable Tokenize(string command) /// Splits a compound command on &&, ||, and ; /// operators, returning each approval unit trimmed. Pipes remain inside the /// same unit so shell pipelines can be approved as one piece of directory - /// work. + /// work. Returns an empty list when the command is "messy" — i.e., contains + /// bash control-flow keywords (for/while/do/done/ + /// then/fi/case/esac) or unbalanced + /// quotes/brackets — so the approval gate can offer only one-shot approval + /// without persisting fragmentary verb chains derived from control-flow + /// tokens. See for the predicate. /// public static IReadOnlyList SplitCompoundCommand(string command) - => ShellApprovalSemantics.ForCommand(command).SplitCompoundCommand(command); + { + if (IsMessyCompoundCommand(command)) + return []; + return ShellApprovalSemantics.ForCommand(command).SplitCompoundCommand(command); + } + + private static readonly HashSet BashControlFlowKeywords = new(StringComparer.Ordinal) + { + "for", "while", "do", "done", "then", "fi", "case", "esac" + }; + + /// + /// Returns true when contains bash control-flow + /// keywords as unquoted standalone words, or has unbalanced quotes, + /// parentheses, brackets, or braces. Used by the approval gate to refuse + /// persistent grants for commands that cannot be cleanly split into verb + /// chains the operator could later reason about. + /// + /// Cheap structural scan; not a bash parser. Heredocs, here-strings, and + /// command substitution are not analyzed semantically — only their + /// quote/bracket balance contributes. + /// + public static bool IsMessyCompoundCommand(string? command) + { + if (string.IsNullOrWhiteSpace(command)) + return false; + + char? quote = null; + var parens = 0; + var brackets = 0; + var braces = 0; + var word = new StringBuilder(); + + foreach (var ch in command) + { + // Quote handling: opens skip the bracket scan, closes resume it. + if (quote is null && (ch == '\'' || ch == '"')) + { + if (FlushWordAsKeyword(word)) return true; + quote = ch; + continue; + } + + if (quote == ch) + { + quote = null; + continue; + } + + if (quote is not null) + continue; + + switch (ch) + { + case '(': + parens++; + if (FlushWordAsKeyword(word)) return true; + continue; + case ')': + if (--parens < 0) return true; + if (FlushWordAsKeyword(word)) return true; + continue; + case '[': + brackets++; + if (FlushWordAsKeyword(word)) return true; + continue; + case ']': + if (--brackets < 0) return true; + if (FlushWordAsKeyword(word)) return true; + continue; + case '{': + braces++; + if (FlushWordAsKeyword(word)) return true; + continue; + case '}': + if (--braces < 0) return true; + if (FlushWordAsKeyword(word)) return true; + continue; + } + + if (char.IsWhiteSpace(ch) || ch is ';' or '|' or '&') + { + if (FlushWordAsKeyword(word)) return true; + continue; + } + + word.Append(ch); + } + + if (FlushWordAsKeyword(word)) return true; + + return quote is not null || parens != 0 || brackets != 0 || braces != 0; + } + + private static bool FlushWordAsKeyword(StringBuilder word) + { + if (word.Length == 0) + return false; + + var match = BashControlFlowKeywords.Contains(word.ToString()); + word.Clear(); + return match; + } /// /// Extracts the verb chain (command name + subcommands) from a tokenized diff --git a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs index 1b17dfac..397ae57c 100644 --- a/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs +++ b/src/Netclaw.Tools.Abstractions/IParentApprovalBridge.cs @@ -41,5 +41,6 @@ Task RequestApprovalAsync( IReadOnlyList patterns, IReadOnlyList candidateVerbs, IReadOnlyList directoryRoots, + bool isMessy, CancellationToken ct); } From b68137c6f8fe8712baf7dd2149ff4c5f2f6919cd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 18:18:37 +0000 Subject: [PATCH 06/46] feat(approvals): ShellTool cwd defaults to project_dir then session_dir Section 4 of the approval-policy-v2 OpenSpec change. Establishes a deterministic cwd resolution chain for shell invocations so the approval policy can reason about safe-space membership and the spawned process never inherits the daemon's cwd. Resolution order (ToolExecutionContext.ResolveShellCwd): 1. Explicit args.WorkingDirectory when the agent provided one. 2. WorkingContext.ProjectDirectory when set via set_working_directory. 3. SessionDirectory (the per-session ~/.netclaw/sessions// scratch). 4. null only when none is available. Plumbing: - ToolExecutionContext gains ProjectDirectory and ResolveShellCwd. The session pipeline populates ProjectDirectory at context-build time from _state.WorkingContext.ProjectDirectory. - SessionToolExecutionPipeline.ExecuteToolsAsync / ExecuteSingleToolAsync / BuildToolExecutionContext gain a projectDirectory parameter; LlmSessionActor passes _state.WorkingContext.ProjectDirectory at every dispatch. - ShellTool.ExecuteAsync uses context.ResolveShellCwd(args.WorkingDirectory) to set psi.WorkingDirectory; never falls through to ProcessStartInfo's default-of-inheriting-the-daemon's-cwd, which is a footgun the approval policy cannot reason about. - DispatchingToolExecutor.AuthorizeCoreAsync calls the same resolver and writes context.Cwd before GetUnapprovedPatternsAsync, so the approval gate evaluates folder-scoped ApprovalEntry records against the same cwd the spawned process will run in. Tests: - Cwd_falls_back_to_project_directory_when_no_explicit_arg - Cwd_falls_back_to_session_directory_when_project_directory_null - Cwd_explicit_arg_overrides_project_and_session_directories - Cwd_does_not_inherit_daemon_process_directory (asserts the spawned pwd output is the resolved session_dir, not Environment.CurrentDirectory) All 3371 tests pass; slopwatch clean; file headers verified. --- openspec/changes/approval-policy-v2/tasks.md | 6 +- .../Tools/ShellToolTests.cs | 114 ++++++++++++++++++ .../Sessions/LlmSessionActor.cs | 3 +- .../Pipelines/SessionToolExecutionPipeline.cs | 15 ++- .../Tools/DispatchingToolExecutor.cs | 25 ++++ src/Netclaw.Actors/Tools/ShellTool.cs | 15 ++- .../ToolExecutionContext.cs | 38 ++++++ 7 files changed, 205 insertions(+), 11 deletions(-) diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index 3d900a51..de83eedd 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -35,9 +35,9 @@ Both PRs sit under this single OpenSpec change. ## 4. ShellTool cwd default -- [ ] 4.1 In `src/Netclaw.Actors/Tools/ShellTool.cs:81-82`, when `args.WorkingDirectory` is null/whitespace, resolve cwd to `WorkingContext.ProjectDirectory` if set, else `session_dir`. -- [ ] 4.2 Thread `WorkingContext` into `ShellTool` via `ToolExecutionContext` (or constructor; whichever matches existing patterns). -- [ ] 4.3 Unit tests: null arg + project_dir set → uses project_dir; null arg + project_dir null → uses session_dir; explicit arg → uses arg verbatim; assert daemon-process cwd is never the resolved value. +- [x] 4.1 In `src/Netclaw.Actors/Tools/ShellTool.cs:81-82`, when `args.WorkingDirectory` is null/whitespace, resolve cwd to `WorkingContext.ProjectDirectory` if set, else `session_dir`. +- [x] 4.2 Thread `WorkingContext` into `ShellTool` via `ToolExecutionContext` (or constructor; whichever matches existing patterns). +- [x] 4.3 Unit tests: null arg + project_dir set → uses project_dir; null arg + project_dir null → uses session_dir; explicit arg → uses arg verbatim; assert daemon-process cwd is never the resolved value. ## 5. Safe-verbs ∩ safe-space short-circuit diff --git a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs index 7b44c108..1b914a2a 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellToolTests.cs @@ -112,6 +112,120 @@ public async Task Missing_command_returns_error() Assert.Contains("missing", result, StringComparison.OrdinalIgnoreCase); } + // ── Cwd resolution chain: explicit arg → ProjectDirectory → SessionDirectory ── + + [Fact] + public async Task Cwd_falls_back_to_project_directory_when_no_explicit_arg() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var sessionDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(sessionDir); + try + { + var context = new ToolExecutionContext("session-1", sessionDir) { ProjectDirectory = projectDir }; + var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + + var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); + + Assert.Contains("Exit code: 0", result); + var resolved = projectDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.Contains(resolved, result, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, recursive: true); + if (Directory.Exists(sessionDir)) Directory.Delete(sessionDir, recursive: true); + } + } + + [Fact] + public async Task Cwd_falls_back_to_session_directory_when_project_directory_null() + { + var sessionDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(sessionDir); + try + { + var context = new ToolExecutionContext("session-1", sessionDir); + // ProjectDirectory not set + var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + + var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); + + Assert.Contains("Exit code: 0", result); + var resolved = sessionDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.Contains(resolved, result, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(sessionDir)) Directory.Delete(sessionDir, recursive: true); + } + } + + [Fact] + public async Task Cwd_explicit_arg_overrides_project_and_session_directories() + { + var explicitDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + var sessionDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(explicitDir); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(sessionDir); + try + { + var context = new ToolExecutionContext("session-1", sessionDir) { ProjectDirectory = projectDir }; + var args = ToolInput.Create( + "Command", OperatingSystem.IsWindows() ? "cd" : "pwd", + "WorkingDirectory", explicitDir); + + var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); + + Assert.Contains("Exit code: 0", result); + var resolved = explicitDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.Contains(resolved, result, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(explicitDir)) Directory.Delete(explicitDir, recursive: true); + if (Directory.Exists(projectDir)) Directory.Delete(projectDir, recursive: true); + if (Directory.Exists(sessionDir)) Directory.Delete(sessionDir, recursive: true); + } + } + + [Fact] + public async Task Cwd_does_not_inherit_daemon_process_directory() + { + var sessionDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(sessionDir); + try + { + // The daemon's cwd is wherever this test process is running. We + // assert the resolved cwd is the session dir, not whatever + // Environment.CurrentDirectory happens to be — proving the + // ProcessStartInfo default-fall-through is gone. + var context = new ToolExecutionContext("session-1", sessionDir); + var args = ToolInput.Create("Command", OperatingSystem.IsWindows() ? "cd" : "pwd"); + + var result = await _tool.ExecuteAsync(args, context, CancellationToken.None); + + Assert.Contains("Exit code: 0", result); + var sessionResolved = sessionDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + Assert.Contains(sessionResolved, result, StringComparison.OrdinalIgnoreCase); + + var daemonCwd = Path.GetFullPath(Environment.CurrentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.Equals(daemonCwd, sessionResolved, StringComparison.OrdinalIgnoreCase)) + { + // Only assert non-inheritance when the daemon cwd is distinct + // from the session dir; otherwise the test is vacuous. + Assert.DoesNotContain($"\n{daemonCwd}\n", result); + } + } + finally + { + if (Directory.Exists(sessionDir)) Directory.Delete(sessionDir, recursive: true); + } + } + [Fact] public async Task Null_arguments_returns_error() { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 63aa587a..0cb32c5a 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -1640,7 +1640,8 @@ await self.Ask( approvalTimeout: Timeout.InfiniteTimeSpan, maxToolTimeoutSeconds: _toolAccessPolicy?.MaxToolTimeoutSeconds ?? 600, shellTimeoutSeconds: _toolAccessPolicy?.ShellTimeoutSeconds ?? 60, - backgroundJobManager: bgJobManager); + backgroundJobManager: bgJobManager, + projectDirectory: _state.WorkingContext.ProjectDirectory); } private void HandleTextResponse( diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index ec51279f..72f7e519 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -52,7 +52,8 @@ public static async Task ExecuteToolsAsync( int maxToolTimeoutSeconds = 600, ILogger? logger = null, int shellTimeoutSeconds = 60, - IActorRef? backgroundJobManager = null) + IActorRef? backgroundJobManager = null, + string? projectDirectory = null) { try { @@ -76,7 +77,8 @@ public static async Task ExecuteToolsAsync( maxToolTimeoutSeconds, logger, shellTimeoutSeconds, - backgroundJobManager)); + backgroundJobManager, + projectDirectory)); var results = await Task.WhenAll(tasks); var fileAttachments = results.SelectMany(r => r.FileAttachments).ToList(); @@ -126,7 +128,8 @@ public static async Task ExecuteSingleToolAsync( int maxToolTimeoutSeconds = 600, ILogger? logger = null, int shellTimeoutSeconds = 60, - IActorRef? backgroundJobManager = null) + IActorRef? backgroundJobManager = null, + string? projectDirectory = null) { var (meta, cleanedTc) = ToolCallMetaExtractor.Extract(tc); tc = cleanedTc; @@ -139,7 +142,7 @@ public static async Task ExecuteSingleToolAsync( var sw = Stopwatch.StartNew(); string resultText; - var context = BuildToolExecutionContext(sessionId, source, sessionDir, spawnChildActor); + var context = BuildToolExecutionContext(sessionId, source, sessionDir, spawnChildActor, projectDirectory); context.RequestedTimeoutSeconds = (int)timeout.TotalSeconds; if (approvalChannel is not null && emitApprovalRequest is not null) { @@ -609,7 +612,8 @@ private static ToolExecutionContext BuildToolExecutionContext( SessionId sessionId, MessageSource? source, string sessionDir, - Func> spawnChildActor) + Func> spawnChildActor, + string? projectDirectory) { var context = new ToolExecutionContext(sessionId.Value, sessionDir); context.Audience = source is null ? null : source.Audience.ToWireValue(); @@ -617,6 +621,7 @@ private static ToolExecutionContext BuildToolExecutionContext( context.ChannelType = source is null ? null : source.ChannelType.ToWireValue(); context.SupportsInteractiveApproval = source?.ChannelType.SupportsInteractiveApproval(); context.SpawnChildActor = spawnChildActor; + context.ProjectDirectory = projectDirectory; return context; } diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index e5a62011..8f8396fa 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -106,6 +106,17 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall var approvalContext = accessDecision.ApprovalContext ?? throw new InvalidOperationException("Approval decision missing approval context."); + // Resolve the cwd the matcher should evaluate against. For shell + // tools this is the same chain ShellTool itself uses + // (explicit arg → ProjectDirectory → SessionDirectory) so the + // gate's view of "where will this run" matches the spawned + // process. Other tools have no directory anchor; cwd stays null. + if (context is not null + && string.Equals(toolCall.Name, ShellTool.ToolName, StringComparison.Ordinal)) + { + context.Cwd = context.ResolveShellCwd(ExtractWorkingDirectoryArg(toolCall.Arguments)); + } + // Messy commands cannot be persistently approved — the matcher // refuses to extract verb chains we could match a future // invocation against. Always round-trip through the user, even if @@ -179,4 +190,18 @@ private static bool IsOneTimeApprovalSatisfied( return approvalContext.Patterns.All(pattern => context.OneTimeApprovedPatterns.Contains(pattern)); } + + private static string? ExtractWorkingDirectoryArg(IDictionary? arguments) + { + if (arguments is null) + return null; + + if (arguments.TryGetValue("WorkingDirectory", out var val) + || arguments.TryGetValue("workingDirectory", out val)) + { + return val?.ToString(); + } + + return null; + } } diff --git a/src/Netclaw.Actors/Tools/ShellTool.cs b/src/Netclaw.Actors/Tools/ShellTool.cs index 8a4cdd3f..46e45edc 100644 --- a/src/Netclaw.Actors/Tools/ShellTool.cs +++ b/src/Netclaw.Actors/Tools/ShellTool.cs @@ -78,8 +78,19 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon psi.ArgumentList.Add(args.Command); } - if (!string.IsNullOrWhiteSpace(args.WorkingDirectory)) - psi.WorkingDirectory = args.WorkingDirectory; + // Resolve working directory in priority order: explicit arg → + // WorkingContext.ProjectDirectory (declared via set_working_directory) + // → SessionDirectory (per-session scratch). Never falls through to + // ProcessStartInfo's default of inheriting the daemon process's cwd — + // that location is wherever the daemon happened to be launched and is + // unrelated to what the agent is "working on," which makes it + // impossible for the approval policy to reason about safe-space + // membership. The matcher reads context.Cwd against the same + // resolution chain so the gate evaluates folder-scoped ApprovalEntry + // records against the directory the spawned process will run in. + var resolvedCwd = context.ResolveShellCwd(args.WorkingDirectory); + if (!string.IsNullOrWhiteSpace(resolvedCwd)) + psi.WorkingDirectory = resolvedCwd; var effectiveTimeoutSeconds = context.RequestedTimeoutSeconds is > 0 ? context.RequestedTimeoutSeconds.Value diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index e7c4997a..a9a02d6a 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -140,6 +140,44 @@ public IReadOnlySet OneTimeApprovedPatterns /// public string? Cwd { get; set; } + /// + /// Absolute path to the project directory the agent is currently working + /// on, mirroring WorkingContext.ProjectDirectory from the session + /// state. Set by the session pipeline at context-build time so tools and + /// the approval gate can resolve a cwd without a round-trip through the + /// session actor. Null when no project root has been declared via + /// set_working_directory. + /// + public string? ProjectDirectory { get; set; } + + /// + /// Resolves the working directory for a shell-style invocation. Returns + /// the first non-empty value of: + /// + /// — the tool call's + /// WorkingDirectory argument when the agent provided one; + /// — the session's declared project + /// root, populated from WorkingContext.ProjectDirectory; + /// — the per-session scratch + /// directory under ~/.netclaw/sessions/<id>/. + /// + /// Returns null only when none of the three is available, which is + /// the contract for tools that are not directory-anchored. Shell tools + /// SHALL never inherit the daemon process's cwd — that defeats the + /// approval policy's safe-space invariant because the daemon's cwd is + /// unrelated to what the agent is "working on." + /// + public string? ResolveShellCwd(string? explicitArg) + { + if (!string.IsNullOrWhiteSpace(explicitArg)) + return explicitArg; + if (!string.IsNullOrWhiteSpace(ProjectDirectory)) + return ProjectDirectory; + if (!string.IsNullOrWhiteSpace(SessionDirectory)) + return SessionDirectory; + return null; + } + /// /// File attachments registered by tools during execution. /// From 6af46220202c9ab6227a3d3948a26a9ffdc9d049 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 18:39:20 +0000 Subject: [PATCH 07/46] =?UTF-8?q?feat(approvals):=20safe-verbs=20=E2=88=A9?= =?UTF-8?q?=20safe-space=20approval=20short-circuit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 5 of the approval-policy-v2 OpenSpec change. Adds the load-bearing friction-reduction layer: read-only verbs invoked inside declared safe spaces auto-allow without prompting, while every other combination still routes through the interactive approval gate. Three-position policy: layer 1 ToolPathPolicy hard-deny (unchanged) layer 1.5 NEW: safe-verb ∩ safe-space short-circuit (this commit) layer 2 interactive approval gate (unchanged) A candidate (verb, cwd) short-circuits to Allow when ALL hold: - verb is on the curated SafeVerbList for the current OS - cwd resolves under one of the audience-aware safe-space roots (Personal/Team: session_dir + project_dir; Public: session_dir) - no segment of the cwd path is a filesystem symlink (reparse point) Bundled lists (Netclaw.Configuration/SafeVerbs/safe-verbs.*.json embedded as resources, additive user override at ~/.netclaw/config/safe-verbs..json): Linux/macOS: ls, find, grep, egrep, fgrep, rg, cat, head, tail, wc, sort, uniq, cut, tr, awk, sed -n, file, pwd, which, stat, tree, du, df, git status, git log, git diff, git show, git branch, git remote, git rev-parse, git ls-files, git blame. Windows: dir, type, more, where, findstr, Get-ChildItem, Get-Content, Select-String, Get-Item, Test-Path, Get-Location, Resolve-Path, plus the same git read subcommands. Mutating verbs (git push, sed -i, awk -i inplace, rm, mv, etc.) are intentionally absent from both lists. sed is pinned to "sed -n" so the matcher refuses to short-circuit "sed -i". The verb-chain matcher means "awk" auto-allows but "awk -i inplace" hits the gate because ExtractVerbChain stops at the first flag. Plumbing: - New SafeVerbList (Configuration) with platform-correct comparer. - New SafeVerbLoader that reads the bundled JSON resource and merges the user override file additively. Malformed override → silently fall back to bundled defaults (the doctor will surface the problem out of band; we do not refuse to start the daemon). - New ScopedShellSafeVerbPolicy (Netclaw.Actors.Tools) mirroring ScopedFileAccessPolicy: takes (verb, cwd, context), returns a short-circuit decision; reuses PathUtility.ContainsSymlinkSegment and the audience model. - ToolAccessPolicy gains a SafeVerbList ctor parameter and runs the safe-verb check inline in CheckApprovalGate after the messy/Auto filters but before producing the approval-prompt context. The cwd it evaluates is resolved by ToolExecutionContext.ResolveShellCwd and written back to context.Cwd so the downstream gate and the spawned process agree on "where this runs." - DispatchingToolExecutor's duplicate cwd resolution removed — CheckApprovalGate now owns the write to context.Cwd. - Program.cs constructs a SafeVerbList at startup and registers it alongside ToolAccessPolicy. - NetclawPaths.SafeVerbsOverridePath returns the per-OS user file. Tests (3388 → 3398 across the suite): - SafeVerbLoaderTests: bundled defaults present per OS, user override extends additively, malformed override falls back, missing override ignored, platform-correct case rules. - ScopedShellSafeVerbPolicyTests: all seven scenarios from the spec — safe verb + project_dir → allow; safe verb + session_dir → allow; safe verb + outside → prompt; mutating verb in safe space → prompt; Public audience cannot use project_dir as safe space; symlink segment in cwd breaks short-circuit; AllShortCircuit fails-loud when any candidate is unsafe. Slopwatch clean; file headers verified. --- openspec/changes/approval-policy-v2/tasks.md | 14 +- .../Tools/ScopedShellSafeVerbPolicyTests.cs | 185 ++++++++++++++++++ .../Tools/DispatchingToolExecutor.cs | 28 +-- .../Tools/ScopedShellSafeVerbPolicy.cs | 145 ++++++++++++++ src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 22 ++- .../SafeVerbLoaderTests.cs | 116 +++++++++++ .../Netclaw.Configuration.csproj | 1 + src/Netclaw.Configuration/NetclawPaths.cs | 16 ++ src/Netclaw.Configuration/SafeVerbList.cs | 161 +++++++++++++++ .../SafeVerbs/safe-verbs.linux.json | 37 ++++ .../SafeVerbs/safe-verbs.windows.json | 26 +++ src/Netclaw.Daemon/Program.cs | 9 +- 12 files changed, 727 insertions(+), 33 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs create mode 100644 src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs create mode 100644 src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs create mode 100644 src/Netclaw.Configuration/SafeVerbList.cs create mode 100644 src/Netclaw.Configuration/SafeVerbs/safe-verbs.linux.json create mode 100644 src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index de83eedd..8a261f1a 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -41,13 +41,13 @@ Both PRs sit under this single OpenSpec change. ## 5. Safe-verbs ∩ safe-space short-circuit -- [ ] 5.1 Create `safe-verbs.linux.json` and `safe-verbs.windows.json` in the daemon's bundled config (alongside other shipped defaults). -- [ ] 5.2 Add a loader that reads bundled defaults and merges `~/.netclaw/config/safe-verbs..json` overrides if present. -- [ ] 5.3 Create `src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs` mirroring `ScopedFileAccessPolicy`. Inputs: candidate verb chain + cwd + `ToolExecutionContext`. Output: short-circuit decision (allow / fall-through). -- [ ] 5.4 Reuse `ToolAudienceProfileResolver` for safe-space root resolution. Personal/Team get `session_dir + project_dir`; Public gets `session_dir` only. -- [ ] 5.5 Reuse `ContainsSymlinkSegment` (or extract to a shared utility) for symlink-segment guard along the cwd path. -- [ ] 5.6 Wire the policy into `ToolAccessPolicy.CheckApprovalGate` so the safe-verb short-circuit runs before the existing approval gate. Hard-deny list (layer 1) still runs first. -- [ ] 5.7 Unit tests covering all four scenarios in the spec: safe verb + project_dir → allow; safe verb + session_dir → allow; safe verb + outside → prompt; mutating verb + safe space → prompt; Public + project_dir → prompt; symlink in cwd → prompt; user override extends defaults. +- [x] 5.1 Create `safe-verbs.linux.json` and `safe-verbs.windows.json` in the daemon's bundled config (alongside other shipped defaults). +- [x] 5.2 Add a loader that reads bundled defaults and merges `~/.netclaw/config/safe-verbs..json` overrides if present. +- [x] 5.3 Create `src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs` mirroring `ScopedFileAccessPolicy`. Inputs: candidate verb chain + cwd + `ToolExecutionContext`. Output: short-circuit decision (allow / fall-through). +- [x] 5.4 Reuse `ToolAudienceProfileResolver` for safe-space root resolution. Personal/Team get `session_dir + project_dir`; Public gets `session_dir` only. +- [x] 5.5 Reuse `ContainsSymlinkSegment` (or extract to a shared utility) for symlink-segment guard along the cwd path. +- [x] 5.6 Wire the policy into `ToolAccessPolicy.CheckApprovalGate` so the safe-verb short-circuit runs before the existing approval gate. Hard-deny list (layer 1) still runs first. +- [x] 5.7 Unit tests covering all four scenarios in the spec: safe verb + project_dir → allow; safe verb + session_dir → allow; safe verb + outside → prompt; mutating verb + safe space → prompt; Public + project_dir → prompt; symlink in cwd → prompt; user override extends defaults. ## 6. CLI updates (list/revoke/trust-verb) diff --git a/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs b/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs new file mode 100644 index 00000000..753d48ab --- /dev/null +++ b/src/Netclaw.Actors.Tests/Tools/ScopedShellSafeVerbPolicyTests.cs @@ -0,0 +1,185 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Actors.Tests.Tools; + +public sealed class ScopedShellSafeVerbPolicyTests : IDisposable +{ + private readonly string _projectDir; + private readonly string _sessionDir; + private readonly string _outsideDir; + + public ScopedShellSafeVerbPolicyTests() + { + _projectDir = CreateTempDir("project"); + _sessionDir = CreateTempDir("session"); + _outsideDir = CreateTempDir("outside"); + } + + public void Dispose() + { + SafeDelete(_projectDir); + SafeDelete(_sessionDir); + SafeDelete(_outsideDir); + } + + private static string CreateTempDir(string label) + { + var path = Path.Combine(Path.GetTempPath(), $"netclaw-{label}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void SafeDelete(string path) + { + if (!Directory.Exists(path)) + return; + + try + { + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Cleanup is best-effort — a leftover temp tree from a failed + // test run is acceptable, a test crash from a permissions glitch + // during teardown is not. Log so an investigator finding the + // leftover tree can correlate it back to a specific test run. + System.Diagnostics.Debug.WriteLine($"SafeDelete failed for '{path}': {ex.Message}"); + } + } + + private static SafeVerbList VerbList(params string[] verbs) + => SafeVerbList.FromVerbs(verbs); + + private ToolExecutionContext PersonalContext(string? projectDir = null, string? sessionDir = null) + => new("session-1", sessionDir ?? _sessionDir) + { + Audience = TrustAudience.Personal.ToWireValue(), + ProjectDirectory = projectDir + }; + + private ToolExecutionContext PublicContext(string? projectDir = null) + => new("session-1", _sessionDir) + { + Audience = TrustAudience.Public.ToWireValue(), + ProjectDirectory = projectDir + }; + + [Fact] + public void Safe_verb_in_project_directory_short_circuits() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.True(policy.ShortCircuitsApproval("grep", _projectDir, ctx)); + } + + [Fact] + public void Safe_verb_in_session_directory_short_circuits() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("cat")); + var ctx = PersonalContext(); + + Assert.True(policy.ShortCircuitsApproval("cat", _sessionDir, ctx)); + } + + [Fact] + public void Safe_verb_outside_safe_spaces_falls_through_to_prompt() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.ShortCircuitsApproval("grep", _outsideDir, ctx)); + } + + [Fact] + public void Mutating_verb_in_safe_space_falls_through_to_prompt() + { + // The verb list deliberately omits "git push"; even with cwd inside + // the safe space the policy refuses the short-circuit. + var policy = new ScopedShellSafeVerbPolicy(VerbList("git status", "git log")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.ShortCircuitsApproval("git push", _projectDir, ctx)); + } + + [Fact] + public void Public_audience_does_not_get_project_directory_safe_space() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep")); + // Public has project_dir set (somehow), but it should be ignored. + var ctx = PublicContext(projectDir: _projectDir); + + Assert.False(policy.ShortCircuitsApproval("grep", _projectDir, ctx)); + // Session_dir still works for Public. + Assert.True(policy.ShortCircuitsApproval("grep", _sessionDir, ctx)); + } + + [Fact] + public void Symlink_segment_in_cwd_breaks_short_circuit() + { + // Skip on Windows where directory symlink creation is privilege-gated. + if (OperatingSystem.IsWindows()) + return; + + var leakTarget = CreateTempDir("leak-target"); + var symlinkPath = Path.Combine(_projectDir, "leak"); + try + { + Directory.CreateSymbolicLink(symlinkPath, leakTarget); + + var policy = new ScopedShellSafeVerbPolicy(VerbList("cat")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.ShortCircuitsApproval("cat", symlinkPath, ctx)); + } + finally + { + SafeDelete(leakTarget); + } + } + + [Fact] + public void All_short_circuit_returns_false_when_any_verb_is_unsafe() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep", "cat")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.AllShortCircuit(["grep", "git push"], _projectDir, ctx)); + } + + [Fact] + public void All_short_circuit_returns_true_when_every_verb_is_safe_and_in_space() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep", "cat", "wc")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.True(policy.AllShortCircuit(["grep", "cat", "wc"], _projectDir, ctx)); + } + + [Fact] + public void Empty_candidate_list_does_not_short_circuit() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.AllShortCircuit([], _projectDir, ctx)); + } + + [Fact] + public void Null_cwd_does_not_short_circuit() + { + var policy = new ScopedShellSafeVerbPolicy(VerbList("grep")); + var ctx = PersonalContext(projectDir: _projectDir); + + Assert.False(policy.ShortCircuitsApproval("grep", null, ctx)); + } +} diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 8f8396fa..0df8f319 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -106,16 +106,10 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall var approvalContext = accessDecision.ApprovalContext ?? throw new InvalidOperationException("Approval decision missing approval context."); - // Resolve the cwd the matcher should evaluate against. For shell - // tools this is the same chain ShellTool itself uses - // (explicit arg → ProjectDirectory → SessionDirectory) so the - // gate's view of "where will this run" matches the spawned - // process. Other tools have no directory anchor; cwd stays null. - if (context is not null - && string.Equals(toolCall.Name, ShellTool.ToolName, StringComparison.Ordinal)) - { - context.Cwd = context.ResolveShellCwd(ExtractWorkingDirectoryArg(toolCall.Arguments)); - } + // Cwd resolution happens upstream in ToolAccessPolicy.CheckApprovalGate + // for shell tools, so context.Cwd is already populated when the + // gate produced an approval context. Other tools have no + // directory anchor; cwd stays null. // Messy commands cannot be persistently approved — the matcher // refuses to extract verb chains we could match a future @@ -190,18 +184,4 @@ private static bool IsOneTimeApprovalSatisfied( return approvalContext.Patterns.All(pattern => context.OneTimeApprovedPatterns.Contains(pattern)); } - - private static string? ExtractWorkingDirectoryArg(IDictionary? arguments) - { - if (arguments is null) - return null; - - if (arguments.TryGetValue("WorkingDirectory", out var val) - || arguments.TryGetValue("workingDirectory", out val)) - { - return val?.ToString(); - } - - return null; - } } diff --git a/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs new file mode 100644 index 00000000..8f97040d --- /dev/null +++ b/src/Netclaw.Actors/Tools/ScopedShellSafeVerbPolicy.cs @@ -0,0 +1,145 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tools; + +namespace Netclaw.Actors.Tools; + +/// +/// Layer 1.5 of the shell approval pipeline (between the hard-deny list and +/// the interactive approval gate): when both the candidate verb chain is on +/// the curated AND the candidate's cwd resolves +/// under one of the audience-aware safe-space roots, the policy short-circuits +/// to "approved" without prompting the user. +/// +/// Mirrors for the audience model and +/// the symlink-segment guard. Personal and Team audiences get +/// session_dir + project_dir as their safe-space roots; Public gets +/// session_dir only — Public sessions cannot expand their safe space +/// via set_working_directory, mirroring the read-roots restriction +/// enforces for file_read. +/// +/// The policy never relaxes the hard-deny list (layer 1) — that runs first +/// in . It only relaxes the interactive +/// approval gate (layer 2) for verbs that have been explicitly classified as +/// read-only by the bundled safe-verbs list and any user-additive override. +/// +internal sealed class ScopedShellSafeVerbPolicy +{ + private readonly SafeVerbList _safeVerbs; + + public ScopedShellSafeVerbPolicy(SafeVerbList safeVerbs) + { + _safeVerbs = safeVerbs; + } + + /// + /// Evaluates a candidate (verb, cwd) pair against the safe-verb policy. + /// Returns true when the gate should short-circuit to allow with + /// no user prompt; false when the candidate should fall through + /// to the existing approval gate. + /// + public bool ShortCircuitsApproval(string candidateVerb, string? cwd, ToolExecutionContext? context) + { + if (string.IsNullOrWhiteSpace(candidateVerb)) + return false; + + if (!_safeVerbs.Contains(candidateVerb)) + return false; + + if (string.IsNullOrWhiteSpace(cwd)) + return false; + + var safeRoots = ResolveSafeSpaceRoots(context); + if (safeRoots.Count == 0) + return false; + + string fullCwd; + try + { + fullCwd = Path.GetFullPath(cwd); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + + foreach (var root in safeRoots) + { + if (!PathUtility.IsWithinRoot(fullCwd, root)) + continue; + + // A planted symlink under a safe-space root could redirect the + // cwd into a path outside that root. Refuse the short-circuit if + // any segment of the cwd path is a reparse point — the user can + // still grant manually via the interactive prompt, where they + // will see the literal cwd they are authorizing. + if (PathUtility.ContainsSymlinkSegment(root, fullCwd)) + continue; + + return true; + } + + return false; + } + + /// + /// Returns true when every candidate verb in + /// is short-circuited by the safe-verb policy under the supplied + /// . Used by the gate to bypass the approval prompt + /// only when the entire compound is read-only-in-safe-space; any single + /// non-safe candidate falls the whole invocation through to the prompt. + /// + public bool AllShortCircuit(IReadOnlyList candidateVerbs, string? cwd, ToolExecutionContext? context) + { + if (candidateVerbs.Count == 0) + return false; + + foreach (var verb in candidateVerbs) + { + if (!ShortCircuitsApproval(verb, cwd, context)) + return false; + } + + return true; + } + + /// + /// Resolves the audience-aware safe-space roots for the current + /// invocation. Personal and Team get session_dir + project_dir; + /// Public gets session_dir only. + /// + private static IReadOnlyList ResolveSafeSpaceRoots(ToolExecutionContext? context) + { + if (context is null) + return []; + + var audience = ResolveAudience(context); + var roots = new List(2); + + if (!string.IsNullOrWhiteSpace(context.SessionDirectory)) + roots.Add(PathUtility.Normalize(context.SessionDirectory)); + + // Public audience cannot expand its safe space via project_dir — + // mirrors the file_read read-roots restriction enforced by + // ScopedFileAccessPolicy. Even a Public session that has somehow + // populated WorkingContext.ProjectDirectory does not get to use it + // as a shell safe-space root. + if (audience != TrustAudience.Public + && !string.IsNullOrWhiteSpace(context.ProjectDirectory)) + { + roots.Add(PathUtility.Normalize(context.ProjectDirectory)); + } + + return roots; + } + + private static TrustAudience ResolveAudience(ToolExecutionContext context) + => SecurityPolicyDefaults.TryParseAudience(context.Audience, out var parsed) + ? parsed + : SecurityPolicyDefaults.ResolveAudienceFromSessionId(context.SessionId); +} diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index c81167c2..ae4b073c 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -23,6 +23,7 @@ public sealed class ToolAccessPolicy private readonly IShellTrustZonePolicy? _shellTrustZonePolicy; private readonly IToolApprovalMatcher _fileApprovalMatcher; private readonly FeatureGates _featureGates; + private readonly ScopedShellSafeVerbPolicy? _safeVerbPolicy; public ToolAccessPolicy( ToolConfig toolConfig, @@ -31,7 +32,8 @@ public ToolAccessPolicy( IToolApprovalMatcher? fileApprovalMatcher = null, ToolPathPolicy? toolPathPolicy = null, FeatureGates? featureGates = null, - IShellTrustZonePolicy? shellTrustZonePolicy = null) + IShellTrustZonePolicy? shellTrustZonePolicy = null, + SafeVerbList? safeVerbs = null) { _toolConfig = toolConfig; _defaults = defaults; @@ -41,6 +43,7 @@ public ToolAccessPolicy( _shellTrustZonePolicy = shellTrustZonePolicy; _fileApprovalMatcher = fileApprovalMatcher ?? DefaultApprovalMatcher.Instance; _featureGates = featureGates ?? FeatureGates.AllEnabled; + _safeVerbPolicy = safeVerbs is not null ? new ScopedShellSafeVerbPolicy(safeVerbs) : null; } public int MaxToolTimeoutSeconds => _toolConfig.MaxToolTimeoutSeconds; @@ -315,6 +318,23 @@ private ToolAccessDecision CheckApprovalGate( var displayText = matcher.FormatForDisplay(toolName, arguments); var isMessy = matcher.IsMessy(toolName, arguments); + // Safe-verb ∩ safe-space short-circuit (layer 1.5). Runs only for shell + // and only when the matcher could extract candidate verbs cleanly — + // messy commands always prompt regardless of verb membership. Resolves + // cwd into context.Cwd here so the matcher and downstream gate + // evaluate folder-scoped ApprovalEntry records against the same + // directory the spawned process will run in. + if (_safeVerbPolicy is not null + && context is not null + && string.Equals(toolName.Value, ShellTool.ToolName, StringComparison.Ordinal) + && !isMessy + && candidateVerbs.Count > 0) + { + context.Cwd = context.ResolveShellCwd(ExtractWorkingDirectory(arguments)); + if (_safeVerbPolicy.AllShortCircuit(candidateVerbs, context.Cwd, context)) + return ToolAccessDecision.Allow(); + } + var approvalContext = new ToolApprovalContext( toolName.Value, displayText, diff --git a/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs b/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs new file mode 100644 index 00000000..4e143f69 --- /dev/null +++ b/src/Netclaw.Configuration.Tests/SafeVerbLoaderTests.cs @@ -0,0 +1,116 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class SafeVerbLoaderTests : IDisposable +{ + private readonly string _tempOverridePath = Path.Combine( + Path.GetTempPath(), + $"netclaw-safe-verbs-{Guid.NewGuid():N}.json"); + + public void Dispose() + { + if (File.Exists(_tempOverridePath)) + File.Delete(_tempOverridePath); + } + + [Fact] + public void Load_returns_bundled_linux_defaults_when_no_override() + { + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: null); + + // Spot-check a few entries from the spec's default Linux list. + Assert.True(list.Contains("ls")); + Assert.True(list.Contains("grep")); + Assert.True(list.Contains("git status")); + Assert.True(list.Contains("sed -n")); + Assert.False(list.Contains("git push")); + Assert.False(list.Contains("rm")); + } + + [Fact] + public void Load_returns_bundled_windows_defaults_when_no_override() + { + var list = SafeVerbLoader.Load(isWindows: true, overrideFilePath: null); + + // Spot-check a few entries from the spec's default Windows list. + Assert.True(list.Contains("dir")); + Assert.True(list.Contains("Get-Content")); + Assert.True(list.Contains("Test-Path")); + Assert.True(list.Contains("git status")); + Assert.False(list.Contains("Remove-Item")); + } + + [Fact] + public void Load_user_override_extends_bundled_defaults() + { + File.WriteAllText(_tempOverridePath, """ + { "verbs": ["eza", "delta"] } + """); + + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: _tempOverridePath); + + // User additions present. + Assert.True(list.Contains("eza")); + Assert.True(list.Contains("delta")); + // Bundled defaults remain. + Assert.True(list.Contains("ls")); + Assert.True(list.Contains("grep")); + } + + [Fact] + public void Load_user_override_cannot_remove_bundled_entries() + { + // Even if the user file is empty, the bundled defaults still apply. + File.WriteAllText(_tempOverridePath, """{ "verbs": [] }"""); + + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: _tempOverridePath); + + Assert.True(list.Contains("ls")); + Assert.True(list.Contains("grep")); + } + + [Fact] + public void Load_malformed_override_falls_back_to_bundled_defaults() + { + File.WriteAllText(_tempOverridePath, "not valid json {{{"); + + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: _tempOverridePath); + + // No throw; bundled defaults still loaded. + Assert.True(list.Contains("ls")); + Assert.True(list.Contains("grep")); + } + + [Fact] + public void Load_missing_override_path_uses_bundled_only() + { + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: "/path/does/not/exist.json"); + + Assert.True(list.Contains("ls")); + } + + [Fact] + public void Contains_uses_platform_correct_case_rules() + { + var list = SafeVerbLoader.Load(isWindows: false, overrideFilePath: null); + + if (OperatingSystem.IsWindows()) + { + // OrdinalIgnoreCase + Assert.True(list.Contains("LS")); + Assert.True(list.Contains("ls")); + } + else + { + // Ordinal — `LS` is a different binary from `ls` on POSIX. + Assert.False(list.Contains("LS")); + Assert.True(list.Contains("ls")); + } + } +} diff --git a/src/Netclaw.Configuration/Netclaw.Configuration.csproj b/src/Netclaw.Configuration/Netclaw.Configuration.csproj index 9bfcbbe4..7e788b5c 100644 --- a/src/Netclaw.Configuration/Netclaw.Configuration.csproj +++ b/src/Netclaw.Configuration/Netclaw.Configuration.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Netclaw.Configuration/NetclawPaths.cs b/src/Netclaw.Configuration/NetclawPaths.cs index 074dd79b..2d91dd66 100644 --- a/src/Netclaw.Configuration/NetclawPaths.cs +++ b/src/Netclaw.Configuration/NetclawPaths.cs @@ -98,6 +98,22 @@ public string ServerFeedSyncStatePath(string feedName) public string LockFilePath => Path.Combine(BasePath, "netclaw.lock"); public string SqliteDbPath => Path.Combine(BasePath, "netclaw.db"); public string McpOAuthMetadataPath => Path.Combine(ConfigDirectory, "mcp-oauth-metadata.json"); + + /// + /// Per-OS user override file for the safe-verbs list consulted by the + /// shell approval gate's safe-space short-circuit. Optional; when absent, + /// the daemon uses only the bundled defaults. Format: a JSON object with + /// a "verbs" array of verb-chain strings; the daemon merges these + /// additively with the shipped defaults. + /// + public string SafeVerbsOverridePath + { + get + { + var fileName = OperatingSystem.IsWindows() ? "safe-verbs.windows.json" : "safe-verbs.linux.json"; + return Path.Combine(ConfigDirectory, fileName); + } + } public string KeysDirectory => Path.Combine(BasePath, "keys"); public NetclawPaths(string? basePath = null, string? workspacesDirectory = null) diff --git a/src/Netclaw.Configuration/SafeVerbList.cs b/src/Netclaw.Configuration/SafeVerbList.cs new file mode 100644 index 00000000..284e6da1 --- /dev/null +++ b/src/Netclaw.Configuration/SafeVerbList.cs @@ -0,0 +1,161 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Netclaw.Configuration; + +/// +/// Curated list of demonstrably read-only shell verb chains the approval gate +/// auto-allows when invoked inside an audience-aware safe space. Loaded from +/// the daemon's bundled safe-verbs.<os>.json resource, optionally +/// merged additively with a user override at +/// ~/.netclaw/config/safe-verbs.<os>.json. +/// +/// Membership is exact-equality against the verb chain extracted by +/// ShellTokenizer.ExtractVerbChain (case rules from +/// : Ordinal on POSIX, +/// OrdinalIgnoreCase on Windows). Mutating verbs (e.g. git push, +/// sed -i) are intentionally absent — they remain subject to the +/// interactive approval gate. +/// +public sealed class SafeVerbList +{ + public static readonly SafeVerbList Empty = new(new HashSet(StringComparer.Ordinal)); + + private readonly HashSet _verbs; + + internal SafeVerbList(HashSet verbs) + { + _verbs = verbs; + } + + /// + /// Builds a from an explicit verb collection. + /// Used by tests and by callers that synthesize a list outside the + /// bundled-plus-override loading path. + /// + public static SafeVerbList FromVerbs(IEnumerable verbs) + { + var set = new HashSet(ToolApprovalEntryComparer.Comparer); + foreach (var verb in verbs) + { + if (!string.IsNullOrWhiteSpace(verb)) + set.Add(verb.Trim()); + } + return new SafeVerbList(set); + } + + /// + /// Returns true when the candidate verb chain is on the safe-verbs list. + /// + public bool Contains(string candidateVerb) + => !string.IsNullOrEmpty(candidateVerb) && _verbs.Contains(candidateVerb); + + /// The verbs in this list. Stable ordering; intended for diagnostics, not lookups. + public IReadOnlyCollection Verbs => _verbs; +} + +/// +/// JSON deserialization shape for safe-verbs.*.json files. +/// +internal sealed class SafeVerbListFile +{ + [JsonPropertyName("verbs")] + public List Verbs { get; set; } = new(); +} + +/// +/// Loads the bundled safe-verbs list for the current OS and merges any user +/// override at additively. +/// +public static class SafeVerbLoader +{ + private const string LinuxResourceName = "Netclaw.Configuration.SafeVerbs.safe-verbs.linux.json"; + private const string WindowsResourceName = "Netclaw.Configuration.SafeVerbs.safe-verbs.windows.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// Loads the safe-verbs list for the current OS. Always returns at least + /// the bundled defaults; merges an additional set from + /// when that file exists + /// and parses cleanly. Never throws — a malformed override file is + /// silently ignored (the bundled defaults still apply). + /// + public static SafeVerbList Load(NetclawPaths? paths = null) + => Load(OperatingSystem.IsWindows(), paths?.SafeVerbsOverridePath); + + internal static SafeVerbList Load(bool isWindows, string? overrideFilePath) + { + var comparer = ToolApprovalEntryComparer.Comparer; + var verbs = new HashSet(comparer); + + foreach (var verb in LoadBundled(isWindows)) + verbs.Add(verb); + + if (!string.IsNullOrWhiteSpace(overrideFilePath) && File.Exists(overrideFilePath)) + { + foreach (var verb in TryLoadOverride(overrideFilePath)) + verbs.Add(verb); + } + + return new SafeVerbList(verbs); + } + + private static IEnumerable LoadBundled(bool isWindows) + { + var resourceName = isWindows ? WindowsResourceName : LinuxResourceName; + var assembly = typeof(SafeVerbLoader).Assembly; + + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException( + $"Bundled safe-verbs resource '{resourceName}' is missing from {assembly.FullName}. " + + "This is a build packaging error: SafeVerbs/*.json must be embedded."); + + var file = JsonSerializer.Deserialize(stream, JsonOptions) + ?? throw new InvalidDataException($"Bundled safe-verbs resource '{resourceName}' deserialized to null."); + + foreach (var verb in file.Verbs) + { + if (!string.IsNullOrWhiteSpace(verb)) + yield return verb.Trim(); + } + } + + private static IEnumerable TryLoadOverride(string path) + { + SafeVerbListFile? file; + try + { + var json = File.ReadAllText(path); + file = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + // A malformed user override should not prevent the daemon from + // starting; the bundled defaults remain in force. The doctor + // surfaces this condition out-of-band so operators can fix it + // without losing trust in the safe-verb policy. + yield break; + } + + if (file?.Verbs is null) + yield break; + + foreach (var verb in file.Verbs) + { + if (!string.IsNullOrWhiteSpace(verb)) + yield return verb.Trim(); + } + } +} diff --git a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.linux.json b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.linux.json new file mode 100644 index 00000000..7138149a --- /dev/null +++ b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.linux.json @@ -0,0 +1,37 @@ +{ + "$comment": "Bundled defaults for shell verbs the approval gate auto-allows when invoked inside an audience-aware safe space (session_dir + project_dir for Personal/Team; session_dir only for Public). Each entry is a verb chain matched by exact equality against ShellTokenizer.ExtractVerbChain output. Mutating verbs are intentionally absent — git push, awk -i inplace, sed -i etc. still prompt. sed is pinned to its read-only -n flag form. Users may extend this list at ~/.netclaw/config/safe-verbs.linux.json (additive merge).", + "verbs": [ + "ls", + "find", + "grep", + "egrep", + "fgrep", + "rg", + "cat", + "head", + "tail", + "wc", + "sort", + "uniq", + "cut", + "tr", + "awk", + "sed -n", + "file", + "pwd", + "which", + "stat", + "tree", + "du", + "df", + "git status", + "git log", + "git diff", + "git show", + "git branch", + "git remote", + "git rev-parse", + "git ls-files", + "git blame" + ] +} diff --git a/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json new file mode 100644 index 00000000..a2aa2252 --- /dev/null +++ b/src/Netclaw.Configuration/SafeVerbs/safe-verbs.windows.json @@ -0,0 +1,26 @@ +{ + "$comment": "Bundled defaults for shell verbs the approval gate auto-allows on Windows when invoked inside an audience-aware safe space (session_dir + project_dir for Personal/Team; session_dir only for Public). Each entry is a verb chain matched by exact equality against ShellTokenizer.ExtractVerbChain output. Includes both cmd.exe builtins and PowerShell read-only cmdlets, plus the same git read subcommands as the Linux list. Mutating verbs are intentionally absent.", + "verbs": [ + "dir", + "type", + "more", + "where", + "findstr", + "Get-ChildItem", + "Get-Content", + "Select-String", + "Get-Item", + "Test-Path", + "Get-Location", + "Resolve-Path", + "git status", + "git log", + "git diff", + "git show", + "git branch", + "git remote", + "git rev-parse", + "git ls-files", + "git blame" + ] +} diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index dbc5d3f0..1f0c7259 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -666,6 +666,12 @@ static void ConfigureDaemonServices( SchedulingEnabled: schedulingConfig.Enabled); var fileApprovalMatcher = new FilePathApprovalMatcher(paths.ConfigDirectory); var shellTrustZonePolicy = new ShellTrustZonePolicy(toolConfig, paths); + // Safe-verbs list: bundled per-OS defaults + optional additive user override + // at ~/.netclaw/config/safe-verbs..json. Used by the approval gate's + // safe-space short-circuit to auto-allow demonstrably read-only verbs + // when invoked inside an audience-aware safe space. + var safeVerbs = SafeVerbLoader.Load(paths); + services.AddSingleton(safeVerbs); var toolAccessPolicy = new ToolAccessPolicy( toolConfig, effectivePolicyDefaults, @@ -673,7 +679,8 @@ static void ConfigureDaemonServices( fileApprovalMatcher, toolPathPolicy, featureGates, - shellTrustZonePolicy); + shellTrustZonePolicy, + safeVerbs); services.AddSingleton(toolAccessPolicy); var toolApprovalStore = new ToolApprovalStore(paths.ToolApprovalsPath); From 406ae03551e1c2f08af097c6fa1f326271d4cdbf Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 18:45:55 +0000 Subject: [PATCH 08/46] feat(approvals): netclaw approvals trust-verb + folder-scoped revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 6 of the approval-policy-v2 OpenSpec change. Replaces the section 1 interim revoke parser with a strict parser for the user- visible scope labels emitted by 'list', and adds the 'trust-verb' subcommand for pre-approving global wildcards from the CLI. Revoke parser: - Accepts only the two forms 'list' emits: ' in ' -> (verb, directory) entry ' anywhere' -> (verb, null) global wildcard - Anything else exits 1 with a clear message — bare verb input no longer silently treated as a global wildcard, so an operator typo cannot widen the intended scope. The TryParseRevokePattern helper is internal so tests can exercise the parser surface directly without the CLI shell. trust-verb subcommand: - 'netclaw approvals trust-verb [--audience ] [--tool ]' - Default audience = personal, default tool = shell_execute. - Writes a (verb, null) entry to tool-approvals.json — the global-wildcard form. Idempotent: existing entry exits zero with a "No changes" message; otherwise prints "Trusted ' anywhere' for / ". - This is the deliberate scriptable path the spec calls out for unattended/scheduled task pre-approval. Combined with section 5's safe-verb short-circuit it covers two distinct user goals: short-circuit (read-only verbs in safe spaces, no persistence) versus trust-verb (any verb, anywhere, persisted). Help text updated to document both new forms; quarantine note from section 1 already covers the .v1.bak case. Tests (Cli.Tests 620 -> 629): - Revoke folder-scoped form removes entry with matching directory; folder-scoped form does not match a global-wildcard entry; unrecognized pattern exits 1 with clear message. - trust-verb adds global wildcard with default audience/tool; idempotent on repeated invocation; honors --audience/--tool; missing verb argument exits 1 with usage; unknown audience flag exits 1. - Help output mentions trust-verb subcommand. TUI display already shows verb + directory via DisplayText (landed in section 1). The trust-verb-from-TUI affordance is deferred — the agent path is CLI-only and the CLI works for human operators too; revisit if friction surfaces. All 3397 tests pass; slopwatch clean; file headers verified. --- openspec/changes/approval-policy-v2/tasks.md | 14 +- .../Approvals/ApprovalsCommandTests.cs | 124 +++++++++++++ src/Netclaw.Cli/Approvals/ApprovalsCommand.cs | 165 ++++++++++++++++-- 3 files changed, 277 insertions(+), 26 deletions(-) diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index 8a261f1a..d653d853 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -51,13 +51,13 @@ Both PRs sit under this single OpenSpec change. ## 6. CLI updates (list/revoke/trust-verb) -- [ ] 6.1 Update `ApprovalsListView` JSON shape to reflect `ApprovalEntry`. -- [ ] 6.2 Update `ApprovalsCommand list` to render entries with scope labels (` in ` / ` anywhere`). -- [ ] 6.3 Update `ApprovalsCommand revoke` to accept the user-visible forms above as the pattern argument; route to `RemoveApproval` with parsed `ApprovalEntry`. -- [ ] 6.4 Add `ApprovalsCommand trust-verb [--audience] [--tool]` subcommand. Idempotent: existing `(verb, null)` entry → exit zero with "no changes". -- [ ] 6.5 Update `ApprovalsManagerPage` (TUI) to show verb + directory columns; revocation + trust-verb both reachable from the TUI. -- [ ] 6.6 Update CLI quarantine-detection note to point at `.v1.bak` (was `.invalid` for v1's malformed-file path; now also fires when v1 is detected during upgrade). -- [ ] 6.7 Tests: `list` stable ordering; `list --json` shape; `revoke` of folder-scoped and global forms; `revoke` no-match exit 1; `trust-verb` adds and is idempotent; `trust-verb` honors audience/tool flags. +- [x] 6.1 Update `ApprovalsListView` JSON shape to reflect `ApprovalEntry`. +- [x] 6.2 Update `ApprovalsCommand list` to render entries with scope labels (` in ` / ` anywhere`). +- [x] 6.3 Update `ApprovalsCommand revoke` to accept the user-visible forms above as the pattern argument; route to `RemoveApproval` with parsed `ApprovalEntry`. +- [x] 6.4 Add `ApprovalsCommand trust-verb [--audience] [--tool]` subcommand. Idempotent: existing `(verb, null)` entry → exit zero with "no changes". +- [x] 6.5 Update `ApprovalsManagerPage` (TUI) to show verb + directory columns; revocation + trust-verb both reachable from the TUI. (Display: done in section 1 via `ApprovalDisplayItem.DisplayText`. Trust-verb-from-TUI affordance is deferred — agent path is CLI-only and human path lands without it; revisit in PR2 if friction surfaces.) +- [x] 6.6 Update CLI quarantine-detection note to point at `.v1.bak` (was `.invalid` for v1's malformed-file path; now also fires when v1 is detected during upgrade). +- [x] 6.7 Tests: `list` stable ordering; `list --json` shape; `revoke` of folder-scoped and global forms; `revoke` no-match exit 1; `trust-verb` adds and is idempotent; `trust-verb` honors audience/tool flags. ## 7. Prompt redesign (Slack) diff --git a/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs b/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs index 5ba97c59..967f85ac 100644 --- a/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Approvals/ApprovalsCommandTests.cs @@ -242,4 +242,128 @@ public async Task Revoke_unscoped_removes_match_across_audiences() Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); Assert.Empty(_store.GetApprovedEntries(TrustAudience.Public, "shell_execute")); } + + // ── Folder-scoped revoke ── + + [Fact] + public async Task Revoke_folder_scoped_form_removes_entry_with_matching_directory() + { + _store.AddApproval(TrustAudience.Personal, "shell_execute", InDir("git remote", "/home/user/repos/foo")); + + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "revoke", "git remote in /home/user/repos/foo"], + _paths, _output); + + Assert.Equal(0, exit); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + } + + [Fact] + public async Task Revoke_folder_scoped_form_does_not_match_global_wildcard() + { + // The store has a (verb, null) entry; a folder-scoped revoke should not remove it. + _store.AddApproval(TrustAudience.Personal, "shell_execute", Verb("git remote")); + + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "revoke", "git remote in /home/user/repos/foo"], + _paths, _output); + + Assert.Equal(1, exit); + var remaining = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(remaining); + Assert.Null(remaining[0].Directory); + } + + [Fact] + public async Task Revoke_unrecognized_pattern_exits_one_with_clear_message() + { + // No "anywhere" suffix and no " in " separator — not a valid revoke pattern. + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "revoke", "git remote"], + _paths, _output); + + Assert.Equal(1, exit); + var output = _output.ToString(); + Assert.Contains("Could not parse revoke pattern", output); + Assert.Contains("' in ' or ' anywhere'", output); + } + + // ── trust-verb ── + + [Fact] + public async Task TrustVerb_adds_global_wildcard_with_default_audience_and_tool() + { + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "trust-verb", "freshdesk"], + _paths, _output); + + Assert.Equal(0, exit); + var entries = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(entries); + Assert.Equal("freshdesk", entries[0].Verb); + Assert.Null(entries[0].Directory); + Assert.Contains("Trusted 'freshdesk anywhere'", _output.ToString()); + } + + [Fact] + public async Task TrustVerb_is_idempotent_on_repeated_invocation() + { + await ApprovalsCommand.RunAsync(["approvals", "trust-verb", "freshdesk"], _paths, _output); + _output.GetStringBuilder().Clear(); + + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "trust-verb", "freshdesk"], + _paths, _output); + + Assert.Equal(0, exit); + var entries = _store.GetApprovedEntries(TrustAudience.Personal, "shell_execute"); + Assert.Single(entries); + Assert.Contains("No changes", _output.ToString()); + } + + [Fact] + public async Task TrustVerb_honors_audience_and_tool_flags() + { + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "trust-verb", "freshdesk", "--audience", "team", "--tool", "shell_execute"], + _paths, _output); + + Assert.Equal(0, exit); + Assert.Single(_store.GetApprovedEntries(TrustAudience.Team, "shell_execute")); + Assert.Empty(_store.GetApprovedEntries(TrustAudience.Personal, "shell_execute")); + } + + [Fact] + public async Task TrustVerb_without_verb_argument_exits_one_with_usage() + { + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "trust-verb"], + _paths, _output); + + Assert.Equal(1, exit); + Assert.Contains("Usage: netclaw approvals trust-verb", _output.ToString()); + } + + [Fact] + public async Task TrustVerb_unknown_audience_exits_one() + { + var exit = await ApprovalsCommand.RunAsync( + ["approvals", "trust-verb", "freshdesk", "--audience", "bogus"], + _paths, _output); + + Assert.Equal(1, exit); + Assert.Contains("Unknown audience 'bogus'", _output.ToString()); + } + + // ── help mentions trust-verb ── + + [Fact] + public async Task Help_lists_trust_verb_subcommand() + { + await ApprovalsCommand.RunAsync(["approvals", "help"], _paths, _output); + + var output = _output.ToString(); + Assert.Contains("trust-verb", output); + Assert.Contains("global-wildcard", output); + } } diff --git a/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs b/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs index 1b0e45bd..20f540e8 100644 --- a/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs +++ b/src/Netclaw.Cli/Approvals/ApprovalsCommand.cs @@ -21,6 +21,9 @@ private sealed record ListOptions(TrustAudience? Audience, string? Tool, bool Em private sealed record RevokeOptions(string? Pattern, TrustAudience? Audience, string? Tool, bool RevokeAll); + private sealed record TrustVerbOptions(string Verb, TrustAudience Audience, string Tool); + + public const string DefaultTrustVerbTool = "shell_execute"; public static Task RunAsync( string[] args, @@ -34,6 +37,7 @@ public static Task RunAsync( { "list" => Task.FromResult(RunList(args, paths, writer)), "revoke" => Task.FromResult(RunRevoke(args, paths, writer)), + "trust-verb" => Task.FromResult(RunTrustVerb(args, paths, writer)), "help" or "-h" or "--help" => Task.FromResult(WriteHelp(writer)), _ => Task.FromResult(WriteHelp(writer)), }; @@ -113,12 +117,14 @@ private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter write return 1; } - // Section 1 interim: revoke parses the legacy single-string form as a - // verb-only global-wildcard lookup so the command compiles and the - // existing test corpus continues to round-trip. Section 6 replaces - // this with a parser for the user-visible forms ("verb in /dir/" and - // "verb anywhere") emitted by `list` above. - var lookup = ParseRevokePatternInterim(opts.Pattern); + if (!TryParseRevokePattern(opts.Pattern, out var lookup, out var parseError)) + { + writer.WriteLine($"Error: {parseError}"); + writer.WriteLine("Patterns must use the form ' in ' or ' anywhere',"); + writer.WriteLine("matching the labels emitted by 'netclaw approvals list'."); + return 1; + } + var snapshot = store.Snapshot(); var removedAny = false; @@ -151,20 +157,134 @@ private static int RunRevoke(string[] args, NetclawPaths paths, TextWriter write return 0; } - private static ApprovalEntry ParseRevokePatternInterim(string pattern) + /// + /// Parses a revoke-pattern argument into a typed + /// lookup, accepting only the user-visible forms emitted by + /// : + /// + /// <verb> in <directory> — folder-scoped grant. + /// <verb> anywhere — global wildcard. + /// + /// Anything else is rejected loudly: the CLI surface is the deliberate + /// scriptable path, so silently treating an unrecognized pattern as a + /// global wildcard would let an operator typo into broader removal than + /// they meant. + /// + internal static bool TryParseRevokePattern(string pattern, out ApprovalEntry entry, out string error) + { + entry = new ApprovalEntry { Verb = string.Empty }; + error = string.Empty; + + const string AnywhereSuffix = " anywhere"; + const string InSeparator = " in "; + + var trimmed = pattern.Trim(); + if (trimmed.Length == 0) + { + error = "Revoke pattern must not be empty."; + return false; + } + + if (trimmed.EndsWith(AnywhereSuffix, StringComparison.Ordinal)) + { + var verb = trimmed[..^AnywhereSuffix.Length].TrimEnd(); + if (verb.Length == 0) + { + error = "Revoke pattern ' anywhere' must include a verb."; + return false; + } + entry = new ApprovalEntry { Verb = verb, Directory = null }; + return true; + } + + // First " in " separates verb from directory. Verb chains in our + // safe-verbs list never contain " in " so this split is unambiguous + // for legitimate inputs; if a user has somehow registered a verb + // chain that contains " in ", they can revoke via the TUI instead. + var inIndex = trimmed.IndexOf(InSeparator, StringComparison.Ordinal); + if (inIndex > 0) + { + var verb = trimmed[..inIndex].TrimEnd(); + var directory = trimmed[(inIndex + InSeparator.Length)..].TrimStart(); + if (verb.Length == 0 || directory.Length == 0) + { + error = "Revoke pattern ' in ' must include both verb and directory."; + return false; + } + entry = new ApprovalEntry { Verb = verb, Directory = directory }; + return true; + } + + error = $"Could not parse revoke pattern '{pattern}'."; + return false; + } + + private static int RunTrustVerb(string[] args, NetclawPaths paths, TextWriter writer) + { + if (TryParseTrustVerbFlags(args, writer) is not { } opts) + return 1; + + var store = new ToolApprovalStore(paths.ToolApprovalsPath); + WarnIfQuarantined(store, writer); + + var entry = new ApprovalEntry { Verb = opts.Verb, Directory = null }; + + var existing = store.GetApprovedEntries(opts.Audience, opts.Tool); + var alreadyTrusted = existing.Any(e => ToolApprovalEntryComparer.Equals(e, entry)); + + if (alreadyTrusted) + { + writer.WriteLine($"No changes: '{opts.Verb} anywhere' is already trusted for {opts.Audience.ToWireValue()} / {opts.Tool}."); + return 0; + } + + store.AddApproval(opts.Audience, opts.Tool, entry); + writer.WriteLine($"Trusted '{opts.Verb} anywhere' for {opts.Audience.ToWireValue()} / {opts.Tool}."); + return 0; + } + + private static TrustVerbOptions? TryParseTrustVerbFlags(string[] args, TextWriter writer) { - // Accepts the global-wildcard form " anywhere" emitted by the - // section 1 list rendering, and falls back to treating the entire - // pattern as a verb (directory: null). Folder-scoped revoke parsing - // (" in ") lands in section 6. - const string Suffix = " anywhere"; - if (pattern.EndsWith(Suffix, StringComparison.Ordinal)) + string? verb = null; + TrustAudience? audience = null; + string? tool = null; + + for (var i = 2; i < args.Length; i++) { - var verb = pattern[..^Suffix.Length].TrimEnd(); - if (verb.Length > 0) - return new ApprovalEntry { Verb = verb, Directory = null }; + switch (TryConsumeSharedFlag(args, ref i, writer, ref audience, ref tool)) + { + case FlagOutcome.Consumed: continue; + case FlagOutcome.Error: return null; + } + + var arg = args[i]; + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + writer.WriteLine($"Error: Unknown flag: {arg}"); + return null; + } + + if (verb is not null) + { + writer.WriteLine($"Error: Unexpected extra argument: {arg}"); + return null; + } + verb = arg; + } + + if (string.IsNullOrWhiteSpace(verb)) + { + writer.WriteLine("Usage: netclaw approvals trust-verb [--audience personal|team|public] [--tool ]"); + writer.WriteLine(); + writer.WriteLine("Adds a global-wildcard '(verb, null)' approval entry — the verb runs in any cwd"); + writer.WriteLine("without prompting. Used to pre-approve verbs for unattended/scheduled tasks."); + return null; } - return new ApprovalEntry { Verb = pattern, Directory = null }; + + return new TrustVerbOptions( + Verb: verb!.Trim(), + Audience: audience ?? TrustAudience.Personal, + Tool: string.IsNullOrWhiteSpace(tool) ? DefaultTrustVerbTool : tool); } private static int RunRevokeAll(RevokeOptions opts, ToolApprovalStore store, TextWriter writer) @@ -192,16 +312,23 @@ private static int WriteHelp(TextWriter writer) writer.WriteLine(" (none) | tui Launch the interactive approvals TUI."); writer.WriteLine(" list List persistent approvals from tool-approvals.json."); writer.WriteLine(" Flags: --audience , --tool , --json"); - writer.WriteLine(" revoke Remove an exact-match approval entry."); + writer.WriteLine(" revoke Remove an approval entry by its user-visible form:"); + writer.WriteLine(" ' in ' — folder-scoped grant"); + writer.WriteLine(" ' anywhere' — global wildcard"); writer.WriteLine(" Flags: --audience , --tool "); writer.WriteLine(" revoke --tool --all"); writer.WriteLine(" Remove every approval entry for a tool."); writer.WriteLine(" Flags: --audience "); + writer.WriteLine(" trust-verb Add a global-wildcard '(verb, null)' approval — the verb runs"); + writer.WriteLine(" in any cwd without prompting. Use to pre-approve verbs for"); + writer.WriteLine(" unattended or scheduled invocations."); + writer.WriteLine(" Flags: --audience (default personal)"); + writer.WriteLine(" --tool (default shell_execute)"); writer.WriteLine(" help Show this message."); writer.WriteLine(); writer.WriteLine("Exit codes: 0 success, 1 user error or no match."); writer.WriteLine(); - writer.WriteLine("The daemon does not require a restart after a revoke; the next approval"); + writer.WriteLine("The daemon does not require a restart after these mutations; the next approval"); writer.WriteLine("check re-reads the file."); return 0; } From 8de3f73b839873f5dabe10038d98d7a4cc9cbf98 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 8 May 2026 18:55:55 +0000 Subject: [PATCH 09/46] feat(approvals): five-button Slack approval prompt with cwd header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 7 of the approval-policy-v2 OpenSpec change. Replaces the v1 Slack approval prompt (4 buttons + Patterns/Directory Roots sections) with the v2 design: 5 buttons, danger styling on the elevated decisions, cwd in the header, verbs as bullets, and a single-line resolution message. Five-button row (ApprovalOptionKeys): Once (primary) - no persist This chat (default) - session-scoped only Always here (default) - persist (verb, cwd) Always anywhere (danger) - persist (verb, null) Deny (danger) - refuse this call ApprovalOptionKeys gains ApproveEverywhere/ApproveEverywhereLabel ("Always anywhere") and renames the existing labels to the spec spelling: "Once" / "This chat" / "Always here" / "Deny". The wire keys are unchanged so persisted resolutions still decode. ApprovalDecision and ParentApprovalDecision gain ApprovedEverywhere so the runtime can distinguish folder-scoped persistence from global wildcard. LlmSessionActor maps the new button key, picks cwd-or-null based on which decision was chosen, and threads through RecordApprovalAsync. ToolApprovalActor's persistent-write path now uses msg.Cwd directly (replacing the section 1 interim that always wrote null), so: Always here -> AddApproval(audience, tool, (verb, msg.Cwd)) Always anywhere -> AddApproval(audience, tool, (verb, null)) Button-row gating by IsMessy / cwd-shallow: IsMessy -> only Once + Deny (no persistence possible) cwd shallow -> Always here omitted (This chat / Always anywhere still available; matches the tool-approval-gates "Shallow directory prevents Always here" scenario) otherwise -> all five buttons Cwd-shallow check in ToolAccessPolicy: a path with fewer than two non-empty path segments under its root (e.g. /, /etc/, C:\) cannot host a folder-scoped grant; fail-closed on Always here so an operator cannot accidentally persist a too-shallow root. Slack prompt body changes: Header (single verb): "Approve git status in /home/user/repos/foo?" Header (multi-verb): "Approve in /home/user/repos/foo?" + "• git fetch / • git rebase / • git status" Messy: "_complex command — only one-shot approval available_" The Patterns and Directory Roots sections are gone; verb display flows from CandidateVerbs (the v2 matcher's pure verb-chain extraction) with a Patterns fallback for legacy callers. Resolution message single-line format: Always here -> "Saved: in " Always anywhere -> "Saved: anywhere" This chat -> "Saved for this chat: in " Once -> "Approved (no save)" Deny -> "Denied" Tests (Actors.Tests 1497 -> 1507): - New SlackApprovalBlockBuilderTests covers all the spec scenarios: single-verb header, multi-verb bulleted header, messy hint, five-button row with danger styling on Always anywhere + Deny, legacy Directory Roots / Patterns sections gone, and all five resolution-message branches (Always here / Always anywhere / This chat / Once / Deny). - Existing DiscordApprovalPromptBuilderTests label expectations bumped to the new spelling ("Once" / "Always here"). All 3407 tests pass; slopwatch clean; file headers verified. Discord rendering still on v1 — section 8 mirrors this design over. --- openspec/changes/approval-policy-v2/tasks.md | 14 +- .../DiscordApprovalPromptBuilderTests.cs | 7 +- .../SlackApprovalBlockBuilderTests.cs | 181 ++++++++++++++++++ .../Protocol/ApprovalOptionKeys.cs | 32 +++- .../Sessions/IApprovalChannel.cs | 15 +- .../Sessions/LlmSessionActor.cs | 19 +- .../Sessions/ParentSessionApprovalBridge.cs | 1 + .../Pipelines/SessionToolExecutionPipeline.cs | 5 +- src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 3 +- src/Netclaw.Actors/Tools/ToolAccessPolicy.cs | 88 ++++++++- src/Netclaw.Actors/Tools/ToolApprovalActor.cs | 16 +- .../SlackApprovalBlockBuilder.cs | 144 ++++++++------ .../IParentApprovalBridge.cs | 1 + 13 files changed, 434 insertions(+), 92 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs diff --git a/openspec/changes/approval-policy-v2/tasks.md b/openspec/changes/approval-policy-v2/tasks.md index d653d853..07eaf5f1 100644 --- a/openspec/changes/approval-policy-v2/tasks.md +++ b/openspec/changes/approval-policy-v2/tasks.md @@ -61,13 +61,13 @@ Both PRs sit under this single OpenSpec change. ## 7. Prompt redesign (Slack) -- [ ] 7.1 Add `ApprovalOptionKeys.ApproveEverywhere` constant ("Always anywhere"). -- [ ] 7.2 Update `SlackApprovalBlockBuilder` to render the 5-button row with `Once` / `This chat` / `Always here` / `Always anywhere` / `Deny` and apply `style: "danger"` on `Always anywhere` and `Deny`. -- [ ] 7.3 Update prompt body: header `Approve in ?` (or `Approve in ?` for single-verb), bulleted verbs, no `Patterns` / `Directory Roots` sections. -- [ ] 7.4 When the cwd is too shallow (fails minimum-depth check) or the command is "messy" (per task 3.4), omit `This chat`/`Always here`/`Always anywhere` and emit the "complex command" hint. -- [ ] 7.5 Update `SlackApprovalHandler` to map button clicks to the right persistence path: Once → no-op; This chat → session-scoped store; Always here → `(verb, cwd)` per extracted verb; Always anywhere → `(verb, null)` per extracted verb; Deny → refuse this call. -- [ ] 7.6 Update resolution message to the single-line format from the spec. -- [ ] 7.7 Snapshot tests for prompt body (single-verb + compound + messy) and resolution message (Once / This chat / Always here / Always anywhere / Deny). +- [x] 7.1 Add `ApprovalOptionKeys.ApproveEverywhere` constant ("Always anywhere"). +- [x] 7.2 Update `SlackApprovalBlockBuilder` to render the 5-button row with `Once` / `This chat` / `Always here` / `Always anywhere` / `Deny` and apply `style: "danger"` on `Always anywhere` and `Deny`. +- [x] 7.3 Update prompt body: header `Approve in ?` (or `Approve in ?` for single-verb), bulleted verbs, no `Patterns` / `Directory Roots` sections. +- [x] 7.4 When the cwd is too shallow (fails minimum-depth check) or the command is "messy" (per task 3.4), omit `This chat`/`Always here`/`Always anywhere` and emit the "complex command" hint. (Messy → only Once/Deny per spec scenario; shallow → only `Always here` omitted, This chat / Always anywhere remain per `tool-approval-gates` "Shallow directory prevents Always here" scenario.) +- [x] 7.5 Update `SlackApprovalHandler` to map button clicks to the right persistence path: Once → no-op; This chat → session-scoped store; Always here → `(verb, cwd)` per extracted verb; Always anywhere → `(verb, null)` per extracted verb; Deny → refuse this call. +- [x] 7.6 Update resolution message to the single-line format from the spec. +- [x] 7.7 Snapshot tests for prompt body (single-verb + compound + messy) and resolution message (Once / This chat / Always here / Always anywhere / Deny). ## 8. Prompt redesign (Discord) diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs index 36864e9d..55d9716b 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordApprovalPromptBuilderTests.cs @@ -68,8 +68,11 @@ public void BuildTextPrompt_omits_pattern_when_empty() [Fact] public void BuildDecisionStatus_formats_known_keys() { - Assert.Contains("Approve once", DiscordApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveOnce)); - Assert.Contains("Approve always", DiscordApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveAlways)); + // Labels updated in section 7 (approval-policy-v2) — see ApprovalOptionKeys. + // Discord prompt body redesign to single-line resolution lands in section 8; + // for now we only assert the new label spellings make it through. + Assert.Contains("Once", DiscordApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveOnce)); + Assert.Contains("Always here", DiscordApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveAlways)); Assert.Contains("Deny", DiscordApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.Deny)); } diff --git a/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs new file mode 100644 index 00000000..829bb555 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/SlackApprovalBlockBuilderTests.cs @@ -0,0 +1,181 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Slack; +using Netclaw.Tools; +using SlackNet.Blocks; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class SlackApprovalBlockBuilderTests +{ + private static IReadOnlyList FullButtonRow() => + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhere, ApprovalOptionKeys.ApproveEverywhereLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ]; + + private static IReadOnlyList MessyRow() => + [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ]; + + private static ToolInteractionRequest Request( + string command, + IReadOnlyList verbs, + string? cwd, + IReadOnlyList options, + bool isMessy = false) + => new() + { + SessionId = new SessionId("signalr/test"), + Kind = "approval", + CallId = "call-1", + ToolName = "shell_execute", + DisplayText = command, + RequesterSenderId = "device-1", + Patterns = verbs, + CandidateVerbs = verbs, + DirectoryRoots = [], + Cwd = cwd, + IsMessy = isMessy, + Options = options + }; + + [Fact] + public void Single_verb_collapses_into_header_line() + { + var request = Request("git status", ["git status"], "/home/user/repos/foo", FullButtonRow()); + + var text = SlackApprovalBlockBuilder.BuildApprovalText(request); + + Assert.Contains("Approve git status in /home/user/repos/foo?", text); + Assert.DoesNotContain("• `git status`", text); // No redundant bullet for single-verb + } + + [Fact] + public void Multi_verb_uses_generic_header_with_bulleted_verbs() + { + var request = Request( + "git fetch && git rebase && git status", + ["git fetch", "git rebase", "git status"], + "/home/user/repos/foo", + FullButtonRow()); + + var text = SlackApprovalBlockBuilder.BuildApprovalText(request); + + Assert.Contains("Approve in /home/user/repos/foo?", text); + Assert.Contains("• `git fetch`", text); + Assert.Contains("• `git rebase`", text); + Assert.Contains("• `git status`", text); + } + + [Fact] + public void Messy_command_emits_complex_command_hint() + { + var request = Request( + "for f in *.log; do grep ERROR \"$f\"; done", + verbs: [], + cwd: "/home/user/repos/foo", + options: MessyRow(), + isMessy: true); + + var text = SlackApprovalBlockBuilder.BuildApprovalText(request); + + Assert.Contains("complex command", text); + Assert.Contains("only one-shot approval available", text); + } + + [Fact] + public void Approval_blocks_render_five_buttons_with_danger_styling_on_danger_options() + { + var request = Request("git status", ["git status"], "/home/user/repos/foo", FullButtonRow()); + + var blocks = SlackApprovalBlockBuilder.BuildApprovalBlocks(request); + var actions = blocks.OfType().Single(); + var buttons = actions.Elements.OfType