diff --git a/openspec/changes/structure-shell-approval-policy/design.md b/openspec/changes/structure-shell-approval-policy/design.md index 3d6e73d07..64f97f2f5 100644 --- a/openspec/changes/structure-shell-approval-policy/design.md +++ b/openspec/changes/structure-shell-approval-policy/design.md @@ -3,6 +3,8 @@ 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. +Complex prompts can expose only `Once` and `Deny`. An observed `Once` response +does not prove that the operator preferred one-time authority. Current ownership is split. `ToolAccessPolicy` performs synchronous policy, `DispatchingToolExecutor` coordinates asynchronous approval, @@ -253,6 +255,12 @@ file. This avoids silently changing the authority set. Only a new version-3 grant can use `TokenPrefix`. The user sees that phrase in the approval surface before Netclaw stores the grant. +A `LegacyExact` entry compares with the projected legacy candidate phrase. It +does not compare with the full command line. A global `gh api` entry therefore +covers both read and mutation calls whose projected phrase is `gh api`. This +behavior preserves version-2 authority and reduces prompts. An operator can +revoke that entry without a reset of unrelated approvals. + ### 6. Use a reviewed immutable safe-policy catalog The bundled per-platform resource contains typed phrase entries. A diff --git a/openspec/changes/structure-shell-approval-policy/evidence/post-1925-binary-swap-approval-harvest.json b/openspec/changes/structure-shell-approval-policy/evidence/post-1925-binary-swap-approval-harvest.json new file mode 100644 index 000000000..9618ecdc9 --- /dev/null +++ b/openspec/changes/structure-shell-approval-policy/evidence/post-1925-binary-swap-approval-harvest.json @@ -0,0 +1,102 @@ +{ + "schemaVersion": 1, + "sourceRuntime": { + "version": "0.26.0", + "commit": "ba83530", + "windowStartUtc": "2026-08-13T19:27:39Z", + "windowEndUtc": "2026-08-13T20:29:54Z", + "shellCallCount": 62, + "approvalPromptCount": 9 + }, + "sanitization": { + "home": "/home/user", + "repositoryRoot": "/work/project", + "worktreeRoot": "/work/project-worktree", + "remoteRepository": "example/project", + "internalHost": "service.example.invalid", + "inlineBodies": "Long inline programs and private commit messages were replaced by semantic placeholders." + }, + "cases": [ + { + "id": "S01", + "sourcePromptTimeUtc": "2026-08-13T20:11:38.238Z", + "commandShape": "curl -sS https://service.example.invalid/api/signals | python3 -c ''", + "observedResponse": "Session", + "classification": "AgentAlignmentDebt", + "owner": "AgentGuidance", + "reason": "The agent used an inline interpreter for a read-only HTTP inspection." + }, + { + "id": "S02", + "sourcePromptTimeUtc": "2026-08-13T20:11:38.407Z", + "commandShape": "cd /work/project && branch=$(git ); [ -z \"$branch\" ] && ; git remote -v; git worktree list", + "observedResponse": "Once", + "classification": "AgentAlignmentDebt", + "owner": "AgentGuidance", + "reason": "The agent used shell state and command substitution for repository discovery instead of bounded direct reads." + }, + { + "id": "S03", + "sourcePromptTimeUtc": "2026-08-13T20:12:23.083Z", + "commandShape": "cd /work/project && git fetch origin && git pull --ff-only origin dev && git rev-parse && ls /work/project-worktree", + "observedResponse": "Once", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "Git fetch and pull contact a remote and mutate repository state." + }, + { + "id": "S04", + "sourcePromptTimeUtc": "2026-08-13T20:15:58.134Z", + "commandShape": "dotnet slopwatch analyze | tail; echo ''; pwsh -NoProfile -Command './scripts/Add-FileHeaders.ps1 -Verify' | tail", + "observedResponse": "Session", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "General dotnet and PowerShell invocations can perform effects that shell syntax cannot prove absent." + }, + { + "id": "S05", + "sourcePromptTimeUtc": "2026-08-13T20:16:40.981Z", + "commandShape": "cd /work/project && grep -rn 'ExampleMethod' src/example | head", + "observedResponse": "Once", + "classification": "AgentAlignmentDebt", + "owner": "AgentGuidance", + "reason": "The agent used a shell directory transition for a bounded read in a known project instead of declaring the working directory." + }, + { + "id": "S06", + "sourcePromptTimeUtc": "2026-08-13T20:20:20.290Z", + "commandShape": "git add ; git commit -m ''; git push", + "observedResponse": "Once", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "The call creates a commit and mutates a remote branch." + }, + { + "id": "S07", + "sourcePromptTimeUtc": "2026-08-13T20:21:21.610Z", + "commandShape": "cd /work/project && git log ; mkdir -p /work/project-worktree && git worktree add /work/project-worktree -b fix/example dev", + "observedResponse": "Once", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "The call creates a directory, branch, and Git worktree." + }, + { + "id": "S08", + "sourcePromptTimeUtc": "2026-08-13T20:22:57.499Z", + "commandShape": "for i in $(seq 1 10); do dotnet test | inspect; done | sort | uniq -c", + "observedResponse": "Session", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "Runtime iteration and test execution can create artifacts and hide future command data." + }, + { + "id": "S09", + "sourcePromptTimeUtc": "2026-08-13T20:24:44.194Z", + "commandShape": "git checkout -b fix/example; git add ; git commit -m ''; git push -u origin fix/example", + "observedResponse": "Once", + "classification": "ExpectedApproval", + "owner": "ApprovalPolicy", + "reason": "The call creates a branch and commit, then publishes the branch." + } + ] +} diff --git a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs index 5543c09ec..1811319f0 100644 --- a/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs +++ b/src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs @@ -19,6 +19,7 @@ public sealed partial class ShellApprovalEvidenceContractTests private const string ApprovalMatrixFile = "approval-matrix.json"; private const string PolicyFixturesFile = "netclaw-policy-fixtures.json"; private const string PostMergeHarvestFile = "post-1890-approval-harvest.json"; + private const string PostSwapHarvestFile = "post-1925-binary-swap-approval-harvest.json"; private const string ApprovalMatrixSha256 = "0169105efe87b345d9a82d777ef86909e31fa81a5255cc0cc30f32fbe4d0d6b0"; @@ -340,6 +341,44 @@ public void Post_merge_harvest_classifies_every_prompt_in_the_frozen_window() }); } + [Fact] + public void Post_swap_harvest_classifies_every_prompt_in_the_frozen_window() + { + var harvest = JsonSerializer.Deserialize( + File.ReadAllBytes(EvidencePath(PostSwapHarvestFile)), + ShellApprovalEvidenceJsonContext.Default.PostMergeApprovalHarvest) + ?? throw new InvalidDataException($"{PostSwapHarvestFile} has no root object."); + + Assert.Equal(1, harvest.SchemaVersion); + Assert.Equal("0.26.0", harvest.SourceRuntime.Version); + Assert.Equal("ba83530", harvest.SourceRuntime.Commit); + Assert.Equal(62, harvest.SourceRuntime.ShellCallCount); + Assert.Equal(9, harvest.SourceRuntime.ApprovalPromptCount); + Assert.Equal( + Enumerable.Range(1, 9).Select(number => $"S{number:00}"), + harvest.Cases.Select(item => item.Id)); + Assert.Equal( + harvest.Cases.Select(item => item.SourcePromptTimeUtc).Order(), + harvest.Cases.Select(item => item.SourcePromptTimeUtc)); + Assert.All(harvest.Cases, item => + { + Assert.InRange( + item.SourcePromptTimeUtc, + DateTimeOffset.Parse(harvest.SourceRuntime.WindowStartUtc), + DateTimeOffset.Parse(harvest.SourceRuntime.WindowEndUtc)); + }); + Assert.Equal(6, harvest.Cases.Count(item => item.Classification == "ExpectedApproval")); + Assert.Equal(3, harvest.Cases.Count(item => item.Classification == "AgentAlignmentDebt")); + Assert.DoesNotContain( + harvest.Cases, + item => item.Classification is "NetclawPolicyDebt" or "ShellSyntaxTreeFactGap"); + Assert.All(harvest.Cases, item => + { + Assert.False(string.IsNullOrWhiteSpace(item.CommandShape)); + Assert.False(string.IsNullOrWhiteSpace(item.Reason)); + }); + } + private static void AssertValuePart(PolicyValuePart expected, ShellValueDomain actual) { if (expected.Exact is { } exact)