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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ creates avoidable drift between main-session and subagent behavior.
- Align explicit `spawn_agent` delegation and declarative `metadata.subagent`
routing so both paths use the same live-loaded registry and inherited parent
context contract.
- Define inherited shell cwd snapshot for subagent executions so the child's
`ToolExecutionContext.InheritedCwd` captures the parent's resolved working
directory at spawn time, and pin down approval-gate behavior for subagent
invocations under both inherited and null cwd (folder-scoped grants match
under the parent's cwd; global grants match regardless of cwd, including
null).

## Capabilities

Expand All @@ -61,6 +67,9 @@ creates avoidable drift between main-session and subagent behavior.
subagent system prompts using the same file precedence as the parent session.
- `skill-execution-routing`: align `metadata.subagent` routing with the same
reloadable registry and parent-context inheritance behavior as `spawn_agent`.
- `tool-approval-gates`: pin down how the approval gate evaluates subagent
shell invocations under inherited and null cwd so persisted folder-scoped
and global grants match consistently with the parent session.

## Impact

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,60 @@ the child, and subagent execution SHALL NOT mutate the parent session's
- **WHEN** a spawned subagent completes
- **THEN** the parent session still has the same `ProjectDirectory`
- **AND** no child-side action implicitly rewrites the parent working context

### Requirement: Resolved shell cwd flows to spawned subagents as read-only snapshot

When a session spawns a subagent, the runtime SHALL capture the parent's
*resolved* shell working directory at spawn time — equivalent to the value
`ToolExecutionContext.ResolveShellCwd(null)` returns on the parent's tool
execution context — and populate the child's
`ToolExecutionContext.InheritedCwd` with that value before any tool
authorization runs inside the child. `ToolExecutionContext.Cwd` remains the
per-call resolved output written by the approval gate when a concrete tool
invocation is evaluated. The inherited cwd SHALL be read-only from the child:
subagent execution SHALL NOT mutate the parent session's
`WorkingContext.ProjectDirectory` or otherwise rewrite the parent's own cwd
inputs. When the parent has no resolvable cwd at spawn time (no explicit
working directory, no `ProjectDirectory`, no `SessionDirectory`), the child's
`InheritedCwd` SHALL be `null`; the approval gate SHALL continue to evaluate
persisted global grants in that case as defined by the
`tool-approval-gates` capability.

#### Scenario: Subagent inherits parent's resolved working directory

- **GIVEN** a session whose parent `ToolExecutionContext.ResolveShellCwd(null)` resolves to
`/home/user/repos/foo`
- **WHEN** the session spawns a subagent
- **THEN** the subagent's `ToolExecutionContext.InheritedCwd` is
`/home/user/repos/foo` before its first tool invocation
- **AND** a `shell_execute` call inside the subagent with no
`WorkingDirectory` argument resolves cwd to `/home/user/repos/foo` for
approval purposes

#### Scenario: Subagent shell approval shows the inherited cwd in the header

- **GIVEN** the parent's resolved cwd at spawn time is `/home/user/repos/foo`
- **WHEN** the subagent invokes an approval-gated shell command with no
explicit `WorkingDirectory` argument
- **THEN** the approval prompt header reads
`Approve <verb> in /home/user/repos/foo?`
- **AND** the prompt header does NOT read
`Approve <verb> in (no working directory)?`

#### Scenario: Subagent with no inheritable cwd surfaces null cwd faithfully

- **GIVEN** the parent's `ToolExecutionContext.ResolveShellCwd(null)` returns
`null` (no explicit cwd, no `ProjectDirectory`, no `SessionDirectory`)
- **WHEN** the session spawns a subagent
- **THEN** the subagent's `ToolExecutionContext.InheritedCwd` is `null`
- **AND** the approval gate SHALL still evaluate persisted global grants
per the `tool-approval-gates` capability

#### Scenario: Subagent does not mutate parent's working context

- **GIVEN** a session with `WorkingContext.ProjectDirectory` =
`/home/user/repos/foo` and resolved cwd = `/home/user/repos/foo`
- **WHEN** a spawned subagent runs to completion (succeeds, fails, or times
out)
- **THEN** the parent session's `WorkingContext.ProjectDirectory` is unchanged
- **AND** no subagent action implicitly rewrites the parent working context
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## ADDED Requirements

### Requirement: Subagent approval evaluation uses the inherited parent cwd

The approval gate SHALL treat a subagent's `shell_execute` invocation as
having the cwd inherited from the parent session at spawn time, captured per
the `session-cwd` capability's "Resolved shell cwd flows to spawned subagents
as read-only snapshot" requirement. Persisted folder-scoped grants whose
directory contains the inherited cwd SHALL therefore auto-approve the
subagent invocation under the same rules as the parent session. Persisted
global grants (`directory: null`) SHALL continue to auto-approve regardless
of cwd, including when the inherited cwd is `null`. The matcher SHALL NOT
introduce a new short-circuit that bypasses persisted grants when the
inherited cwd is `null`; the existing
`ApprovalPatternMatching.MatchesShellApproval` semantics apply.

#### Scenario: Folder-scoped parent grant covers subagent invocation

- **GIVEN** `tool-approvals.json` contains
`{"verb":"dotnet build","directory":"/home/user/repos/foo/"}`
- **AND** the parent session's resolved cwd at subagent spawn is
`/home/user/repos/foo/`
- **WHEN** the spawned subagent invokes `dotnet build` with no explicit
`WorkingDirectory` argument
- **THEN** the matcher returns approved
- **AND** no approval prompt is rendered to the user

#### Scenario: Global grant covers subagent invocation with null cwd

- **GIVEN** `tool-approvals.json` contains
`{"verb":"netclaw stats","directory":null}`
- **AND** the spawned subagent has no inherited cwd (the parent had none
either)
- **WHEN** the subagent invokes `netclaw stats`
- **THEN** the matcher returns approved regardless of the null cwd
- **AND** no approval prompt is rendered

#### Scenario: Folder-scoped parent grant does not match subagent with null cwd

- **GIVEN** `tool-approvals.json` contains
`{"verb":"dotnet build","directory":"/home/user/repos/foo/"}`
- **AND** the spawned subagent has no inherited cwd
- **WHEN** the subagent invokes `dotnet build` with no explicit
`WorkingDirectory` argument
- **THEN** the folder-scoped grant SHALL NOT match (no effective directory)
- **AND** the approval gate prompts the user with the header form
`Approve dotnet build in (no working directory)?` as documented in this
capability's "Five-button approval prompt with verb-and-directory framing"
requirement
- **AND** the daemon log SHALL emit an `approval_near_miss` diagnostic with
reason `NoCandidateDirectory` so the operator can see why the grant did
not match

### Requirement: Subagent inherits parent session-scoped approvals

The approval gate SHALL walk from a subagent's scope id toward its parent
session and SHALL treat any session-scoped approval (a `This chat` click)
recorded against the parent session id as also authorizing the subagent's
verbs. The subagent scope id has the form
`{parentSessionId}/subagent/{name}/{runId}`; the walk SHALL terminate at the
first non-`/subagent/` segment so unrelated sessions never share
session-scoped approvals. This requirement codifies the existing
`ToolApprovalActor.IsSessionApproved` scope-walk behavior so future
refactors SHALL NOT regress it; it does not introduce a new code path.

#### Scenario: This-chat grant in parent authorizes subagent invocation

- **GIVEN** the parent session granted `This chat` for verb `gh pr view` in
the current chat
- **WHEN** a spawned subagent in that chat invokes `gh pr view 123`
- **THEN** the matcher returns approved via the session-scoped grant
- **AND** no approval prompt is rendered
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,15 @@
- [ ] 6.4 `/opsx-verify align-subagent-loading-and-parent-context` after implementation lands.
- [ ] 6.5 `/opsx-sync align-subagent-loading-and-parent-context` to merge the deltas into the main specs.
- [ ] 6.6 `/opsx-archive align-subagent-loading-and-parent-context` after merge.

## 7. Subagent cwd snapshot and approval-gate alignment

- [x] 7.1 Extend `session-cwd` delta with the "Resolved shell cwd flows to spawned subagents" requirement and scenarios.
- [x] 7.2 Add a `tool-approval-gates` delta covering subagent approval evaluation under inherited and null cwd.
- [x] 7.3 Add `ParentCwd` to the `RunSubAgent` protocol record.
- [x] 7.4 Populate `ParentCwd` from `context.ResolveShellCwd(null)` in `SubAgentSpawner`.
- [x] 7.5 Initialize `_toolExecutionContext.InheritedCwd` from `msg.ParentCwd` in `SubAgentActor.Idle`.
- [x] 7.6 Add `ShellApprovalMatcherTests` regression case for null-cwd + null-candidateDirectory + null-entry-directory.
- [x] 7.7 Add `SubAgentActor` context-init test that asserts `ToolExecutionContext.InheritedCwd` equals `RunSubAgent.ParentCwd`.
- [x] 7.8 Add `SubAgentSpawner` propagation test that asserts the dispatched `RunSubAgent` carries the parent's resolved cwd.
- [x] 7.9 Run targeted tests and quality gates (slopwatch, file headers).
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,76 @@ await AwaitAssertAsync(() =>
}, cancellationToken: ct);
}

[Fact]
public async Task Cold_text_approval_response_forwards_to_session_when_binding_cold_spawned()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-cold-text-approve");

// Empty output stream: the binding never sees the original approval prompt,
// so any text reply must be forwarded through the cold-path fallback.
var pipeline = new RecordingSessionPipeline(_ => []);

var actor = CreateBindingActor(sid, pipeline, detector);

actor.Tell(CreateInboundMessage("A", "user-1"), TestActor);

await AwaitAssertAsync(() =>
{
var feedback = pipeline.RecordedFeedback.OfType<ToolInteractionTextResponse>().ToList();
Assert.Single(feedback);
Assert.Equal(sid, feedback[0].SessionId);
Assert.Equal("A", feedback[0].Text);
Assert.Equal("user-1", feedback[0].SenderId.Value);
}, cancellationToken: ct);
}

[Fact]
public async Task Text_approval_response_uses_visible_option_order_when_option_set_is_pruned()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-text-approve-pruned");
var pipeline = new RecordingSessionPipeline(_ =>
[
new ToolInteractionRequest
{
SessionId = sid,
Kind = "approval",
CallId = new Netclaw.Tools.ToolCallId("call-3b"),
ToolName = new Netclaw.Tools.ToolName("shell_execute"),
DisplayText = "git push origin main",
RequesterSenderId = new SenderId("user-1"),
Options =
[
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]
}
]);

var actor = CreateBindingActor(sid, pipeline, detector);

await AwaitAssertAsync(() =>
{
var texts = GetPostedTexts();
Assert.Contains(texts, t => t.Contains("shell_execute"));
}, cancellationToken: ct);

actor.Tell(CreateInboundMessage("C", "user-1"), TestActor);

await AwaitAssertAsync(() =>
{
var feedback = pipeline.RecordedFeedback.OfType<ToolInteractionResponse>().ToList();
Assert.Single(feedback);
Assert.Equal("call-3b", feedback[0].CallId.Value);
Assert.Equal(ApprovalOptionKeys.ApproveEverywhere, feedback[0].SelectedKey.Value);
}, cancellationToken: ct);
}

[Fact]
public async Task Approvals_cleared_on_turn_completed()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public void BuildTextPrompt_contains_tool_name_and_options()
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]
};
Expand All @@ -39,6 +40,8 @@ public void BuildTextPrompt_contains_tool_name_and_options()
Assert.Contains("B)", prompt);
Assert.Contains("C)", prompt);
Assert.Contains("D)", prompt);
Assert.Contains("E)", prompt);
Assert.Contains(ApprovalOptionKeys.ApproveEverywhereLabel, prompt);
}

[Fact]
Expand Down Expand Up @@ -145,7 +148,7 @@ public void BuildResolvedPromptText_omits_patterns_when_empty()
}

[Fact]
public void BuildButtonPrompt_produces_attachment_with_four_buttons()
public void BuildButtonPrompt_produces_attachment_with_five_buttons()
{
var request = CreateStandardRequest();

Expand All @@ -154,12 +157,12 @@ public void BuildButtonPrompt_produces_attachment_with_four_buttons()

Assert.Contains("Tool approval required", text);
Assert.Contains("git_push", text);
Assert.Contains("reply with `A`, `B`, `C`, or `D`", text);
Assert.Contains("reply with `A`, `B`, `C`, `D`, `E`", text);

Assert.Single(attachments);
var attachment = attachments[0];
Assert.NotNull(attachment.Actions);
Assert.Equal(4, attachment.Actions!.Count);
Assert.Equal(5, attachment.Actions!.Count);
}

[Fact]
Expand Down Expand Up @@ -293,6 +296,7 @@ private static ToolInteractionRequest CreateStandardRequest()
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveAlwaysKey, ApprovalOptionKeys.ApproveAlwaysLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveEverywhereLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,7 @@ public void ToolApprovalRequested_round_trips_all_persisted_context()
RequesterSenderId = new SenderId("U12345"),
RequesterPrincipal = Netclaw.Configuration.PrincipalClassification.Operator,
Cwd = "/home/user/project",
OptionKeys = [ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveEverywhere, ApprovalOptionKeys.Deny],
Candidates =
[
new ToolApprovalRequested.ApprovalCandidateRecord { Verb = "git", Directory = "/home/user/project" },
Expand All @@ -736,6 +737,7 @@ public void ToolApprovalRequested_round_trips_all_persisted_context()
Assert.Equal(wrapped.RequesterSenderId, result.RequesterSenderId);
Assert.Equal(wrapped.RequesterPrincipal, result.RequesterPrincipal);
Assert.Equal(wrapped.Cwd, result.Cwd);
Assert.Equal(wrapped.OptionKeys, result.OptionKeys);
Assert.Equal(2, result.Candidates.Count);
Assert.Equal("git", result.Candidates[0].Verb);
Assert.Equal("/home/user/project", result.Candidates[0].Directory);
Expand Down
Loading
Loading