Skip to content

fix(approvals): scope trailing-slash directory globs to their parent - #1785

Open
Aaronontheweb wants to merge 5 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/approvals-trailing-slash-glob
Open

fix(approvals): scope trailing-slash directory globs to their parent#1785
Aaronontheweb wants to merge 5 commits into
netclaw-dev:devfrom
Aaronontheweb:fix/approvals-trailing-slash-glob

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

Netclaw 0.25.3 changed how the approval gate reads a glob. A command like
ls -d /home/netclaw/.netclaw/workspaces/immovlan/*/ now shows the prompt
"complex command — only one-shot approval available". The operator cannot keep
the grant. So the agent asks for approval on every call. This causes approval
fatigue.

Root cause

The approval gate marks a glob as an unresolved descendant scope. It applies the
mark when a / follows the first wildcard character. The helper is
HasUnresolvedDescendantScope.

A trailing slash (foo/*/) is a type filter for directories. It is not a
descendant path segment. Every path that the wildcard expands is still a direct
child of the covering directory foo.

The gate did not tell these two shapes apart. So it failed foo/*/ closed to
one-shot approval. Two prior changes added the behavior: the parser-based
analysis (#1753) and the descendant-scope rule (#1768). Version 0.25.2 did not
have the behavior.

Fix

HasUnresolvedDescendantScope now removes trailing slashes first. Then it runs
the same test for a / after the wildcard. One helper feeds three consumers:
HasDynamicSyntax, the covering-directory guard, and IsMessy. So the three
cannot drift apart.

  • foo/*/ becomes the leaf glob foo/*. The gate resolves the covering
    directory foo. The grant is persistable, and the symlink scan still runs.
  • A real segment after the wildcard keeps its separator. foo/*/x and foo/*/*
    stay one-shot.

Corpus examples

These cases are safe to evaluate. The gate can resolve a scope and offer a
persistent grant:

// In the project tree a read-only safe verb auto-allows. No prompt appears.
Case(
    "directory-listing-glob-in-project-auto-allows",
    Bash("ls -d subdirs/*/"),
    Approvals.None,
    ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)),

// Outside the trusted tree the command prompts, but now offers a persistent
// grant scoped to the covering directory instead of one-shot only.
Case(
    "directory-listing-glob-external-offers-persistent-grant",
    Bash("ls -d subdirs/*/", ApprovalDirectoryShape.External),
    Approvals.None,
    ExpectedApproval.Require(["ls"], isMessy: false)),

These cases are not safe to evaluate. The gate keeps them one-shot:

"cat artifacts/*/deeper/"  // a real segment follows the wildcard
"ls  artifacts/*/*/"       // a second wildcard segment follows the first
// ls -d artifacts/*/ when "artifacts" holds a symlink child

Why the safe cases are safe

A glob is safe to evaluate for a persistent grant when the covering directory
bounds every expanded path. For foo/*/:

  • The wildcard matches only names directly inside foo.
  • The trailing slash keeps only the directories. It adds no deeper path.
  • So every expanded path is a direct child of foo. The directory foo is a
    true upper bound.
  • The gate scans foo for a symlink child. A symlink child fails the command
    closed. So a matched directory cannot escape foo through a link.

A glob is not safe when a real segment follows the wildcard. For foo/*/x:

  • The wildcard matches a directory name. Then the path continues into that
    directory.
  • The scan of foo checks only direct children. It cannot bound a deeper link
    such as foo/dir/x.
  • So the gate keeps this shape one-shot.

Blast radius

The predicate serves only the approval matcher. The hard-deny policy does not
read it, and hard-deny runs first. So the fix cannot bypass a deny rule. The net
effect for foo/*/:

  • A read-only safe verb inside the trusted tree now auto-allows. This matches
    ls foo/* today.
  • Outside the trusted tree the command prompts. It now offers a persistent grant
    scoped to the covering directory, not one-shot only.
  • The change reaches every command that carries a foo/*/ argument, not only
    the reported one.

These paths do not change (and the tests verify it):

  • hard-deny
  • external and protected paths still prompt
  • symlink globs still fail closed
  • real descendant globs stay one-shot
  • Windows (the legacy tokenizer path)
  • non-shell matchers

Tests

  • Matcher glob corpus (ShellApprovalMatcherPathExtractionTests): trailing-slash
    globs resolve a covering directory and are not complex. Real descendant
    segments and symlink children stay one-shot.
  • Disposition matrix (ShellApprovalCases): an in-project directory command
    auto-allows as a safe verb, an external scope offers a persistent grant, and
    the reported pipeline is not complex.
  • Hardened the case external-glob-does-not-reuse-project-grant. It now uses an
    isolated temp subdirectory. A symlink child of the shared system temp (for
    example an IDE socket) no longer flakes the covering-directory scan.

dotnet slopwatch analyze: 0 issues. Copyright headers verified.

A glob that ends in a slash (foo/*/) matches only directories. It adds no
descendant path segment. Every path that the wildcard expands is still a direct
child of the covering directory foo. Since 0.25.3 the approval gate read the
trailing slash as an unresolved descendant scope. So a command like
`ls -d .../immovlan/*/` failed closed to a one-shot "complex command" prompt.

HasUnresolvedDescendantScope now removes trailing slashes before the
descendant-segment test. A real segment after the wildcard (foo/*/x, foo/*/*)
still fails closed. The covering-directory symlink scan does not change. So
foo/*/ gets the same persistable, symlink-checked scope that the leaf glob
foo/* already has.

Tests:
- Matcher corpus: trailing-slash globs resolve a covering directory and are not
  complex. Real descendant segments and symlink children stay one-shot.
- Disposition corpus: an in-project directory command auto-allows as a safe
  verb, an external scope offers a persistent grant, and the reported pipeline
  is not complex.
- Harden the external-glob case to an isolated temp subdirectory. A symlink
  child of shared /tmp (for example an IDE socket) no longer flakes the scan.
@Aaronontheweb
Aaronontheweb force-pushed the fix/approvals-trailing-slash-glob branch from a69479e to 9da8939 Compare August 6, 2026 15:25
@Aaronontheweb Aaronontheweb added bug Something isn't working security Security-related changes shell Issues related to the shell tool, since it has the largest security perimeter. labels Aug 6, 2026
@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) August 6, 2026 15:28

@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.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security Security-related changes shell Issues related to the shell tool, since it has the largest security perimeter.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant