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
28 changes: 28 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,34 @@ the smallest repeatable manual script plus expected output.

## NOW

### Priority: Reduce Shell Approval Fatigue

**PRDs:** `docs/prd/PRD-002-gateway-security-envelope.md`, `docs/prd/PRD-006-mcp-tool-integration.md`
**Spec:** `openspec/specs/tool-approval-gates/spec.md`
**Surface area:** shell authorization, approval matching, security corpus
**Verification:** L2

The user promoted this work into `NOW`. The work must reduce repeat prompts
without allowing an incomplete or unknown shell form.

Done when:

- [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
still requires approval.
- [x] A prompt excludes a safe stage from the approval candidates that the user
can persist.
- [x] A one-time retry is bound to the exact prompted candidate set, including
each effective directory, across live, sub-agent, and redrive paths.
- [x] External paths, mismatched grants, dynamic syntax, and hard-deny rules
keep their strict behavior.
- [ ] A constrained executable grammar proves any future safe `sed` form. The
`-n` option alone is not proof because a `sed` program can write files or
execute commands.
- [ ] Netclaw consumes the ShellSyntaxTree v0.3 occurrence model for bounded
loops, substitutions, and explicit redirects after the composition slice.

### Priority: Simplify Tool Execution Context Architecture

**PRDs:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-002-gateway-security-envelope.md`, `docs/prd/PRD-006-mcp-tool-integration.md`, `docs/prd/PRD-007-agent-personality-and-local-memory.md`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ public async Task One_time_approval_allows_immediate_retry_only()
executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));

context.OneTimeApprovedToolName = toolCall.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));

// The one-time-approval bypass should let the call succeed.
// Output text varies by test environment (git status); meaningful
Expand Down Expand Up @@ -941,7 +941,7 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns(
executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));

context.OneTimeApprovedToolName = toolCall.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));

var decision = await executor.EvaluateAuthorizationAsync(
toolCall,
Expand Down Expand Up @@ -1006,7 +1006,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns
executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken));

context.OneTimeApprovedToolName = toolCall.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));

var retryResult = await executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken);
Assert.Contains("Successfully wrote", retryResult, StringComparison.Ordinal);
Expand Down Expand Up @@ -1095,7 +1095,7 @@ await approvalService.RecordApprovalAsync(
Assert.Contains("ls", firstAttempt.ApprovalContext.Patterns);

context.OneTimeApprovedToolName = call.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));

var retryResult = await executor.ExecuteAsync(call, context, TestContext.Current.CancellationToken);
Assert.Contains("Exit code: 0", retryResult, StringComparison.Ordinal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public async Task ApprovedOnce_on_messy_command_satisfies_one_time_bypass()
// patterns list is empty (per ApprovalContext.Patterns above), so
// the bypass must rely on tool-name match only.
context.OneTimeApprovedToolName = toolCall.Name;
context.SetOneTimeApprovedPatterns(firstAttempt.ApprovalContext.Patterns);
context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(firstAttempt.ApprovalContext));

// The retry must succeed without throwing. Output text varies by
// environment (bash for-loop expansion); the load-bearing assertion
Expand Down
53 changes: 30 additions & 23 deletions src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,12 +302,12 @@ public static class ShellApprovalCases
"mixed-safe-unsafe-compound-prompts",
Bash("git status && git push"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"safe-pipe-unsafe-tail-prompts",
Bash("git status | git push"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"safe-pipeline-allows",
Bash("git log | head -20"),
Expand Down Expand Up @@ -442,17 +442,17 @@ public static class ShellApprovalCases
"semicolon-sequence-prompts",
Bash("git status; git push"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"newline-sequence-prompts",
Bash("git status\ngit push"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"or-chain-prompts",
Bash("git status || git push"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"three-step-release-prompts",
Bash("git add . && git commit -m fix && git push origin dev"),
Expand Down Expand Up @@ -497,7 +497,7 @@ public static class ShellApprovalCases
"subshell-prompts",
Bash("(git status && git push)"),
Approvals.None,
ExpectedApproval.Require(["git status", "git push"])),
ExpectedApproval.Require(["git push"])),
Case(
"command-substitution-fails-closed",
Bash("echo $(git push)"),
Expand Down Expand Up @@ -537,7 +537,7 @@ public static class ShellApprovalCases
"fd-dup-redirect-mutating-no-grant-prompts-not-messy",
Bash("git push origin dev 2>&1 | tail -2"),
Approvals.None,
ExpectedApproval.Require(["git push origin dev", "tail"], isMessy: false)),
ExpectedApproval.Require(["git push origin dev"], isMessy: false)),
Case(
"dynamic-fd-redirect-fails-closed",
Bash("git status 2>&$FD"),
Expand Down Expand Up @@ -674,10 +674,10 @@ public static class ShellApprovalCases
Approvals.None,
ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),
Case(
"workload-search-cat-jq-pipeline-currently-prompts",
"workload-search-cat-jq-pipeline-prompts-for-tail",
Bash("cat config.json | jq '.items[]'"),
Approvals.None,
ExpectedApproval.Require(["cat", "jq"])),
ExpectedApproval.Require(["jq"])),
Case(
"workload-search-jq-direct-prompts",
Bash("jq '.items[]' config.json"),
Expand All @@ -689,17 +689,22 @@ public static class ShellApprovalCases
Approvals.PersistentHere(ApprovalDirectoryShape.Project, "jq"),
ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:jq")),
Case(
"workload-search-cat-jq-stored-tail-currently-prompts",
"workload-search-cat-jq-stored-tail-allows",
Bash("cat config.json | jq '.items[]'"),
Approvals.PersistentHere(ApprovalDirectoryShape.Project, "jq"),
ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:jq")),
Case(
"workload-search-cat-jq-external-stored-tail-still-prompts",
Bash("cat config.json | jq '.items[]'", ApprovalDirectoryShape.External),
Approvals.PersistentHere(ApprovalDirectoryShape.External, "jq"),
ExpectedApproval.Require(
["cat", "jq"],
approvalMatches: ["persistent:jq"])),
Case(
"workload-edit-grep-tee-pipeline-prompts",
Bash("grep \"error\" logs/app.log | tee reports/errors.txt"),
Approvals.None,
ExpectedApproval.Require(["grep", "tee"])),
ExpectedApproval.Require(["tee"])),
Case(
"workload-edit-tee-direct-prompts",
Bash("tee reports/output.txt"),
Expand All @@ -711,12 +716,15 @@ public static class ShellApprovalCases
Approvals.PersistentHere(ApprovalDirectoryShape.Project, "tee"),
ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:tee")),
Case(
"workload-edit-grep-tee-stored-tail-currently-prompts",
"workload-edit-grep-tee-stored-tail-allows",
Bash("grep \"error\" logs/app.log | tee reports/errors.txt"),
Approvals.PersistentHere(ApprovalDirectoryShape.Project, "tee"),
ExpectedApproval.Require(
["grep", "tee"],
approvalMatches: ["persistent:tee"])),
ExpectedApproval.Allow(ToolAllowReason.StoredApproval, 1, "persistent:tee")),
Case(
"workload-edit-grep-tee-mismatched-tail-grant-prompts",
Bash("grep \"error\" logs/app.log | tee reports/errors.txt"),
Approvals.PersistentHere(ApprovalDirectoryShape.External, "tee"),
ExpectedApproval.Require(["tee"])),
Case(
"workload-edit-sed-in-place-prompts",
Bash("sed -i 's/old/new/' src/file.txt"),
Expand Down Expand Up @@ -951,15 +959,12 @@ public static class ShellApprovalCases
ExpectedApproval.Allow(
ToolAllowReason.StoredApproval,
1,
"session:git status",
"persistent:git push")),
Case(
"partial-compound-grant-prompts",
Bash("git status && git push"),
Approvals.PersistentAnywhere("git status"),
ExpectedApproval.Require(
["git status", "git push"],
approvalMatches: ["persistent:git status"])),
ExpectedApproval.Require(["git push"])),
Case(
"four-unapproved-clauses-prompt",
Bash("git add . && git commit -m fix && git push && gh pr merge 123"),
Expand Down Expand Up @@ -1065,12 +1070,14 @@ public static class ShellApprovalCases
"persistent:git push",
"session:gh pr merge")),
Case(
"safe-and-stored-authority-currently-do-not-compose",
"safe-and-stored-authority-compose",
Bash("git status && git push && git log && gh pr merge 123"),
Approvals.PersistentAnywhere("git push", "gh pr merge"),
ExpectedApproval.Require(
["git status", "git push", "git log", "gh pr merge"],
approvalMatches: ["persistent:git push", "persistent:gh pr merge"])),
ExpectedApproval.Allow(
ToolAllowReason.StoredApproval,
1,
"persistent:git push",
"persistent:gh pr merge")),
Case(
"four-hard-deny-beats-grants",
Bash("git add . && git commit -m fix && netclaw daemon stop && git push"),
Expand Down
Loading
Loading