Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions openspec/changes/structure-shell-approval-policy/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 '<inline parser>'",
"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 <repository query>); [ -z \"$branch\" ] && <fallback>; 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 <refs> && 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 '<status>'; 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 <files>; git commit -m '<message>'; 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 <query>; 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 <filter> | 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 <file>; git commit -m '<message>'; git push -u origin fix/example",
"observedResponse": "Once",
"classification": "ExpectedApproval",
"owner": "ApprovalPolicy",
"reason": "The call creates a branch and commit, then publishes the branch."
}
]
}
39 changes: 39 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalEvidenceContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)
Expand Down
Loading