Skip to content

fix(tools): unblock non-interactive shell + webhook provenance + autonomous filesystem zone (#1244)#1250

Merged
Aaronontheweb merged 9 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/1244-noninteractive-shell-trust-zone
May 31, 2026
Merged

fix(tools): unblock non-interactive shell + webhook provenance + autonomous filesystem zone (#1244)#1250
Aaronontheweb merged 9 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/1244-noninteractive-shell-trust-zone

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Closes #1244.

Problem

All non-interactive channels — Headless (netclaw chat -p), Reminder, and Webhook (SupportsInteractiveApproval == false) — failed every shell command with shell_no_trust_zone_roots, even for pre-approved verbs.

Root cause was a semantic divergence inside ScopedFileAccessPolicy: file tools read WriteFiles.Mode == All as allow-all, while the non-interactive shell "trust zone" read the same Mode == All as deny-all (it resolved to an empty roots list). Since shell is Personal-only and Personal is Mode == All, the gate was unreachable-success code — it could only ever deny.

What changed

A — Unify the shell path check with file-access semantics (closes #1244). The non-interactive shell path validation now delegates to ScopedFileAccessPolicy.TryResolveWritePath, so shell and file_write share one interpretation of Mode.All/Roots/None (and one symlink defense). The always-deny shell_no_trust_zone_roots path is removed. Hard-deny, protected-path (ToolPathPolicy), Personal-only, and approval gates are unchanged.

B — Webhook audience provenance. set_webhook now inherits the creating context's audience when omitted (instead of hard-defaulting to Public) and rejects minting above the creator's authority (downgrade-only), mirroring the reminder provenance/escalation model. File-defined config routes keep Public as the fail-closed default.

C — Autonomous filesystem zone (defensive clamp). A self-review found that the unified Mode.All path authorization combined with the pre-existing safe-verb short-circuit — which auto-approves a read-only verb on the cwd alone and never inspects path arguments — could let an unattended session processing a hostile payload (a webhook) auto-read arbitrary out-of-zone files (e.g. ~/.ssh/id_rsa). So autonomous (non-interactive) channels are now confined to a filesystem zone regardless of audience:

  • Single seam at ScopedFileAccessPolicy.TryResolvePath's Mode.All short-circuit, so shell (via TryResolveWritePath) and every file tool (file_read/file_write/file_edit/file_list/attach_file) are confined together — confining only shell would move the vector to file_read.
  • Zone derived from the existing path layoutsession_dir + current project_dir + WorkspacesDirectory (operator-configurable via Workspaces:Directory; projects nest under it), plus the non-sensitive global read roots for reads. No new config knob.
  • Narrows, never widens (autonomous Public stays session-scoped; Mode.Roots/None keep existing behavior), keys on SupportsInteractiveApproval (the missing human backstop), and fails closed on an empty zone. Interactive sessions are unaffected.

Design notes

The decision record lives in the OpenSpec change fix-noninteractive-shell-and-webhook-provenance (proposal/design/specs/tasks), including the rejected alternatives: deleting the trust-zone mechanism, taint-gating (needs plumbing not yet on ToolExecutionContext), and narrowing the safe-verb list (bricks unattended capability and pushes operators to the broader trust-verb). Spec deltas touch tool-approval-gates, inbound-webhooks, and audience-context-filtering.

Testing

  • dotnet build Netclaw.slnx clean; 2137 Netclaw.Actors.Tests pass; dotnet slopwatch analyze 0 issues; copyright headers verified.
  • New: AutonomousZoneClampTests (denied outside zone for read and write; allowed in session/project/workspaces; Public not widened; interactive unrestricted; empty zone fails closed), SetWebhookToolProvenanceTests (inherit / downgrade / upscope-reject), plus an updated Bug: Non-interactive channels always fail with shell_no_trust_zone_roots #1244 regression in ToolApprovalGateTests.
  • netclaw-operations system skill updated (set_webhook audience inheritance; corrected stale "trust zone path" guidance), version bumped.

Follow-ups (not in this PR)

  • Behavioral eval suite + live CLI/webhook end-to-end need a model provider; run pre-merge in CI.
  • OpenSpec sync/archive after merge.

… provenance (netclaw-dev#1244)

Non-interactive channels (Headless/Reminder/Webhook) always failed shell with shell_no_trust_zone_roots. The shell trust zone resolved Personal's WriteFiles.Mode==All to an empty roots list and denied, while file tools read the same Mode.All as allow-all — a semantic divergence inside ScopedFileAccessPolicy. Because shell is Personal-only, the gate could only ever deny.

Part A: IShellTrustZonePolicy now authorizes a write path by delegating to ScopedFileAccessPolicy.TryResolveWritePath, so shell and file_write share one Mode.All/Roots/None interpretation (incl. symlink defense). EnforceShellTrustZones validates each path token and the working directory through it; the always-deny shell_no_trust_zone_roots path is removed. Hard-deny, protected-path (ToolPathPolicy), Personal-only, and approval gates are unchanged.

Part B: SetWebhookTool inherits the creating context's audience when omitted (instead of hard-defaulting to Public) and rejects minting above creator authority, mirroring the reminder provenance and downgrade-only escalation guard. Config-defined routes keep Public.

Includes the OpenSpec change (tool-approval-gates + inbound-webhooks spec deltas), netclaw-operations skill guidance, and tests (netclaw-dev#1244 regression + webhook provenance).
 change

Code review of the shipped fix found that unifying shell with the file-access policy (Mode.All authorizes any path for Personal) combines with the pre-existing safe-verb short-circuit — which auto-approves a read-only verb on cwd alone and never inspects path arguments — to let an unattended session processing a hostile payload (a webhook) auto-read arbitrary out-of-zone files (e.g. ~/.ssh/id_rsa).

Capture the chosen defense in the OpenSpec change: autonomous (non-interactive) channels are confined to a filesystem zone (session + project + operator-configured roots) across all filesystem tools, regardless of audience. The clamp keys on SupportsInteractiveApproval (the missing human backstop), lives at a single seam in ScopedFileAccessPolicy.TryResolvePath, narrows-never-widens, and is non-empty by construction (the corrected form of the original trust-zone roots bug). Adds a new audience-context-filtering spec delta, the design rationale (incl. rejected taint-gating and verb-narrowing alternatives), and task group 3.
…w-dev#1244)

Review follow-up. Unifying shell with the file-access policy (Mode.All authorizes any path for Personal) combined with the pre-existing safe-verb short-circuit — which auto-approves a read-only verb on the cwd alone, never inspecting path arguments — let an unattended session processing a hostile payload (a webhook) auto-read arbitrary out-of-zone files (e.g. ~/.ssh/id_rsa).

Confine autonomous (non-interactive) channels to a filesystem zone regardless of audience: session dir + current project dir + the workspaces directory (operator-configurable via the existing Workspaces:Directory; projects nest under it), plus the non-sensitive global read roots for reads. The zone is derived from NetclawPaths rather than a new config knob. Reads and writes share the single seam at ScopedFileAccessPolicy.TryResolvePath's Mode.All short-circuit, so shell (via TryResolveWritePath) and every file tool are confined together — confining only shell would move the vector to file_read.

The clamp keys on SupportsInteractiveApproval (the missing human backstop), narrows-never-widens (Mode.Roots/None audiences keep their existing, already-tighter behavior; autonomous Public stays session-scoped), and fails closed on an empty zone. Interactive sessions are unchanged. Updates the OpenSpec design/spec delta/tasks to match.
@Aaronontheweb Aaronontheweb added bug Something isn't working remote-access Network exposure, device pairing, tunnels, webhooks, and remote ingress security Security-related changes shell Issues related to the shell tool, since it has the largest security perimeter. tools Issues related to agent tools: file_read, web_search, shell_execute, image processing, etc. labels May 31, 2026
…bing (netclaw-dev#1244)

Review feedback: the previous approach anchored the zone on NetclawPaths.WorkspacesDirectory, which isn't uniformly available — file_write/file_edit/attach/set_working_directory construct ScopedFileAccessPolicy without paths — so it would have required threading paths through four tool constructors and left the zone inconsistent (shell got workspaces, file_write didn't).

Source the zone from data already at the seam instead: session_dir + project_dir (both already on ToolExecutionContext) for write/attach, plus the existing _cachedGlobalReadRoots for reads. An autonomous session can read across the project tree but write only within its session or current project. Same exfil protection, tighter writes, and no new accessor, constructors, or DI threading. Removes ToolAudienceProfileResolver.WorkspacesDirectory; updates the spec delta/design/tasks and AutonomousZoneClampTests (read/write split).
Twice in netclaw-dev#1244 the implementation reached for a new construct (a config knob, then a constructor parameter threaded through four tools) to feed a design, when the data needed was already flowing through the seam being edited (NetclawPaths, then ToolExecutionContext). Codify the lesson in the Universal Quality Bar: check existing constructs first, anchor on data already present at the call site, and treat a parallel new construct as a defect unless nothing existing fits.
…ntext fallback

Two reviewer-facing source comments capturing 'why would this surprise me?' decisions: (1) ShellTrustZonePolicy checks shell paths against the stricter WRITE rules even for read-only commands — deliberately over-cautious vs file_read on the global read roots, a known capability gap not a security hole; (2) SetWebhookTool's context-less Empty fallback resolves to Public (the floor), so a missing context can only reduce a webhook's privilege, never escalate it.

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few inline notes explaining the non-obvious decisions in this change, in plain language for anyone reviewing the security model later.

// blanket grant — the live approval gate is their backstop. This is the
// single seam that covers shell (via TryResolveWritePath) and every file
// tool at once.
if (context.SupportsInteractiveApproval == false)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one line is the whole fix. When a session has a human who can approve things (Slack, terminal), we let it touch any file — the person is the safety check. When there's no human (a reminder firing, a webhook arriving), we instead box it into a small set of allowed folders. The split is on "is a human watching," not on how trusted the session is.

out string error)
{
var zone = ResolveAutonomousZone(context, accessKind);
if (zone.Count == 0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberately fail shut: if we somehow end up with no allowed folders at all, deny everything rather than fall back to "allow." A locked door with no key beats a door that opens when the lock is missing. In practice the session folder is always present, so this is a just-in-case guard.

if (!string.IsNullOrWhiteSpace(context.ProjectDirectory))
roots.Add(context.ProjectDirectory);

if (accessKind == AccessKind.Read)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading is allowed a bit wider than writing on purpose. An unattended session can read shared things like its own skills and identity files, but can only write inside its session/project folders — so it can consult its instructions but not rewrite them mid-task.

// allow it (the read zone includes the global read roots, the write zone does
// not). This is a known capability gap, not a security hole; making the check
// verb-aware (read-verbs against the read zone) is a possible follow-up.
public bool IsShellWritePathAuthorized(string fullPath, ToolExecutionContext context)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We check shell file paths against the stricter write rules even when the command is only reading. A shell command can write, so we treat it as write-level to be safe. Trade-off: a shell cat of a skills file gets denied even though the read-only file_read tool would allow it. That's intentionally over-cautious, not a security hole — being read/write-smart here is a possible follow-up. (Captured as a source comment just above this line.)

// escalation guard in TryResolveAudience is keyed off creatorAudience, and
// Public is the floor.
protected override Task<string> ExecuteAsync(Params args, CancellationToken ct)
=> ExecuteAsync(args, ToolExecutionContext.Empty, ct);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this tool is ever called without a session context, it falls back to the lowest-privilege identity (Public). So a missing context can only make a webhook less powerful, never accidentally grant it more. (Captured as a source comment just above this line.)

return false;
}

if (parsed > creatorAudience)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A webhook can't be created with more privilege than whoever created it. You can dial a webhook down (e.g. a trusted user makes a locked-down public one), but never up. Same rule reminders already use.

@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) May 31, 2026 21:06
@Aaronontheweb
Aaronontheweb merged commit 347ab28 into netclaw-dev:dev May 31, 2026
14 checks passed
@Aaronontheweb
Aaronontheweb deleted the fix/1244-noninteractive-shell-trust-zone branch May 31, 2026 21:10
Aaronontheweb added a commit that referenced this pull request Jun 25, 2026
#1493) (#1498)

* fix(tools): allow autonomous sessions to write the workspace directory (#1493)

Autonomous (non-interactive) reminder/webhook sessions were confined to a
filesystem write zone of session + project only (introduced in #1250), which
blocked them from persisting state files under the workspaces directory — the
operator's designated writable working area. An autonomous reminder that tried
to write a dedup/state file there hit "autonomous session may only access files
inside its session directory, project directory, or configured autonomous
roots", degraded, and stalled.

Two compounding causes, both fixed:

- ScopedFileAccessPolicy.ResolveAutonomousZone included the global read roots for
  reads but excluded them for writes/attach. It now additionally includes the
  configured workspaces directory for write/attach access — but NOT the
  system-managed skills/identity trees, which stay read-only (an autonomous
  session must never rewrite its own identity or skills).
- FileWriteTool/FileEditTool/AttachFileTool/SetWorkingDirectoryTool constructed
  ScopedFileAccessPolicy without NetclawPaths, leaving the global-roots cache
  empty. NetclawPaths is now threaded through these tools (mirroring
  FileReadTool/FileListTool) so the {workspaces_dir} token resolves.

Cross-boundary contract: the workspaces root is sourced from
NetclawPaths.WorkspacesDirectory, which the daemon builds from the
Workspaces:Directory config (Program.cs), so writes land where the operator
pointed them. Added a test asserting the policy honors a custom configured
workspaces directory.

Updates the netclaw-operations skill (scheduling reference) to describe the
autonomous zone accurately, including the now-writable workspace.

* refactor(tools): make file/shell tool security dependencies required (no silent-skip fallbacks)

Review follow-up to #1493. The same silent-fallback pattern that hid the
workspace-write bug existed across the file/shell tool surface: NetclawPaths,
ToolPathPolicy, and ShellCommandPolicy were all optional/nullable and consumed
with `?.` / `is not null`, so a null silently disabled the check — path roots
dropped, deny-lists skipped, hard-deny command evaluation bypassed. All three
are constructed unconditionally in production, so nullability bought nothing but
a security-skip hazard.

- ScopedFileAccessPolicy, FileReadTool, FileListTool, FileWriteTool,
  FileEditTool, AttachFileTool, SetWorkingDirectoryTool, ShellTool,
  ShellTrustZonePolicy and WithFirstPartyTools now require these dependencies
  (non-nullable). Consumption sites drop the `?.`/null-guards.
- SkillToolRegistration uses GetRequiredService<ToolPathPolicy>() instead of the
  nullable GetService (ToolPathPolicy is a registered singleton).
- Feature-toggle params that genuinely mean "feature off" (searchBackend,
  webhookRouteStore) stay optional.
- Call sites (production + ~40 test sites) updated to pass explicit
  dependencies; the compiler enforced completeness.

Adds an AGENTS.md (constitution) rule: optional/nullable parameters are rare by
default — never added for compatibility, and especially not for security-
relevant dependencies where a null silently disables the check.

Note: ToolAccessPolicy (tool-exposure filtering in SearchToolsTool/LoadToolTool)
is the same pattern and a good follow-up; left out here to keep this change
scoped to the file/shell access surface.

Full Netclaw.Actors.Tests suite green (2479); solution builds clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working remote-access Network exposure, device pairing, tunnels, webhooks, and remote ingress security Security-related changes shell Issues related to the shell tool, since it has the largest security perimeter. tools Issues related to agent tools: file_read, web_search, shell_execute, image processing, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Non-interactive channels always fail with shell_no_trust_zone_roots

1 participant