From 42fd66a69889c3b679d5e7bad59f5d4927fc0b3d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 18:10:59 +0000 Subject: [PATCH] docs(openspec): structure shell approval policy --- IMPLEMENTATION_PLAN.md | 30 +- .../.openspec.yaml | 2 + .../structure-shell-approval-policy/design.md | 393 ++++++++++ .../evidence/approval-matrix.json | 175 +++++ .../evidence/netclaw-policy-fixtures.json | 390 ++++++++++ .../proposal.md | 56 ++ .../specs/tool-approval-gates/spec.md | 673 ++++++++++++++++++ .../structure-shell-approval-policy/tasks.md | 138 ++++ 8 files changed, 1854 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/structure-shell-approval-policy/.openspec.yaml create mode 100644 openspec/changes/structure-shell-approval-policy/design.md create mode 100644 openspec/changes/structure-shell-approval-policy/evidence/approval-matrix.json create mode 100644 openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json create mode 100644 openspec/changes/structure-shell-approval-policy/proposal.md create mode 100644 openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md create mode 100644 openspec/changes/structure-shell-approval-policy/tasks.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index f408b5403..26e1cdd48 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -158,6 +158,26 @@ without allowing an incomplete or unknown shell form. Done when: +- [x] The sanitized v0.26.0-beta.3 approval window has 18 classified cases in + the linked ShellSyntaxTree and Netclaw OpenSpec changes. +- [x] `openspec/changes/structure-shell-approval-policy/` defines the typed + coordinator and actor protocol, per-candidate coverage, real and intent + scopes, token-boundary grants, reviewed policy catalog, bounded trace, + migration, and validation tasks. +- [ ] The maintainer approves the ShellSyntaxTree 0.3.1 API names and the + use of separate authored-source facts for approval matching before + implementation. +- [ ] The maintainer approves whether simple v2 grants gain token-prefix + authority during schema-3 migration or remain exact until re-approved. +- [ ] The policy pipeline replaces the shell branches in `ToolAccessPolicy` + and `ShellApprovalMatcher`; any retained legacy scan is deny-only and cannot + authorize, create candidates, or widen scope. +- [ ] The bundled safe catalog removes every executable whose accepted + arguments can write, delete, execute code, or mutate a remote service through + executable argv interpretation. Redirect, parser-owned path/provider, and + unknown shell-expansion checks remain separate. +- [ ] The `git ls-tree` production special case is removed. Token-boundary + policy match handles longer parser phrases without private command grammar. - [x] A synthetic workload corpus covers ordinary search, read, pipeline, redirect, and file-change commands without production command text. - [x] A safe pipeline stage can compose with a stored grant for each stage that @@ -215,13 +235,17 @@ Done when: - [x] A constrained stdin grammar allows a complete literal heredoc or bounded here string only for argument-free `cat`. Unknown data, expanding heredocs, arguments, wrappers, interpreters, and stored grants stay strict. -- [ ] Netclaw interprets bounded loop arguments only after the executor can - prove the Bash initial variable state. The inherited shell state remains - fail closed because an ambient nameref can change assignment semantics. +- [ ] Netclaw interprets bounded loop arguments only after the maintainer + accepts ShellSyntaxTree 0.3.1's separate authored-source projection for + approval matching. Effective runtime values remain unknown when hidden + ambient Bash attributes can transform them. - [x] The approval matrix pins inherited and same-language child loops as complex under the canonical unknown-state contract for Bash, PowerShell 7, and Windows PowerShell 5.1. It also proves that a stored command grant cannot cover an unproved loop-dependent argument. + - [ ] Netclaw adopts `AuthoredValue`, positive authored path evidence, + bounded status concatenation, and the typed consumer boundary after the + paired package release. ### Priority: Use Native PowerShell on Windows diff --git a/openspec/changes/structure-shell-approval-policy/.openspec.yaml b/openspec/changes/structure-shell-approval-policy/.openspec.yaml new file mode 100644 index 000000000..a8821c74d --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/structure-shell-approval-policy/design.md b/openspec/changes/structure-shell-approval-policy/design.md new file mode 100644 index 000000000..e009c4457 --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/design.md @@ -0,0 +1,393 @@ +## Context + +The exact sanitized harvest is `evidence/approval-matrix.json`. The approval +store contained 435 Personal shell grants and no new persistent grant during +the window. The observed responses were one-time, session, denied, or pending. + +Current ownership is split. `ToolAccessPolicy` performs synchronous policy, +`DispatchingToolExecutor` coordinates asynchronous approval, +`ToolApprovalAttempt` owns the exact one-time grant for one invocation, +`ToolApprovalActor` owns session and persistent grants, and the session +pipeline owns pending requests, responses, and recovery. The redesign must +preserve those boundaries while making one explainable decision. + +ShellSyntaxTree supplies shell facts. Netclaw owns authority. Executable-private +options and operands remain outside both the policy evaluator and safe-catalog +code. + +## Goals / Non-Goals + +**Goals:** + +- Build one immutable preflight fact set. +- Compose grants and safe policy per candidate. +- Preserve one atomic actor snapshot for session and persistent grants. +- Keep real execution facts separate from approval-intent facts. +- Make hard deny, protected paths, and internal errors terminal. +- Use versioned shell-token grant phrases without raw prefix matching. +- Bound and redact every trace field. +- Pin exact Linux and native Windows outcomes. + +**Non-Goals:** + +- Parse Git, GitHub CLI, .NET CLI, or any other executable's private grammar. +- Rewrite a tool call, execution source, working directory, or model history. +- Treat approval as a process sandbox. +- Infer ambient profiles, aliases, functions, modules, or environment values. +- Auto-approve remote mutation, repository moves, runtime-generated loops, or + unsupported control flow. +- Add PowerShell causal scope in this slice. + +## Decisions + +### 1. Coordinate preflight, actor match, and completion + +The executor uses one coordinator. It receives the existing `ToolName`, +`ToolExecutionContext`, argument object, `ShellExecutionEnvironment`, and +`ToolApprovalMode`. At entry it snapshots the immutable `ToolRunScope` facts +and the exact `ToolApprovalAttempt.OneTimeApprovedPatterns` set. Despite the +legacy property name, those strings are `OneTimeApprovalKeys` that bind the +filtered phrase and effective-directory set. The coordinator does not invent a +parallel execution context or a scalar retry key. + +Only the actor seam needs a new protocol shape: + +```csharp +internal sealed record ShellApprovalMatchRequest( + SessionId? SessionId, + TrustAudience Audience, + ToolName ToolName, + ShellExecutionEnvironment Environment, + IReadOnlyList Candidates); + +internal sealed record ShellGrantCandidate( + int CandidateId, + ShellGrantPhrase Phrase, + string? RealDirectory); + +internal sealed record ShellApprovalMatchResult( + PersistentGrantStoreStatus PersistentStore, + IReadOnlyList CandidateMatches); + +internal abstract record PersistentGrantStoreStatus +{ + private PersistentGrantStoreStatus() { } + internal sealed record Ready : PersistentGrantStoreStatus; + internal sealed record Unavailable(ShellPolicyReason Reason) : PersistentGrantStoreStatus; +} + +internal sealed record ShellGrantCandidateMatch( + int CandidateId, + ToolApprovalMatch? Match, + IReadOnlyList NearMisses); +``` + +`ShellExecutionEnvironment` carries platform, canonical grammar, path style, +and PowerShell dialect. `ToolRunScope.Session` may be sessionless; a bound +scope supplies its nullable session directory. No protocol field assumes a +session ID or directory exists. + +`DispatchingToolExecutor` runs preflight once. Its ordered synchronous stages +are canonical parse validation, hard deny, protected paths, approval-mode +resolution, candidate construction, and the existing +`InteractiveApprovalCapability.Unavailable` trust-zone enforcement. A +terminal deny returns immediately. Otherwise it sends exactly one batch +request to `ToolApprovalActor`. The actor atomically snapshots inherited +session grants and, when available, persistent grants; matches every candidate; +and returns the match for each stable ID plus typed persistent-store status. It +does not own or inspect one-time state. An absent file is `Ready` with an empty +persistent snapshot. Expected corruption or migration failure is +`Unavailable`; an unexpected actor/protocol failure remains an internal error. + +The coordinator imports actor coverage, applies reviewed safe policy to still +uncovered candidates, and finally checks whether the invocation-owned exact +one-time approval-key set satisfies the remaining prompt as a set. It constructs +one result and never rescans grants. When every candidate is covered by +one-time, session, or reviewed-safe authority, persistent-store unavailability +does not deny the call. If any candidate remains uncovered after those sources +and the persistent store was unavailable, completion returns terminal +`ApprovalStoreUnavailable` instead of a prompt. + +Implementation must reuse `ToolExecutionContext`, `ToolAccessDecision`, +`ApprovalCandidate`, `ToolApprovalCheckResult`, `ToolApprovalMatch`, and +`ToolApprovalRequiredContext` where their current contracts fit. It must +replace overlapping types instead of leaving a parallel model. A new DTO is +justified only for the actor batch protocol or a fact no current type can +represent. + +Ownership does not move: `ToolApprovalAttempt` owns one-time invocation state; +`ToolApprovalActor` owns session inheritance and persistent snapshots; the +session pipeline owns pending approval, response validation, stale-response +rejection, and recovery. Coordinator facts are immutable snapshots, not actor +or pipeline state. + +### 2. Track coverage per candidate + +Every ShellSyntaxTree occurrence receives a stable call-local `CandidateId`. +Coverage is a state machine: + +```csharp +internal enum ShellCoverageKind +{ + Uncovered = 0, + OneTime = 1, + Session = 2, + PersistentGlobal = 3, + PersistentFolder = 4, + ReviewedSafePolicy = 5, + Denied = 6, +} + +internal sealed record ShellCandidateCoverage( + int CandidateId, + ShellCoverageKind Kind, + ShellPolicyReason Reason); +``` + +A stage may refine only `Uncovered`. Deny is terminal. Allow occurs only when +every candidate has non-deny coverage and every call-level invariant passes. +An expected unresolved parse may produce a one-time prompt with no reusable +choices. An exception, invalid enum, mismatched candidate ID, duplicate actor +result, or impossible transition is an internal failure and terminal deny. + +D03 therefore composes global `cd` and `gh api` grants with reviewed `wc` and +`head` safe coverage instead of requiring one stage to authorize the whole +call. + +### 3. Keep authorization on canonical facts; retain deny-only defenses + +The canonical ShellSyntaxTree result supplies occurrences, paths, redirects, +and control flow to every authorization stage. No second tokenizer may allow, +create candidates, widen scope, or create persistence choices. + +Existing legacy scans may remain only as deny-only defense when canonical +analysis is incomplete. A deny-only scan can convert prompt to deny. It cannot +convert deny or prompt to allow, and its output cannot enter a stored grant. + +### 4. Model causal approval intent separately + +`Execution` is the unmodified ShellSyntaxTree analysis at the real starting +directory. Hard deny, protected paths, folder grants, noninteractive authority, +and process execution use it. + +For Bash only, `Intent` can carry the exact target of a leading authored +directory transition. The transition begins on the success edge of `cd TARGET +&& ...`. It may remain the user's approval scope for later top-level diagnostic +occurrences until invalidated, even when a semicolon means runtime failure +would continue in the original directory. This is an approval-intent fact, not +a runtime cwd claim. + +Intent is invalidated by: + +- a later directory mutation whose exact target is unavailable; +- `||`, alternate branches, or a join whose incoming intent scopes differ; +- entry to or exit from a subshell/group boundary unless both sides retain the + same proved intent; +- dynamic identity, command substitution controlling flow, or unsupported + control flow. + +An exact later success-gated `cd` replaces intent on its success edge. Relative +authored paths under intent are rebased only for safe-policy scope; protected +path evaluation also checks their real execution projection. Folder grants +never use intent. + +An intent target is eligible only when it is exact, absolute, normalized, +symlink-free, and allowed by protected-path policy; the `cd` candidate and the +first non-navigation action on its success edge must already have one-time, +session, or stored-grant coverage. This existing user authority is what lets a +later reviewed diagnostic consume intent even when the target is not a normal +session/project safe root. Safe policy alone cannot manufacture causal intent. + +Only a catalog entry proved read-only for every accepted argument shape and an +occurrence without a file-writing redirect can consume eligible intent. Native +PowerShell remains strict in this slice: `Set-Location` does not create causal +intent. Existing PowerShell filesystem-provider checks remain mandatory, and +`Get-Content Env:SECRET` cannot receive filesystem safe-space coverage. + +### 5. Persist typed shell-token phrases in schema version 3 + +New entries persist canonical token arrays plus the canonical shell: + +```json +{ + "shell": "Bash", + "match": "TokenPrefix", + "verbTokens": ["git", "push"], + "directory": null +} +``` + +Matching compares token arrays with the selected shell's case rule. A shorter +grant matches only whole leading tokens. Raw string prefix is never used. + +On first successful load of version 2, plain entries whose `verb` is a +whitespace-separated sequence of safe unquoted atoms migrate according to the +maintainer-approved authority choice. Entries containing quotes, escapes, or +ambiguous whitespace migrate as `LegacyExact` and retain exact-string behavior +only. A structurally valid entry containing controls or otherwise not safely +representable is omitted with one bounded migration diagnostic; it never enters +`LegacyExact`. The original file is copied to `.v2.bak` before one atomic +version-3 replacement. Session grants use the same typed phrase model but are +not persisted. + +Storage recovery is explicit and fail closed: + +- an absent file is a valid empty store; +- an absent-version or version-1 file follows the existing v1 quarantine path, + produces an empty version-3 store only after a successful atomic write, and + emits one bounded operator diagnostic; +- malformed JSON, a partially invalid version-3 file, an invalid enum or token + array, or an unsupported future version makes the approval store unavailable; + no entry from that file authorizes and an approval-dependent call terminates + with `ApprovalStoreUnavailable` rather than offering a prompt; +- a future-version file is never modified or quarantined; +- failure to create the v2 backup aborts migration and leaves v2 untouched; +- failure of the atomic version-3 replacement leaves v2 and any completed + backup intact, marks the store unavailable for that check, and retries + migration on a later load. + +The implementation never salvages individual grants from a partially corrupt +file. This avoids silently changing the authority set. + +This intentionally widens simple existing grants such as `git push` to cover +later static candidate tokens such as `upstream`. It is a material authority +change and requires maintainer approval before implementation. + +### 6. Use a reviewed immutable safe-policy catalog + +The bundled per-platform resource contains typed phrase entries. A +`ReadOnlyForAllArguments` entry means no accepted argument shape can write or +delete a file, execute another command, or mutate a remote service through the +executable's argv interpretation. Redirects, parser-owned path operands, +provider paths, and unknown shell expansions remain separate strict effects. +Displaying a value explicitly supplied by the shell does not itself make a +phrase executable-private or unsafe. + +The catalog is immutable at runtime. User-overridable safe-verb files are +removed because an agent-writable or operator-edited file can silently widen +authority outside code review. At minimum `find`, `awk`, `rg`, and `sort` are +not eligible. Production code has no flag-specific exceptions. The existing +`git ls-tree` special case is deleted. + +### 7. Consume ShellSyntaxTree 0.3.1 facts explicitly + +Netclaw uses effective `AnalyzedArgument.Value` for runtime-sensitive checks. +It may use `AuthoredValue` for approval matching only after the maintainer +accepts that ambient Bash attributes, ambient `IFS`, and field splitting are +outside the approval claim. The existing parser-owned `Argument.IsPath` +contract decides whether a value is path-relevant. Every effective or authored +finite value for an `IsPath` argument still passes `ToolPathPolicy`; an unknown +path-relevant value stays strict. + +`AuthoredPathShape` is lexical shape only. It may make review stricter, but it +never establishes that an executable treats an argument as a filesystem +operand and never creates filesystem authority. Repository slugs, container +images, URIs, and slash-bearing data are counterexamples. + +`IntegerRange` and `Concatenation` are bounded scalar data only. They cannot +select an executable, create path authority, or justify a redirect. The broad +consumer rule that exempts every Bash environment-variable argument is removed. + +### 8. Emit a bounded redacted trace + +The trace contains enum stage, enum outcome, enum reason, call-local candidate +ID, executable basename, coverage kind, scope relation, and grant timestamp. +It contains no full command, argument values, environment values, redirect +bodies, raw paths, tokens, secrets, or model content. + +The trace has at most one row per stage per candidate and 256 rows total. Each +text field is at most 128 UTF-16 code units. CR, LF, other controls, bidi +controls, and invalid Unicode are escaped. Secret-pattern redaction runs before +logging. Overflow replaces later detail with one `TraceTruncated` row; it never +changes the decision. + +The actor returns exact match and bounded near-miss evidence from its one +snapshot. The coordinator projects grant rows from that evidence and returns +one ordered trace. Near-miss diagnostics project from those rows and do not +rescan grants. Trace data is operator-log-only and is not persisted in the +session journal or sent to the model. + +### 9. Preserve prompt, actor, and recovery behavior + +The original source and approved arguments remain attached to the +session-pipeline pending request. `Once` seeds the exact `OneTimeApprovalKeys` set +on that invocation's `ToolApprovalAttempt` and retries only that blocked +request. A stale, duplicate, expired, or wrong-scope response cannot execute +work. Recovery reconstructs pending approval in the session pipeline and +re-evaluates policy before execution. Parent-session grants continue to cover +child scopes through the actor's existing bounded scope walk. + +Prompt display keeps verbatim source separate from normalized policy phrases. +Existing newline, carriage-return, bidi, and multiword-spoof protections remain +in force for display and persistence. + +### 10. Use one exact cross-repository catalog + +`evidence/approval-matrix.json` is byte-identical to the ShellSyntaxTree +artifact. Every row has exact sanitized input, observed response, +classification, owner, ShellSyntaxTree expectation, and Netclaw expectation. +It is the shared classification catalog, not an implied trace fixture. + +`evidence/netclaw-policy-fixtures.json` is Netclaw-owned. It gives exact +candidate IDs, typed phrases, real and intent scopes, available grant/safe +inputs, expected coverage, the ordered bounded trace, and final outcome for the +policy-owned acceptance cases. Tests load these structured fields directly; +they do not branch on Dxx IDs or derive expectations from prose. + +The fixture's top-level defaults are executable inputs, not test conventions: +tool name, audience, approval mode, interactive capability, session identity +and safe root, project safe root, inherited cwd, and persistent-store status. +Each case supplies its canonical shell environment, initial cwd, and every +stored grant includes its canonical shell tag. The exact executable cases are +D02, D03, D07, D08, D09, D10, D11, D14, D17, and D18. + +Complete D03 example: + +```text +Input: cd /tmp && gh api ... > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log +Preflight: candidates C0=cd, C1=gh api, C2=wc, C3=head; intent=/tmp +Actor: C0=PersistentGlobal, C1=PersistentGlobal, C2/C3=Uncovered +Safe policy: C2=ReviewedSafePolicy, C3=ReviewedSafePolicy +Final: Allow(AllCandidatesCovered) +``` + +## Risks / Trade-offs + +- **Token-prefix migration widens authority.** It is versioned, token-boundary + based, shell-tagged, backed up, and gated on maintainer approval. +- **Causal intent differs from one runtime failure path.** It is approval-only; + real facts still control execution and denial. +- **The safe catalog can be wrong.** Whole-argument safety is reviewed in code, + with adversarial tests and no user override. +- **Trace diagnostics can disclose data.** The schema excludes raw arguments and + paths, caps fields, escapes controls, and redacts before logging. +- **Refactoring actors can lose pending work.** Recovery, stale response, + one-time retry, and child inheritance are explicit acceptance tests. + +## Migration Plan + +1. Approve the ShellSyntaxTree API/threat boundary and token-prefix authority + widening. +2. Freeze exact D01-D18 and current prompt snapshots. +3. Add coordinator, coverage types, and actor batch protocol without behavior + changes. +4. Move deny and path checks into ordered preflight stages. +5. Migrate approval storage and session grants to typed phrases. +6. Replace safe-list strings with the reviewed immutable catalog. +7. Add Bash causal intent and keep native PowerShell strict. +8. Upgrade ShellSyntaxTree, consume authored facts, and remove broad relaxations + and command-specific normalization. +9. Update operator skill, guides, behavioral evals, and exact trace snapshots. +10. Validate Linux and native Windows before staged delivery. + +Rollback requires stopping the daemon, preserving the version-3 file, and +manually restoring the migration-created `.v2.bak` before starting an older +binary. No old binary reads version 3 and no automatic downgrade occurs. + +## Open Questions + +- Maintainer approval is required to use ShellSyntaxTree `AuthoredValue` for + approval matching. +- Maintainer approval is required to migrate simple v2 grants to token-prefix + authority. The conservative alternative is exact matching for all migrated + entries and prefix matching only for newly approved version-3 entries. diff --git a/openspec/changes/structure-shell-approval-policy/evidence/approval-matrix.json b/openspec/changes/structure-shell-approval-policy/evidence/approval-matrix.json new file mode 100644 index 000000000..9a5783fab --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/evidence/approval-matrix.json @@ -0,0 +1,175 @@ +{ + "cutoffUtc": "2026-08-11T15:13:55Z", + "sourceRelease": "Netclaw 0.26.0-beta.3", + "sanitization": { + "user": "user", + "workingRepository": "/work", + "remoteRepository": "example/project", + "numericIdentifier": "123456", + "commit": "deadbeef" + }, + "cases": [ + { + "id": "D01", + "command": "which netclaw netclawd 2>/dev/null; netclaw --version 2>/dev/null || netclawd --version 2>/dev/null || echo \"no CLI version flag found\"", + "observed": "Session", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Complete static command identities and redirects; no relaxation required.", + "netclawExpectation": "Prompt because at least one fallback executable has no grant coverage." + }, + { + "id": "D02", + "command": "gh run view 123456 --repo example/project --log-failed --verbose 2>&1 | head -200; echo \"---EXIT $?---\"", + "observed": "Once", + "classification": "ShellSyntaxTreeFactGap", + "owner": "ShellSyntaxTree", + "sstExpectation": "The echo argument is Concatenation(Exact, IntegerRange(0,255), Exact); the status is not a path or identity.", + "netclawExpectation": "Allow when gh run view has global grant coverage and head plus echo have reviewed safe coverage." + }, + { + "id": "D03", + "command": "cd /tmp && gh api repos/example/project/actions/jobs/123456/logs > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log", + "observed": "Once", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish the leading success-gated directory transition, file redirect, relative paths, and four command occurrences.", + "netclawExpectation": "Allow after cd and gh api grants plus wc and head safe coverage compose per candidate; hard-deny and real path checks still pass." + }, + { + "id": "D04", + "command": "cd /work && /home/user/.dotnet/dotnet build src/App/App.csproj -v minimal --nologo 2>&1 | tail -25", + "observed": "Once", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish the explicit executable path, build arguments, redirect duplication, pipeline, and cwd transition.", + "netclawExpectation": "Prompt because the explicit /home/user/.dotnet/dotnet executable has no matching grant." + }, + { + "id": "D05", + "command": "cd /work && /home/user/.dotnet/dotnet list src/App/App.csproj package --include-transitive 2>/dev/null | grep -i \"ProtocolPackage\" | head -5", + "observed": "Once", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish all static identities, paths, redirects, and pipeline stages.", + "netclawExpectation": "Prompt because the explicit dotnet executable has no matching grant; agent alignment may remove the mismatch later." + }, + { + "id": "D06", + "command": "cd /work && /home/user/.dotnet/dotnet list src/Service/Service.csproj package 2>&1 | grep -i \"ProtocolPackage\"; echo \"===TESTS===\"; /home/user/.dotnet/dotnet list tests/Service.Tests/Service.Tests.csproj package 2>&1 | grep -i \"ProtocolPackage\"; echo \"===ASSETS===\"; grep -o '\"ProtocolPackage[^\"]*\"' src/App/obj/project.assets.json | sort -u | head", + "observed": "Once", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish the static command list, redirects, pipelines, and authored paths.", + "netclawExpectation": "Prompt because both explicit dotnet invocations lack grant coverage." + }, + { + "id": "D07", + "command": "cd /work && git push upstream feature/update:automation/update 2>&1 | tail -5", + "observed": "Session", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish git push as a static token sequence with later argument tokens.", + "netclawExpectation": "Allow when a migrated git push grant covers the candidate at shell-token boundaries and tail is safely covered." + }, + { + "id": "D08", + "command": "cd /work && git ls-remote upstream feature/update; echo \"---LOCAL---\"; git rev-parse HEAD; git log --oneline -3", + "observed": "Once", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish the cwd transition and static git plus echo occurrences.", + "netclawExpectation": "Allow when existing global grants and safe coverage compose under the intended cwd." + }, + { + "id": "D09", + "command": "cd /work && gh api repos/example/project/commits/deadbeef --jq '{sha: .sha, message: .commit.message}' 2>&1; echo \"===RUN 123456===\"; gh run view 123456 --repo example/project --json status,conclusion 2>&1 | head -40", + "observed": "Once", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish static gh, echo, and head occurrences with the cwd transition.", + "netclawExpectation": "Allow when global gh grants and reviewed safe coverage compose." + }, + { + "id": "D10", + "command": "cd /work && git fetch upstream feature/update 2>&1 | tail -2 && echo \"===REMOTE TIP===\" && git rev-parse FETCH_HEAD && git log --oneline -3 FETCH_HEAD && echo \"===HAS FIX?===\" && git show FETCH_HEAD:src/App/App.csproj | grep -n \"ProtocolPackage\"; echo \"exit: $?\"", + "observed": "Once", + "classification": "ShellSyntaxTreeFactGap", + "owner": "ShellSyntaxTreeAndNetclaw", + "sstExpectation": "The final echo argument is Concatenation(Exact, IntegerRange(0,255)); all command identities and control operators remain explicit.", + "netclawExpectation": "Allow after bounded status plus global grants and safe coverage compose under causal intent." + }, + { + "id": "D11", + "command": "cd /work && echo \"===PARENT===\" && gh api repos/example/project/commits/deadbeef --jq '.parents[] | {sha: .sha}' 2>&1; echo \"===CURRENT PR HEAD===\" && gh pr view 123 --repo example/project --json headRefOid,updatedAt", + "observed": "Once", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish the cwd transition and static echo plus gh occurrences.", + "netclawExpectation": "Allow when global gh grants and echo safe coverage compose." + }, + { + "id": "D12", + "command": "mv /home/user/repos/source-project /home/user/repos/group/target-project && git -C /home/user/repos/group/target-project status --short --branch && ls -d /home/user/repos/group/target-project", + "observed": "Denied", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish the move source and target paths and later inspection commands.", + "netclawExpectation": "Prompt or deny because a cross-scope filesystem mutation is not covered by read-only policy." + }, + { + "id": "D13", + "command": "for f in $(find /work/src /work/tests -name \"*.csproj\" | sort); do echo \"=== $f ===\"; grep -E \"TargetFramework|PackageReference|ProjectReference\" \"$f\"; done", + "observed": "Once", + "classification": "IrreduciblyDynamic", + "owner": "ShellSyntaxTree", + "sstExpectation": "Discover find and sort but keep the runtime-generated iterator and dependent values unknown.", + "netclawExpectation": "Prompt once; do not execute find during policy analysis." + }, + { + "id": "D14", + "command": "for f in src/App/App.csproj src/Hosting/Hosting.csproj src/Discovery/Discovery.csproj tests/Hosting.Tests/Hosting.Tests.csproj; do echo \"=== $f ===\"; cat /work/$f; done", + "observed": "Once", + "classification": "ShellSyntaxTreeFactGap", + "owner": "ShellSyntaxTree", + "sstExpectation": "With PublishAuthoredSourceFacts enabled, effective cat value is Unknown; AuthoredValue is the four pre-field-splitting /work words and AuthoredPathShape is Posix.", + "netclawExpectation": "Allow only if product policy accepts pre-field-splitting authored words, applies path checks conservatively, and covers cat plus echo." + }, + { + "id": "D15", + "command": "for pkg in Package.One Package.Two Package.Three; do code=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://packages.example.invalid/v3/$pkg/index.json\"); echo \"$pkg -> HTTP $code\"; if [ \"$code\" = \"200\" ]; then curl -s \"https://packages.example.invalid/v3/$pkg/index.json\" | jq -r '.versions[-3:][]'; fi; done", + "observed": "Pending", + "classification": "IrreduciblyDynamic", + "owner": "ShellSyntaxTree", + "sstExpectation": "Keep command substitution and unsupported Bash if control flow strict.", + "netclawExpectation": "Prompt once; do not infer network results." + }, + { + "id": "D16", + "command": "gh search repos example topic --limit 10 2>/dev/null; echo \"---REPO CHECK---\"; for r in project-one project-two project-three; do echo \"example/$r:\"; gh repo view example/$r --json name,visibility 2>&1 | head -2; done", + "observed": "Pending", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish the static search and finite loop structure.", + "netclawExpectation": "Prompt because the ungranted network search remains visible even if later reads have coverage." + }, + { + "id": "D17", + "command": "cd /work && echo \"===API BRANCH===\" && gh api repos/example/project/branches/feature%2Fupdate --jq '.commit.sha' 2>&1; echo \"===GIT REMOTE===\" && git ls-remote upstream 'refs/heads/feature/update'; echo \"===COMMIT===\" && gh api repos/example/project/commits/deadbeef --jq '{sha: .sha, message: .commit.message}' 2>&1", + "observed": "Once", + "classification": "NetclawPolicyDefect", + "owner": "Netclaw", + "sstExpectation": "Publish the cwd transition and static gh, git, and echo occurrences.", + "netclawExpectation": "Allow when existing global grants and safe coverage compose." + }, + { + "id": "D18", + "command": "gh pr close 123 --repo example/project --comment \"Closing this automated update because it is being replaced by a coordinated change.\" 2>&1", + "observed": "Pending", + "classification": "CorrectPrompt", + "owner": "Netclaw", + "sstExpectation": "Publish the static remote-mutation command and redirect duplication.", + "netclawExpectation": "Prompt because closing a pull request is a remote mutation without an explicit matching grant." + } + ] +} diff --git a/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json new file mode 100644 index 000000000..77d7880a3 --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/evidence/netclaw-policy-fixtures.json @@ -0,0 +1,390 @@ +{ + "schemaVersion": 1, + "fixtureDefaults": { + "toolName": "shell_execute", + "audience": "Personal", + "approvalMode": "Approval", + "interactiveApprovalCapability": "Available", + "session": { + "sessionId": "fixture-session", + "sessionDirectory": "/work" + }, + "projectDirectory": "/work", + "inheritedWorkingDirectory": null, + "persistentStoreStatus": "Ready" + }, + "cases": [ + { + "evidenceId": "D02", + "environment": { + "platform": "Linux", + "executablePath": "/bin/bash", + "commandArguments": ["-c"], + "grammar": "Bash", + "pathStyle": "Posix", + "powerShellDialect": null + }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "run", "view"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["head"], "proof": "ReadOnlyForAllArguments" }, + { "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["gh", "run", "view"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["head"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 2, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "ReviewedSafePolicy" } + ], + "valueFacts": [ + { "candidateId": 2, "argumentIndex": 0, "domain": "Concatenation", "parts": [{ "exact": "---EXIT " }, { "integerRange": [0, 255] }, { "exact": "---" }] } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 1, "executableBasename": "head", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D03", + "environment": { + "platform": "Linux", + "executablePath": "/bin/bash", + "commandArguments": ["-c"], + "grammar": "Bash", + "pathStyle": "Posix", + "powerShellDialect": null + }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "api"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["wc"], "proof": "ReadOnlyForAllArguments" }, + { "tokens": ["head"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["gh", "api"], "realDirectory": "/tmp", "intentDirectory": "/tmp", "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["wc"], "realDirectory": null, "intentDirectory": "/tmp", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 3, "tokens": ["head"], "realDirectory": null, "intentDirectory": "/tmp", "expectedCoverage": "ReviewedSafePolicy" } + ], + "shellEffects": { + "redirects": [ + { "candidateId": 1, "target": "/tmp/slopwatch.log", "mode": "Write", "expectedPathPolicy": "Allow" } + ] + }, + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "wc", "outcome": "Covered", "reason": "ReadOnlyUnderEligibleIntent", "coverage": "ReviewedSafePolicy", "scopeRelation": "EligibleIntent", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 3, "executableBasename": "head", "outcome": "Covered", "reason": "ReadOnlyUnderEligibleIntent", "coverage": "ReviewedSafePolicy", "scopeRelation": "EligibleIntent", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D07", + "environment": { + "platform": "Linux", + "executablePath": "/bin/bash", + "commandArguments": ["-c"], + "grammar": "Bash", + "pathStyle": "Posix", + "powerShellDialect": null + }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "push"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["tail"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["git", "push", "upstream"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["tail"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "tail", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D08", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "ls-remote"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "rev-parse"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "log"], "directory": null } + ], + "safePhrases": [{ "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" }] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["git", "ls-remote", "upstream"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 3, "tokens": ["git", "rev-parse"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 4, "tokens": ["git", "log"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 3, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 4, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D09", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "api"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "run", "view"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" }, + { "tokens": ["head"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["gh", "api"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 3, "tokens": ["gh", "run", "view"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 4, "tokens": ["head"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 3, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 4, "executableBasename": "head", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D10", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "fetch"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "rev-parse"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "log"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "show"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["tail"], "proof": "ReadOnlyForAllArguments" }, + { "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" }, + { "tokens": ["grep"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["git", "fetch", "upstream"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 2, "tokens": ["tail"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 3, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 4, "tokens": ["git", "rev-parse"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 5, "tokens": ["git", "log"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 6, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 7, "tokens": ["git", "show"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 8, "tokens": ["grep"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 9, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" } + ], + "valueFacts": [ + { "candidateId": 9, "argumentIndex": 0, "domain": "Concatenation", "parts": [{ "exact": "exit: " }, { "integerRange": [0, 255] }] } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 4, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 5, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 7, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 2, "executableBasename": "tail", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 3, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 6, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 8, "executableBasename": "grep", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 9, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D11", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "api"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "pr", "view"], "directory": null } + ], + "safePhrases": [{ "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" }] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 2, "tokens": ["gh", "api"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 3, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 4, "tokens": ["gh", "pr", "view"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 2, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 4, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 1, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 3, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D17", + "environment": { "platform": "Linux", "executablePath": "/bin/bash", "commandArguments": ["-c"], "grammar": "Bash", "pathStyle": "Posix", "powerShellDialect": null }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cd"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["gh", "api"], "directory": null }, + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["git", "ls-remote"], "directory": null } + ], + "safePhrases": [{ "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" }] + }, + "candidates": [ + { "id": 0, "tokens": ["cd"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" }, + { "id": 1, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 2, "tokens": ["gh", "api"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 3, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 4, "tokens": ["git", "ls-remote", "upstream"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" }, + { "id": 5, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 6, "tokens": ["gh", "api"], "realDirectory": "/work", "intentDirectory": "/work", "expectedCoverage": "PersistentGlobal" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "cd", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 2, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 4, "executableBasename": "git", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "StoredGrantMatch", "candidateId": 6, "executableBasename": "gh", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 1, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 3, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 5, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D14", + "environment": { + "platform": "Linux", + "executablePath": "/bin/bash", + "commandArguments": ["-c"], + "grammar": "Bash", + "pathStyle": "Posix", + "powerShellDialect": null + }, + "initialWorkingDirectory": "/work", + "parserOptions": { "publishAuthoredSourceFacts": true }, + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [ + { "kind": "PersistentGlobal", "shell": "Bash", "match": "TokenPrefix", "tokens": ["cat"], "directory": null } + ], + "safePhrases": [ + { "tokens": ["echo"], "proof": "ReadOnlyForAllArguments" } + ] + }, + "candidates": [ + { "id": 0, "tokens": ["echo"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "ReviewedSafePolicy" }, + { "id": 1, "tokens": ["cat"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "PersistentGlobal" } + ], + "authoredPathFacts": [ + { + "candidateId": 1, + "argumentIsPath": true, + "effectiveValue": "Unknown", + "authoredValues": [ + "/work/src/App/App.csproj", + "/work/src/Hosting/Hosting.csproj", + "/work/src/Discovery/Discovery.csproj", + "/work/tests/Hosting.Tests/Hosting.Tests.csproj" + ], + "authoredPathShape": "Posix", + "expectedPathPolicy": "Allow" + } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 1, "executableBasename": "cat", "outcome": "Covered", "reason": "GlobalPhraseMatch", "coverage": "PersistentGlobal", "scopeRelation": "Global", "grantTimestamp": null }, + { "stage": "ReviewedSafePolicy", "candidateId": 0, "executableBasename": "echo", "outcome": "Covered", "reason": "ReadOnlyUnderRealRoot", "coverage": "ReviewedSafePolicy", "scopeRelation": "UnderRealRoot", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "Allow", "reason": "AllCandidatesCovered", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "Allow", "reason": "AllCandidatesCovered" } + }, + { + "evidenceId": "D18", + "environment": { + "platform": "Linux", + "executablePath": "/bin/bash", + "commandArguments": ["-c"], + "grammar": "Bash", + "pathStyle": "Posix", + "powerShellDialect": null + }, + "initialWorkingDirectory": "/work", + "available": { + "oneTimeApprovalKeys": [], + "sessionGrants": [], + "persistentGrants": [], + "safePhrases": [] + }, + "candidates": [ + { "id": 0, "tokens": ["gh", "pr", "close"], "realDirectory": "/work", "intentDirectory": null, "expectedCoverage": "Uncovered" } + ], + "expectedTrace": [ + { "stage": "StoredGrantMatch", "candidateId": 0, "executableBasename": "gh", "outcome": "Uncovered", "reason": "NoGrant", "coverage": "Uncovered", "scopeRelation": "None", "grantTimestamp": null }, + { "stage": "Completion", "candidateId": null, "executableBasename": null, "outcome": "RequiresApproval", "reason": "UncoveredCandidates", "coverage": null, "scopeRelation": null, "grantTimestamp": null } + ], + "expectedFinal": { "outcome": "RequiresApproval", "reason": "UncoveredCandidates" } + } + ] +} diff --git a/openspec/changes/structure-shell-approval-policy/proposal.md b/openspec/changes/structure-shell-approval-policy/proposal.md new file mode 100644 index 000000000..1d5791bd5 --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/proposal.md @@ -0,0 +1,56 @@ +## Why + +Netclaw v0.26.0-beta.3 produced 18 distinct shell approval prompts after its +2026-08-11 15:13:55 UTC daemon start. The current policy path spreads parsing, +hard deny, path checks, candidate extraction, asynchronous grant matching, +safe-space rules, and prompt construction across several owners. A 1,655-line +matcher and repeated special cases make it difficult to prove why a call was +allowed or prompted. + +The defect is architectural, not a request to parse more executables. Netclaw +needs a typed per-candidate coverage pipeline that composes stored grants with +reviewed safe policy while retaining the approval actor as the atomic owner of +session and persistent grant snapshots. + +## What Changes + +- Add a coordinator with a synchronous preflight, one typed approval-actor + request, and a deterministic completion pass. +- Track authorization coverage per ShellSyntaxTree command occurrence. +- Keep hard deny and protected paths terminal and based on real execution + facts. +- Add a separate, bounded causal approval-intent projection for Bash diagnostic + tails without rewriting the command or model history. +- Replace flat safe-verb strings with an immutable reviewed policy catalog. +- Replace exact flat grant strings with versioned shell-token phrases and a + decision-gated v2 migration. +- Emit a bounded, redacted decision trace that also supplies near-miss data. +- Adopt the paired ShellSyntaxTree 0.3.1 authored/effective fact separation. +- Pin the exact sanitized D01-D18 catalog and adversarial cases. + +No production branch will parse an executable's private options or operands. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `tool-approval-gates`: structured shell evaluation, actor-owned grant + coverage, causal approval intent, token phrases, safe catalog, and traces. + +## Impact + +- Security: hard deny and protected paths remain first and terminal; internal + evaluator failures deny rather than prompt. +- Persistence: approval store advances to version 3 with typed token phrases; + simple v2 authority follows the recorded maintainer choice, and ambiguous + entries remain exact-only. +- Actors: `ToolApprovalActor` receives one batch match request and returns + per-candidate coverage from one atomic snapshot. +- UX: covered diagnostic chains stop prompting; unresolved syntax remains + one-time-only. +- Dependencies: implementation waits for ShellSyntaxTree 0.3.1. +- Documentation/evals: operator guidance and approval behavioral evals change. diff --git a/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md b/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md new file mode 100644 index 000000000..d093f3eac --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/specs/tool-approval-gates/spec.md @@ -0,0 +1,673 @@ +## ADDED Requirements + +### Requirement: Shell policy coordinator preserves actor ownership + +The system SHALL evaluate a shell call through one coordinator with three +phases: synchronous preflight, one asynchronous approval-actor batch match, and +deterministic completion. + +Preflight SHALL snapshot the existing `ToolExecutionContext` and exact +`ToolApprovalAttempt.OneTimeApprovedPatterns` set. Those legacy-named strings +SHALL remain `OneTimeApprovalKeys` binding filtered phrase and effective +directory. Preflight SHALL build one canonical +`ShellCommandAnalysis`, apply hard deny and protected paths, resolve approval +mode, build candidates, and preserve the existing noninteractive trust-zone +gate. If preflight is not terminal, `DispatchingToolExecutor` SHALL send +exactly one typed batch request to `ToolApprovalActor`. + +`ToolApprovalActor` SHALL atomically snapshot inherited session and persistent +grants. It SHALL return one match result per stable candidate ID. It SHALL NOT +own or inspect one-time approval state. The coordinator SHALL import actor +coverage, apply safe policy to still-uncovered candidates, validate the +invocation-owned one-time set exactly, and SHALL NOT rescan grants. + +The actor result SHALL include typed persistent-store status. An absent store +file SHALL be ready with an empty snapshot. Expected corruption or migration +failure SHALL be unavailable. Completion SHALL allow a call fully covered by +one-time, session, or reviewed-safe authority without persistent state. If any +candidate remains uncovered and persistent state was unavailable, completion +SHALL return terminal `ApprovalStoreUnavailable` instead of a prompt. + +`ToolApprovalAttempt` SHALL remain owner of one-time invocation state. +`ToolApprovalActor` SHALL remain owner of session and persistent grants. The +session pipeline SHALL remain owner of pending requests, response validation, +stale-response rejection, and recovery. + +The implementation SHALL reuse current execution, decision, candidate, match, +and prompt-context types when they can carry the required fact. It SHALL remove +superseded overlap. A new type SHALL exist only for the actor batch protocol or +a fact that no current type represents. + +#### Scenario: One actor snapshot covers every candidate + +- **GIVEN** a compound command has four candidates +- **WHEN** preflight completes without a terminal result +- **THEN** the executor sends one batch request containing four stable IDs +- **AND** the actor returns one result from one grant snapshot +- **AND** no synchronous policy service reads the approval store directly + +#### Scenario: Independent coverage survives unavailable persistence + +- **GIVEN** the persistent store is unavailable +- **AND** session and reviewed-safe coverage jointly cover every candidate +- **WHEN** completion evaluates the actor result +- **THEN** the call is allowed +- **AND** no persisted grant is assumed + +#### Scenario: Uncovered candidate fails closed when persistence is unavailable + +- **GIVEN** the persistent store is unavailable +- **AND** one candidate remains uncovered after one-time, session, and safe + coverage +- **WHEN** completion evaluates the call +- **THEN** it denies with `ApprovalStoreUnavailable` +- **AND** it does not offer an approval prompt + +#### Scenario: Hard deny terminates before actor match + +- **GIVEN** a stored grant could match a command phrase +- **AND** canonical analysis matches hard deny +- **WHEN** preflight evaluates the call +- **THEN** it returns terminal deny +- **AND** the executor sends no grant-match request + +#### Scenario: Noninteractive trust zone precedes approval matching + +- **GIVEN** interactive approval is unavailable +- **AND** a stored grant covers the command phrase +- **WHEN** canonical path facts fall outside the configured trust zone +- **THEN** preflight returns terminal deny +- **AND** neither the stored grant nor safe policy can override it + +#### Scenario: Recovery re-evaluates the original request + +- **GIVEN** a pending approval is recovered after daemon restart +- **WHEN** the response resumes the request +- **THEN** policy re-evaluates the original source and immutable context +- **AND** it obtains a current actor snapshot before execution +- **AND** it does not replay a stale allow result + +### Requirement: Candidate coverage composes authorization sources + +Every ShellSyntaxTree command occurrence SHALL receive a stable call-local +candidate ID. Coverage SHALL begin `Uncovered` and MAY transition once to +OneTime, Session, PersistentGlobal, PersistentFolder, ReviewedSafePolicy, or +Denied. + +A stage SHALL refine only uncovered candidates. A denial SHALL be terminal. +The coordinator SHALL allow only when every candidate has non-deny coverage and +every call-level invariant passes. + +Expected unresolved shell syntax MAY produce a one-time prompt without +reusable choices. An internal exception, invalid enum, duplicate candidate ID, +mismatched actor result, or impossible transition SHALL produce terminal deny. + +#### Scenario: Grants and safe policy compose + +- **GIVEN** a command has `cd`, `gh api`, `wc`, and `head` candidates +- **AND** global grants cover `cd` and `gh api` +- **AND** reviewed safe policy covers `wc` and `head` +- **WHEN** the coordinator completes policy +- **THEN** every candidate has coverage +- **AND** the call is allowed without a prompt + +#### Scenario: One uncovered candidate prompts + +- **GIVEN** three candidates are covered and one remains uncovered +- **WHEN** no strict call-level invariant denies the call +- **THEN** the call requires one interactive prompt +- **AND** the prompt identifies the uncovered candidate + +#### Scenario: Internal evaluator failure denies + +- **WHEN** any policy stage throws or returns an invalid typed result +- **THEN** the final result is terminal deny with `InternalPolicyFailure` +- **AND** no approval prompt can override it +- **AND** the shell does not execute + +#### Scenario: One-time approval requires the exact approval-key set + +- **GIVEN** the invocation attempt contains one-time approval keys +- **WHEN** the current phrase-and-effective-directory key set differs by any + missing or extra key +- **THEN** one-time coverage is not applied +- **AND** actor-owned session or persistent coverage is unaffected + +### Requirement: Causal approval intent is separate from execution scope + +The system SHALL keep canonical execution facts unchanged. For Bash only, it +MAY derive an approval-intent directory from an exact leading authored +directory transition on the success edge of `cd TARGET && ...`. + +Intent MAY continue through later top-level diagnostic statements until a +later directory mutation, differing control-flow join, alternate branch, +subshell/group boundary, dynamic flow, or unsupported region invalidates it. An +exact later success-gated directory transition SHALL replace intent on its +success edge. + +An intent target SHALL be eligible only when exact, absolute, normalized, +symlink-free, and allowed by protected-path policy. The directory-transition +candidate and first non-navigation action on its success edge SHALL already +have one-time, session, or stored-grant coverage. Safe policy alone SHALL NOT +manufacture causal intent. + +Only a reviewed read-only-for-all-arguments candidate without a file-writing +redirect MAY consume eligible intent. Hard deny, protected paths, folder +grants, noninteractive authority, and process execution SHALL use real facts. +The system SHALL NOT rewrite source, arguments, cwd, or model history. + +Native PowerShell SHALL remain strict in this slice and SHALL NOT derive causal +scope from `Set-Location`. + +#### Scenario: Exact D03 chain composes under intended tmp scope + +- **GIVEN** global grants cover `cd` and `gh api` +- **AND** `wc` and `head` are reviewed read-only entries +- **WHEN** the agent submits + `cd /tmp && gh api repos/example/project/actions/jobs/123456/logs > slopwatch.log 2>&1; wc -c slopwatch.log; head -100 slopwatch.log` +- **THEN** real redirect and path facts pass deny policy +- **AND** the exact protected-path-safe `/tmp` target is eligible approval + intent for `wc` and `head` +- **AND** all four candidate coverages compose to allow + +#### Scenario: Later unknown directory mutation invalidates intent + +- **GIVEN** intent is `/tmp` +- **WHEN** a later `cd "$1"` precedes a diagnostic tail +- **THEN** the tail has unknown intent +- **AND** safe policy cannot use the earlier `/tmp` intent + +#### Scenario: Alternate branch does not leak intent + +- **WHEN** source is `cd /tmp && inspect || recover; head result.log` +- **THEN** the joined intent before `head` is unknown +- **AND** real execution facts still control path policy + +#### Scenario: Subshell intent does not escape + +- **WHEN** source is `(cd /tmp && inspect); head result.log` +- **THEN** `/tmp` intent applies only inside the subshell +- **AND** it does not cover the outer `head` + +#### Scenario: Native PowerShell stays strict + +- **WHEN** native Windows PowerShell analyzes + `Set-Location C:\\Temp; Get-Content result.log` +- **THEN** no synthetic causal scope is created +- **AND** existing real-scope and provider rules decide the call + +### Requirement: Shell approval decision trace is bounded and redacted + +The coordinator SHALL return one ordered trace. Rows SHALL contain only enum +stage, enum outcome, enum reason, call-local candidate ID, bounded executable +basename, coverage kind, scope relation, and grant timestamp. + +The trace SHALL NOT contain full commands, argument values, environment values, +redirect bodies, raw paths, tokens, secrets, or model content. It SHALL contain +at most one row per stage per candidate and 256 rows total. Text fields SHALL +contain at most 128 UTF-16 code units. Control, newline, bidi, and invalid +Unicode SHALL be escaped. Secret-pattern redaction SHALL run before logging. + +Trace overflow SHALL add one `TraceTruncated` row without changing the decision. +The trace SHALL not enter prompts or session persistence. Near-miss diagnostics +SHALL project from the trace without another grant scan. + +#### Scenario: Grant and safe coverage produce one trace + +- **WHEN** actor grants and safe policy jointly cover a call +- **THEN** the trace contains one coverage row for each candidate +- **AND** the final row is `Allow(AllCandidatesCovered)` + +#### Scenario: Malicious text cannot forge trace lines + +- **GIVEN** authored input contains CR, LF, bidi controls, or a token-like secret +- **WHEN** a strict result is logged +- **THEN** controls are escaped and secrets are redacted +- **AND** no authored text creates an additional log row + +#### Scenario: Trace overflow does not widen authority + +- **WHEN** trace evidence exceeds a configured bound +- **THEN** later detail is replaced by `TraceTruncated` +- **AND** candidate coverage and the final decision are unchanged + +### Requirement: Exact sanitized beta approval catalog + +The change SHALL contain `evidence/approval-matrix.json` with exact sanitized +D01-D18 commands, observed responses, classifications, owners, parser facts, +and policy outcomes. It SHALL match the paired ShellSyntaxTree artifact +byte-for-byte. + +The shared catalog SHALL NOT imply structured trace fields through prose. +Netclaw SHALL also contain `evidence/netclaw-policy-fixtures.json` with exact +candidate IDs, typed phrases, scopes, available grants and safe entries, +expected per-candidate coverage, ordered trace rows, and final outcome for the +policy-owned acceptance cases. Tests SHALL load those fields directly and +SHALL NOT branch on Dxx identifiers to manufacture expected results. + +Fixture defaults SHALL explicitly provide tool name, audience, approval mode, +interactive capability, session identity and safe root, project safe root, +inherited cwd, and persistent-store status. Each case SHALL provide canonical +shell environment and initial cwd. Every stored grant SHALL carry a canonical +shell tag. D02, D03, D07, D08, D09, D10, D11, D14, D17, and D18 SHALL be exact +executable fixtures. + +Additional adversarial rows SHALL cover dynamic identity, deny-only wrappers, +redirects, protected paths, prefix collisions, runtime iterators, PowerShell +providers, and unsafe catalog entries. + +#### Scenario: Every harvested prompt appears once + +- **WHEN** the catalog loads +- **THEN** IDs D01 through D18 each occur exactly once +- **AND** each classification is correct prompt, Netclaw policy defect, + ShellSyntaxTree fact gap, or irreducibly dynamic + +#### Scenario: Catalog contains no source identity + +- **WHEN** the PII audit scans the change +- **THEN** it finds no local username, private repository, channel, thread, + host, email, token, or secret + +#### Scenario: Unsafe catalog counterexamples stay strict + +- **WHEN** policy evaluates `find . -exec rm {} +`, + `awk 'BEGIN { system("touch marker") }'`, `rg --pre helper pattern .`, and + `sort -o output input` +- **THEN** none receives reviewed safe-policy coverage + +## MODIFIED Requirements + +### Requirement: Global grant precedence over folder-scoped grants + +A persisted global version-3 phrase (`directory: null`) SHALL authorize every +candidate matched by the phrase in its declared audience, tool, and canonical +shell. When both a global entry and folder-scoped entries exist for the same +typed phrase identity, the global entry SHALL be sufficient regardless of real +cwd. Folder-scoped entries SHALL remain on disk so revoking the global entry +restores the narrower authority. + +The matcher SHALL evaluate every persisted entry whose canonical shell, match +kind, and phrase identity can cover the candidate. It SHALL NOT stop at the +first phrase match whose directory check fails. Adding a global entry SHALL +NOT remove, supersede, or rewrite a folder entry. + +#### Scenario: Global token phrase wins outside folder scope + +- **GIVEN** version 3 contains folder and global `TokenPrefix` entries with + Bash tokens `["dotnet"]` +- **WHEN** Bash invokes `dotnet --info` outside the folder +- **THEN** the global entry covers the candidate +- **AND** no prompt is rendered + +#### Scenario: Adding global phrase retains narrower phrase + +- **GIVEN** version 3 contains a folder-scoped Bash phrase `["dotnet"]` +- **WHEN** the user approves the same phrase everywhere +- **THEN** both entries remain on disk with their original timestamps +- **AND** revoking the global entry restores folder-only matching + +### Requirement: Approval entry creation timestamp + +Each version-3 approval entry SHALL carry optional ISO-8601 `createdAt` and +SHALL be stamped on first persistence using injected `TimeProvider`. Timestamp +SHALL NOT participate in equality. Phrase identity for idempotency SHALL be +canonical shell, match kind, token array or legacy-exact value, and directory. + +Adding an equivalent entry SHALL preserve the existing entry and its original +timestamp. Version-2 migration SHALL preserve an existing timestamp exactly; +a missing timestamp SHALL remain null. Migration SHALL NOT restamp grants. + +#### Scenario: New version-3 grant receives one timestamp + +- **GIVEN** a deterministic `TimeProvider` +- **WHEN** a new typed phrase is persisted +- **THEN** `createdAt` equals the provider time +- **AND** re-adding the same phrase and directory does not change it + +#### Scenario: Migration preserves timestamp absence + +- **GIVEN** a valid version-2 entry without `createdAt` +- **WHEN** it migrates to a version-3 phrase +- **THEN** its `createdAt` remains null +- **AND** phrase equality remains independent of time + +### Requirement: Shell command pattern matching + +The system SHALL derive one candidate from every complete canonical +`ShellSyntaxTree.CommandOccurrence`. Candidate identity SHALL use the static +authored verb tokens reported by ShellSyntaxTree. Netclaw SHALL NOT parse an +executable's private subcommands, flags, options, or operands. + +Every candidate SHALL retain its occurrence, redirects, effective and authored +value facts, real scope, and optional intent scope. Pipelines, lists, and loops +SHALL NOT hide later occurrences. Incomplete identity or unknown policy-relevant +facts SHALL remain strict. + +Stored token-prefix phrases SHALL compare whole tokens with the selected +shell's case rule. Raw string prefix SHALL NOT authorize. Same-language wrapper +occurrences reported by ShellSyntaxTree SHALL remain visible. Cross-language +payloads SHALL remain arguments to the native host command. + +Display and persistence SHALL keep existing spoof protections. Raw source SHALL +remain verbatim in the prompt only. CR, LF, bidi controls, malformed quoting, +and multiword free-text SHALL NOT enter a stored phrase. Path evidence SHALL +remain available to directory policy. Candidate normalization SHALL be the same +for actor match, prompt options, and persistence. + +#### Scenario: Token-prefix grant covers a greedy candidate + +- **GIVEN** a Bash token-prefix grant `git push` +- **AND** ShellSyntaxTree reports tokens `git`, `push`, `upstream` +- **WHEN** actor matching compares them +- **THEN** the grant covers the candidate +- **AND** no Git-specific remote rule runs + +#### Scenario: Prefix collision does not match + +- **GIVEN** a grant with tokens `git`, `push` +- **WHEN** the candidate tokens are `git`, `push-force` +- **THEN** the grant does not match + +#### Scenario: All occurrences remain visible + +- **WHEN** source is `inspect && head file; wc file` +- **THEN** candidates exist for `inspect`, `head`, and `wc` +- **AND** coverage for one cannot hide another + +#### Scenario: Same-language wrapper exposes inner occurrences + +- **WHEN** ShellSyntaxTree reports a static `bash -c` inner occurrence +- **THEN** that occurrence receives its own candidate and deny evaluation +- **AND** Netclaw does not decode the wrapper itself + +#### Scenario: Cross-language payload stays external data + +- **GIVEN** the canonical shell is Bash +- **WHEN** Bash invokes `pwsh -Command 'Get-Content ./a.txt'` +- **THEN** `pwsh` is the Bash external-command candidate +- **AND** the inline payload is not parsed as native PowerShell + +#### Scenario: Multi-line or bidi content cannot persist + +- **WHEN** a candidate contains multi-line, carriage-return, or bidi-controlled + authored content +- **THEN** that content is excluded from the normalized grant phrase +- **AND** the prompt retains a separately escaped verbatim display +- **AND** no reusable option is offered if a clean phrase cannot be formed + +#### Scenario: Dynamic identity stays one-time + +- **WHEN** source is `"$1" --version` +- **THEN** no stored phrase or safe policy covers the identity +- **AND** only one-time approval and deny are offered + +### Requirement: Persistent approval storage + +The system SHALL store persistent approvals in +`~/.netclaw/config/tool-approvals.json` using version 3. New shell entries SHALL +contain canonical shell, match kind, immutable verb-token array, optional +absolute directory, and creation timestamp. Null directory SHALL mean global. + +On first successful version-2 load, the daemon SHALL back up the original file. +Plain whitespace-separated unquoted atoms SHALL migrate according to the +maintainer-approved authority choice. Quoted, escaped, or ambiguous strings +SHALL migrate as exact-only legacy phrases. A structurally valid v2 entry whose +phrase contains controls or cannot be represented safely SHALL be omitted with +a bounded migration diagnostic and SHALL NOT authorize. A structurally invalid +v2 file SHALL fail as a whole. The version-3 write SHALL be atomic. + +The daemon SHALL observe valid operator edits on the next approval check. CLI +list, add, and revoke SHALL understand both token-prefix and legacy-exact +entries. It SHALL NOT silently downgrade a version-3 file. + +An absent file SHALL be a valid empty store. An absent-version or version-1 +file SHALL follow the existing quarantine path and become an empty version-3 +store only after a successful atomic write. Malformed JSON, partial version-3 +corruption, invalid enum or token values, and unsupported future versions SHALL +make the store unavailable: no entry SHALL authorize and an +approval-dependent call SHALL terminate deny with `ApprovalStoreUnavailable`. +A future-version file SHALL remain untouched. + +Failure to create the v2 backup SHALL abort migration and leave v2 untouched. +Failure of atomic replacement SHALL retain v2 and any completed backup, make +the store unavailable for that check, and permit a later load to retry. The +loader SHALL NOT salvage individual grants from a partially corrupt version-3 +or structurally invalid version-2 file. + +#### Scenario: New global entry stores tokens + +- **WHEN** the user approves `git push` everywhere under native Bash +- **THEN** version 3 stores shell `Bash`, match `TokenPrefix`, tokens + `["git", "push"]`, and null directory + +#### Scenario: Ambiguous v2 phrase remains exact + +- **GIVEN** a v2 verb contains quoting or an escape +- **WHEN** migration runs +- **THEN** the entry becomes `LegacyExact` +- **AND** it does not gain token-prefix authority + +#### Scenario: Invalid migrated entry cannot authorize + +- **GIVEN** a v2 entry contains controls or cannot be represented safely +- **WHEN** migration runs +- **THEN** the entry is omitted with a bounded migration diagnostic +- **AND** no candidate matches it + +#### Scenario: Revocation is visible without restart + +- **WHEN** an operator revokes a version-3 entry through the CLI +- **THEN** the next actor snapshot excludes it +- **AND** a later call prompts if no other coverage exists + +#### Scenario: Future schema fails closed without modification + +- **GIVEN** `tool-approvals.json` declares a version newer than 3 +- **WHEN** an approval-dependent shell call is checked +- **THEN** no persisted entry authorizes +- **AND** the call is denied with `ApprovalStoreUnavailable` +- **AND** the file is not rewritten or quarantined + +#### Scenario: Backup failure preserves version 2 + +- **GIVEN** a valid version-2 store +- **AND** creation of `.v2.bak` fails +- **WHEN** migration is attempted +- **THEN** the version-2 source remains byte-identical +- **AND** no version-3 replacement is attempted +- **AND** the approval-dependent call fails closed + +### Requirement: Directory-root approvals for shell_execute + +Global token phrases SHALL not require an exact cwd. Folder phrases SHALL +require the candidate's real exact scope under the stored directory, with +normalization, boundary-safe containment, minimum-depth, traversal, and symlink +checks. Intent scope SHALL never satisfy a folder grant. + +`Once` SHALL retry only the blocked request. `This chat` SHALL create typed +session entries. `Always here` SHALL persist one clean version-3 entry per +persistable candidate at the real prompt scope. `Always anywhere` SHALL persist +global entries. Unknown or synthetic-only scope SHALL omit `Always here`. + +#### Scenario: Global grant works with unknown cwd + +- **GIVEN** a global token phrase covers a static candidate +- **WHEN** its joined cwd is unknown and path facts are otherwise strict-safe +- **THEN** the actor may cover the candidate globally + +#### Scenario: Folder grant rejects synthetic-only scope + +- **GIVEN** a folder grant under `/work/project` +- **AND** only intent scope is `/work/project` +- **WHEN** the real candidate scope is unknown +- **THEN** the folder grant remains a near miss + +#### Scenario: One persistent click stores each clean candidate + +- **GIVEN** three candidates are clean and persistable +- **WHEN** the user selects a persistent option +- **THEN** one typed entry is stored for each candidate +- **AND** no uncovered candidate is silently omitted + +#### Scenario: Symlink cannot widen folder authority + +- **GIVEN** a path under a folder grant crosses a symlink to protected space +- **WHEN** policy evaluates it +- **THEN** folder coverage fails +- **AND** protected-path policy denies when applicable + +### Requirement: Safe-verb auto-allow short-circuit in declared safe spaces + +The system SHALL load an embedded immutable per-platform policy catalog. +`ReadOnlyForAllArguments` SHALL mean no accepted argument shape can write or +delete a file, execute another command, or mutate a remote service through the +executable's argv interpretation. Runtime user overrides SHALL NOT widen the +catalog. Redirects, parser-owned path operands, provider paths, and unknown +shell expansions SHALL remain separate strict effects. Displaying a value +explicitly supplied by the shell SHALL NOT by itself disqualify a phrase. + +Safe policy SHALL refine only uncovered candidates. It SHALL require reviewed +phrase coverage, an allowed real or eligible intent scope, no symlink segment, +no writing redirect, and no unknown explicit path fact. Hard deny and protected +paths SHALL run first. Personal and Team safe roots SHALL be session directory +plus declared project directory. Public SHALL use session directory only. + +`find`, `awk`, `rg`, and `sort` SHALL not be reviewed-safe. Production policy +code SHALL contain no executable-specific flag exceptions. PowerShell provider +paths SHALL retain existing strict checks. + +#### Scenario: Read-only candidate in project scope is covered + +- **GIVEN** `head` is reviewed safe +- **AND** its real scope is under a Personal project root +- **WHEN** every earlier stage passes +- **THEN** safe policy covers that candidate + +#### Scenario: Unsafe argument surface excludes whole phrase + +- **GIVEN** any accepted argument can write or execute +- **WHEN** maintainers audit the catalog phrase +- **THEN** the phrase is excluded entirely +- **AND** no private flag branch compensates for it + +#### Scenario: File redirect remains separate + +- **WHEN** reviewed `head` writes through a shell redirect +- **THEN** safe policy does not cover the occurrence +- **AND** redirect path policy still applies + +#### Scenario: Public project directory is not safe + +- **GIVEN** a Public session has a project directory +- **WHEN** a reviewed read-only candidate runs only there +- **THEN** safe policy does not cover it + +#### Scenario: PowerShell environment provider stays strict + +- **WHEN** native PowerShell submits `Get-Content Env:SECRET` +- **THEN** the provider is not treated as filesystem safe space +- **AND** the call requires explicit authority or denial + +### Requirement: Pattern extraction refuses bash control-flow + +Authorization SHALL use canonical ShellSyntaxTree completeness rather than a +second control-flow tokenizer. Supported static loops SHALL expose candidates. +Unsupported branches and runtime-generated loops SHALL remain strict. + +A legacy scanner MAY add a denial when canonical analysis is incomplete. It +SHALL NOT allow, create candidates, create persistent options, or widen scope. + +#### Scenario: Static loop exposes authored candidates + +- **GIVEN** ShellSyntaxTree 0.3.1 reports D14 authored values and the existing + parser-owned `Argument.IsPath` role +- **WHEN** the maintainer-approved authored-source policy evaluates it +- **THEN** finite `cat` paths are checked individually +- **AND** the presence of `for` alone does not force a prompt + +#### Scenario: Runtime iterator stays one-time + +- **WHEN** an iterator depends on command substitution output +- **THEN** the call offers only one-time approval and deny +- **AND** policy does not execute the substitution + +#### Scenario: Deny-only scanner cannot authorize + +- **GIVEN** canonical analysis is incomplete +- **WHEN** a legacy scan finds no deny pattern +- **THEN** the call remains unresolved +- **AND** it does not receive grant or safe coverage + +### Requirement: Approval-gate near-miss diagnostics + +Near-miss diagnostics SHALL project only from the actor match trace. A near miss +SHALL identify candidate ID, grant kind, creation timestamp, and enum reason +such as token mismatch, shell mismatch, outside directory, or symlink. It SHALL +not include raw arguments, raw paths, or secrets and SHALL not rescan grants. + +Diagnostics SHALL be operator-log-only and SHALL not alter the prompt or final +decision. + +#### Scenario: Folder near miss uses actor evidence + +- **GIVEN** a token phrase matches but folder scope does not +- **WHEN** the actor returns uncovered coverage +- **THEN** its trace contains `OutsideDirectory` +- **AND** logging uses that row without another store read + +#### Scenario: First-time prompt has no fabricated near miss + +- **GIVEN** no grant was considered for a candidate +- **WHEN** it remains uncovered +- **THEN** no grant near-miss row is emitted + +### Requirement: Subagent inherits parent session-scoped approvals + +The approval actor SHALL walk a child scope toward its parent session using the +existing bounded `/subagent/` scope rule. Typed session phrases from the parent +SHALL cover matching child candidates. Unrelated sessions SHALL never share +coverage. The batch actor request SHALL perform this walk within the same +atomic snapshot as persistent matching. + +#### Scenario: Parent session phrase covers child candidate + +- **GIVEN** the parent chat has a typed session grant for `gh pr view` +- **WHEN** its child submits a matching candidate +- **THEN** the actor returns Session coverage +- **AND** no separate parent-grant scan runs + +### Requirement: Shell policy uses the canonical grammar and dialect + +The system SHALL select Bash only for native Bash execution and PowerShell only +for native Windows PowerShell execution. Bash invoking `pwsh` SHALL remain one +Bash external command. Every authorization stage SHALL share one canonical +ShellSyntaxTree analysis. + +PowerShell SHALL use the selected dialect and `PwshInitialStateMode.Unknown`. +Netclaw SHALL use effective values for runtime and deny policy. It MAY use +ShellSyntaxTree 0.3.1 authored values only for the explicitly approved approval +perspective. Unknown policy-relevant values SHALL not create reusable or safe +coverage. + +Deny-only defensive scans MAY deny incomplete input but SHALL never authorize +it. + +#### Scenario: PowerShell pipeline evaluates every occurrence + +- **WHEN** native Windows PowerShell submits a pipeline +- **THEN** every stage receives a candidate or strict finding +- **AND** one covered stage cannot hide an uncovered stage + +#### Scenario: Bash does not cross-parse PowerShell payload + +- **WHEN** native Bash submits `pwsh -Command 'Get-Content ./a.txt'` +- **THEN** policy evaluates the Bash `pwsh` occurrence +- **AND** it does not create a native PowerShell child candidate + +#### Scenario: Authored facts do not replace effective deny facts + +- **GIVEN** an argument has finite `AuthoredValue` but unknown effective value +- **WHEN** hard deny or runtime path policy evaluates it +- **THEN** those stages retain the effective uncertainty +- **AND** authored facts are limited to the approved matching perspective diff --git a/openspec/changes/structure-shell-approval-policy/tasks.md b/openspec/changes/structure-shell-approval-policy/tasks.md new file mode 100644 index 000000000..f8cb0856c --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/tasks.md @@ -0,0 +1,138 @@ +## 1. Decisions and exact evidence + +- [ ] 1.1 Approve Netclaw's use of ShellSyntaxTree `AuthoredValue` for approval + matching while effective facts retain runtime meaning. +- [ ] 1.2 Approve whether simple v2 grants gain token-prefix authority during + migration or remain exact until re-approved. +- [ ] 1.3 Keep `evidence/approval-matrix.json` byte-identical to the paired + ShellSyntaxTree artifact. +- [ ] 1.4 Add `evidence/netclaw-policy-fixtures.json` with exact structured + candidates, phrases, scopes, grant and safe inputs, coverage, ordered trace, + and outcome for D02, D03, D07, D08, D09, D10, D11, D14, D17, and D18; tests + must load explicit authority defaults and fields, not branch on IDs. +- [ ] 1.5 Add adversarial dynamic identity, redirect, protected path, prefix + collision, runtime loop, wrapper, provider, and unsafe-catalog cases. +- [ ] 1.6 Run the PII audit and manually inspect every command and fixture. + +## 2. Typed coordinator and actor protocol + +- [ ] 2.1 Snapshot immutable preflight facts from existing + `ToolExecutionContext`, `ToolRunScope`, `ToolApprovalAttempt`, and + `ShellExecutionEnvironment`; preserve `OneTimeApprovalKeys` exact-set + semantics and do not add a parallel context or scalar retry key. +- [ ] 2.2 Add one coordinator that runs synchronous preflight, sends one actor + batch request, and completes policy without a second grant scan. +- [ ] 2.3 Add `ShellApprovalMatchRequest` and `ShellApprovalMatchResult` to + `ToolApprovalActor`; match inherited session and persistent snapshots + atomically, return typed persistent-store status, and leave one-time state in + `ToolApprovalAttempt`. +- [ ] 2.4 Route `DispatchingToolExecutor` through the coordinator without + changing the original source, argument object, or tool history. +- [ ] 2.5 Preserve session-pipeline pending-request persistence, + stale/duplicate response rejection and recovery; preserve exact-set one-time + retry in `ToolApprovalAttempt` and actor-owned subagent scope inheritance. + +## 3. Ordered security stages and coverage + +- [ ] 3.1 Implement parse validation, hard deny, protected path, approval mode, + candidate construction, noninteractive trust-zone enforcement, actor match, + safe policy, exact-set one-time matching, and prompt completion in the + specified order. +- [ ] 3.2 Track coverage per candidate; allow only when all candidates are + covered and call-level invariants pass. +- [ ] 3.3 Make internal exceptions, invalid enums, duplicate candidate IDs, + mismatched actor results, and impossible transitions terminal deny. +- [ ] 3.4 Allow fully one-time/session/safe-covered calls when persistent state + is unavailable; deny with `ApprovalStoreUnavailable` instead of prompting + when any candidate still depends on that state. +- [ ] 3.5 Let expected unresolved shell input offer only one-time approval and + deny; never create a reusable candidate. +- [ ] 3.6 Keep legacy token scans deny-only and prove they cannot authorize, + create persistence choices, or widen scope. + +## 4. Typed grant phrases and persistence + +- [ ] 4.1 Add version-3 token-prefix and legacy-exact approval entry shapes with + explicit canonical shell tags. +- [ ] 4.2 Implement backed-up atomic v2 migration; migrate only plain unquoted + atoms according to the approved authority choice and retain ambiguous + entries exact-only. +- [ ] 4.3 Specify and test absent, v1, malformed, partial-v3, future-version, + invalid-enum/token, backup-failure, and atomic-replacement-failure behavior; + internal store failures deny and never salvage partial authority. +- [ ] 4.4 Use the same typed phrase comparison for one-time, session, + persistent, global, and folder coverage. +- [ ] 4.5 Require real exact scope, normalization, containment, and symlink + checks for folder grants; global grants do not require cwd. +- [ ] 4.6 Preserve prompt display/spoof protections and store one clean entry per + persistable candidate. +- [ ] 4.7 Update CLI list/add/revoke behavior and operator docs for schema 3, + including manual rollback by restoring the preserved `.v2.bak` while the + daemon is stopped. + +## 5. Immutable reviewed safe-policy catalog + +- [ ] 5.1 Replace runtime-overridable safe-verb strings with embedded typed + per-platform catalog entries. +- [ ] 5.2 Audit every Linux and Windows entry against the + read-only-for-all-arguments rule. +- [ ] 5.3 Remove `find`, `awk`, `rg`, `sort`, and every other unproved entry; + add direct adversarial cases. +- [ ] 5.4 Delete the `git ls-tree` and all other executable-specific production + normalization branches. +- [ ] 5.5 Preserve redirect, explicit path, safe-root, audience, and symlink + checks as separate effects. +- [ ] 5.6 Preserve native PowerShell provider checks, including strict + `Get-Content Env:SECRET` behavior. + +## 6. Bash causal approval intent + +- [ ] 6.1 Derive intent from canonical Bash directory-transition and control-flow + facts without changing execution analysis. +- [ ] 6.2 Implement replacement and invalidation across later directory changes, + `||`, joins, groups/subshells, dynamic flow, and unsupported regions. +- [ ] 6.3 Apply intent only to reviewed read-only candidates without writing + redirects; keep folder grants and every deny check on real facts. +- [ ] 6.4 Pin D03's `/tmp` trace and later-directory-mutation counterexamples. +- [ ] 6.5 Keep native PowerShell causal scope strict and record native Windows + expected results. + +## 7. ShellSyntaxTree 0.3.1 adoption + +- [ ] 7.1 Upgrade the central package after 0.3.1 is published. +- [ ] 7.2 Consume effective `Value` for runtime checks and approved + `AuthoredValue` only for the documented approval perspective. +- [ ] 7.3 Treat `IntegerRange` and `Concatenation` as bounded scalar data only. +- [ ] 7.4 Check every effective or authored finite value whose existing + `Argument.IsPath` is true through `ToolPathPolicy`; treat + `AuthoredPathShape` as lexical-only and keep unknown path values strict. +- [ ] 7.5 Delete the broad Bash environment-variable relaxation and its + superseded tests. +- [ ] 7.6 Pin exact D02, D10, and D14 input-to-coverage results. + +## 8. Trace, guides, and behavioral evals + +- [ ] 8.1 Emit the capped trace schema and enforce control/bidi escaping, + secret redaction, and truncation. +- [ ] 8.2 Append actor grant rows without a second scan; project near-miss logs + from the same trace. +- [ ] 8.3 Keep trace data out of model prompts and session journals. +- [ ] 8.4 Update consumer and operator guides with complete input, facts, + coverage, trace, and output examples. +- [ ] 8.5 Update the `netclaw-operations` skill and deterministic approval evals + for schema 3 and the authored-source boundary. +- [ ] 8.6 Update `IMPLEMENTATION_PLAN.md` and canonical OpenSpec requirements. + +## 9. Validation and staged delivery + +- [ ] 9.1 Run strict OpenSpec validation before implementation and delivery. +- [ ] 9.2 Run focused `Netclaw.Security`, `Netclaw.Actors`, persistence, CLI, + recovery, and eval tests. +- [ ] 9.3 Run the complete Linux approval matrix and native Windows PowerShell + matrix. +- [ ] 9.4 Run Release build, repository tests, header verification, and + Slopwatch. +- [ ] 9.5 Obtain adversarial review of every vertical slice and resolve all + findings. +- [ ] 9.6 Rebase each slice on `upstream/dev` and deliver dependency-ordered PRs + with observed CI status.