Skip to content

fix(runtime): accept provider-home ancestors writable only by the operator's private group - #1240

Closed
aviggiano wants to merge 2 commits into
mainfrom
claude/provider-home-private-group
Closed

aviggiano wants to merge 2 commits into
mainfrom
claude/provider-home-private-group

Conversation

@aviggiano

@aviggiano aviggiano commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

assertSafeDirectory in packages/runtime/src/templates/smithers/agents/provider-home.tsx refused every group- or world-writable provider-home ancestor that is not sticky. Ubuntu's default umask is 0002, so ~/.local is 0775 and belongs to the user's private group. On the test host it is drwxrwxr-x ubuntu:ubuntu, and /etc/group has ubuntu:x:1000:, with no members. The default provider-home root, ~/.local/state/ultrafuzz/provider-homes, was therefore refused.

Without ULTRAFUZZ_PROVIDER_HOME_ROOT, this affects OpenRouterAgent and DeepSeekAgent always, because they have no canonical home. It also affects ClaudeAgent, CodexAgent and KimiAgent when they set config_dir. The campaign fails at its first node that uses one of them.

I reproduced it on main on this host: resolveProviderHome("openrouter", "probe") from main's template, run with the real HOME, throws provider-home ancestors cannot be group/world writable.

Change

provider-home.tsx

Each existing directory above a provider home is checked in this order. Each refusal names the directory, the problem and the fix.

  1. Owner (new). It must be owned by root or by the operator, because a directory's owner can change its permissions, and can rename entries even in a sticky directory. main accepted a 0755 ancestor owned by another account.
    provider-home ancestor <dir> is owned by another account; set ULTRAFUZZ_PROVIDER_HOME_ROOT to a directory outside it
  2. Sticky: accepted, as before.
  3. World-writable: refused (… is world writable without the sticky bit; remove that write access or set ULTRAFUZZ_PROVIDER_HOME_ROOT to a directory outside it).
  4. Group-writable: accepted only when all of the following hold. Anything these checks cannot show counts as shared.
    • The owner is process.getuid() and the group is process.getgid().
    • /etc/nsswitch.conf takes passwd, group and initgroups only from files, compat or systemd. Every line for those databases counts, because glibc versions differ on which one they use. A missing passwd or group line, an empty service list, or an unreadable file refuses. Ubuntu's default, files systemd, passes. compat reads the same files, and the NIS +/- entries it would add are already refused as unrecognized.
    • The operator's /etc/passwd entry has that group as its primary group, and no other account does. Every /etc/group entry with that GID lists only names that belong to the operator's UID and to no other UID. Membership goes by name, so a duplicate name counts as the other account's.
    • /bin/ls -ld -- <dir> shows that it has no ACL. The adapter runs /bin/ls --version and /bin/ls -ld -- <dir> with only LC_ALL=C in the environment.
      • GNU ls must show no mark or . (a security context alone). uutils ls 0.1.0 or later must show no mark.
      • + is refused: … is group writable, and ls marks it as having an ACL.
      • These are refused as … is group writable, and ls cannot show whether it has an ACL: any other ls, a non-zero exit, any stderr, or a listing that does not start with a mode string.

Why an ACL probe. With a POSIX ACL, the group bits of st_mode are the ACL mask, not the owning group's permission. The previous head read mode & 0o020 as "the owning group can write" and accepted a directory whose group was private. A named user or group entry could still grant another account write access. main refused every such grant, because an ACL write grant works only when the mask includes w, and the mask shows as g+w. Node and Bun have no xattr API. getfacl and getfattr are not installed by default, and are not on this host. ls is present on every host this check applies to.

The adapter runs /bin/ls by absolute path and does not check who owns the binary, unlike trustedGitExecutable. That function searches PATH, where a user-writable directory can come first. Here there is no search to hijack, and only root can replace /bin/ls. The environment is only LC_ALL=C, so TIME_STYLE, QUOTING_STYLE and similar settings cannot change the listing.

Why uutils as well, and why stricter. Ubuntu 26.04 LTS makes uutils coreutils 0.8.0 its default ls, so a GNU-only probe would leave this issue unfixed on the current Ubuntu LTS. I ran the real release binaries on this host against real ACLs, which I set with Python's os.setxattr:

ls no xattr access ACL default ACL user.* xattr only --version first line
GNU 9.4 (host) none + + none ls (GNU coreutils) 9.4
uutils 0.8.0 none + + + ls (uutils coreutils) 0.8.0
uutils 0.2.2, 0.1.0 none + + + ls (uutils coreutils) 0.2.2
uutils 0.0.30, 0.0.27 none + + + <argv0> 0.0.30
uutils 0.0.23 none none none none <argv0> 0.0.23

uutils has two quirks, both read from its source:

  • It prints . instead of + when a file has a security context (alt_access_indicator in src/uu/ls/src/display.rs), so . is refused under uutils.
  • Its has_acl counts any extended attribute, and treats a failed listxattr as having none.

The ls (uutils coreutils) prefix only matches 0.1.0 and later, and every such release has the marker, which first appeared in 0.0.24.

Boundary test

agent-adapter-boundaries.test.ts counts any array literal that contains a dash-prefixed string as agent argv construction, and a non-adapter helper may carry no such signal. So spawnSync(LS, ["-ld", "--", directory]) failed it.

A policy can now declare systemTools. An argument list is exempt when it is the second argument of spawn, spawnSync, execFile or execFileSync, and the command is a declared tool, named by a literal or a top-level const. provider-home.tsx declares /bin/ls. A new test pins the boundary: an undeclared tool, an argv array that is not passed to such a call, and a call through another function are still flagged. docs/reference/agent-adapter-boundaries.md documents the exemption. I did not rebuild the argument lists in a form the detector misses.

Template enforcement

Planning admits only the byte-exact packaged .smithers/agents closure, and since #1173 plain ultrafuzz init rewrites any stock adapter that differs from it. The closure digest is computed from the packaged templates at runtime (controller-source.ts), and #1173 removed the boundary test's source fingerprints. So no pinned digest or fingerprint needed updating.

Tests

  • test/process-umask.ts is byte-identical to fix(runtime)!: keep the Forge guard under group-writable umasks, warn about old cloud runs' Modal storage in clean, and drop the smithers shim #1235's (blob 5cd07bae, checked again against fix(runtime)!: keep the Forge guard under group-writable umasks, warn about old cloud runs' Modal storage in clean, and drop the smithers shim #1235's head 40c8855f).
  • test/provider-home-host.ts replaces account-files.ts.
    • It writes /etc/passwd, /etc/group and /etc/nsswitch.conf stand-ins for the test process's own UID and GID, plus a fake /bin/ls: a sh script that logs LC_ALL, HOME and its arguments.
    • It rewrites the adapter's four path literals and asserts that each occurs exactly once.
    • groupWritableAncestors lists the directories the check will probe, so no test assumes the temporary directory is not group writable.
  • provider-home.test.ts. Every test runs under umask 0002 with Ubuntu's layout (home 0750, ~/.local 0775) and the default root reached through HOME.
    1. Private group accepted. Tried with an empty member list and with only the operator's name. The fake ls was asked --version and -ld -- <dir> for exactly the group-writable ancestors, with LC_ALL=C and no HOME. Every component the adapter creates is 0700.
    2. Account files, 9 refused cases. Another member, and a second group entry with the same GID. In both, teammate now has its own passwd entry and primary group, so only the member list can share the group. Also: another account's primary group; a member name another UID also has (new); another primary group for the operator; no operator account; no group entry; an unrecognized entry; no group file. After each case the private files are restored, and the same directories are accepted again.
    3. nsswitch.conf. Accepted: compat, and files systemd with a comment, leading spaces, [SUCCESS=merge] and a spaced action. Refused, 12 cases: SSSD; LDAP groups; initgroups from winbind; a non-local line before or after a local one; GROUP: in capitals; no passwd line; no group line; no service; an unclosed action; no file.
    4. ls. Refused, 10 cases: GNU +; uutils +; uutils .; uutils 0.0.30's version line; a BusyBox version line; ?; exit 2; a diagnostic on stderr; total 0 as the listing; no ls. Accepted: uutils 0.8.0 and 1.0.0 with no mark, and GNU ..
    5. Real /bin/ls on the host. A fixture home passes when that ls is GNU's or uutils', and is refused with the cannot show message otherwise. Both branches assert, and neither skips.
    6. Process IDs stubbed. With process.getuid returning another UID, the first non-root ancestor is refused as owned by another account. With process.getgid returning another GID (as after newgrp), the group-writable ancestor is refused.
    7. World-writable (0777 and 0757) is refused, and 1777 is accepted.
  • The OpenRouter adapter contract test (Bun) runs under umask 0002 with the default root, as before, now with the fake ls injected. It asserts that the Bun-run adapter asked ls -ld about exactly the group-writable ancestors.

Verification

Every new behaviour test fails on origin/main. I put main's provider-home.tsx into this tree and rebuilt.

  • As written, all 8 provider-home tests fail. The 7 new tests stop at the literal check (must name /etc/passwd exactly once). The existing test fails on main's message.
  • With the literal assertion disabled in the compiled helper, all 8 fail on main's provider-home ancestors cannot be group/world writable, because main refuses the 0775 ~/.local.

The review's cases fail on the previous head, 71f7b3f5. I put its template into this tree.

  • The positive test fails because no ls was asked.
  • The account-files test fails at a member name another account also has.
  • The nsswitch test fails at SSSD accounts, the ls test at an ACL, and the process-ID test at the ownership refusal.

Mutation run. 32 mutants against provider-home.tsx, under umask 0002, with the provider-home tests. 30 are killed.

  • The reviewer's survivor is now killed: treating every passwd name as the operator's. So are dropping the ownership rule, dropping the getgid check, and every nsswitch, ls and account-file rule.
  • Two survive:
    • Dropping the in-branch owner !== getuid check. It only separates a root-owned group-writable directory, which a test cannot create without root.
    • Letting the mark regex capture ?. This is an equivalent mutant: any mark besides "", + (or . under GNU) already gives "cannot show".

The review's ACL reproductions, on this host. Real ACLs, the host's real /etc/passwd, /etc/group and /etc/nsswitch.conf, and HOME a fixture whose other ancestors are 0700:

Case main previous head this head (GNU ls) this head (uutils 0.8.0)
~/.local 0775, no ACL refused accepted accepted accepted
(a) ~/.local 0755 + u:nobody:rwx refused accepted refused (marks it as having an ACL) refused
(b) ~/.local 0755 + g:adm:rwx refused accepted refused refused
(c) d:u:nobody:rwx on HOME, then mkdir -p ~/.local/state under 0002 refused accepted refused refused
~/.local with only a user.* xattr refused accepted accepted refused

After chmod g-w, the mask in case (a) is r-x, and the directory is accepted.

Group-writable TMPDIR. With TMPDIR set to a 0775 operator-owned directory, the provider-home tests pass (8/8, under umask 0002 and 022), and so does the OpenRouter contract.

Probe cost. resolveProviderHome walks the root and then the full path, so Ubuntu's layout runs ls 8 times per call. That is 2.8 ms per call under Bun and 21 ms under Node.

Suites and gates

Risk

Closes #1236

🤖 Generated with Claude Code

RetriggerConfidence Score: 3/5

The PR should not merge until the ACL check handles GNU ls builds that cannot detect ACLs.

Fix All in Claude CodeFindings

  1. P1 Security Missing ACL mark permits access ▶
Fix with agent prompt
### Issue 1
packages/runtime/src/templates/smithers/agents/provider-home.tsx:200
If a Linux host's GNU `/bin/ls` was built without ACL support, it prints no `+` even when an ACL lets another account write to a group-writable ancestor. This code treats the missing mark as proof that there is no ACL and accepts the ancestor, allowing that account to modify the provider-home path. The GNU version string does not establish whether ACL detection is available.

**How this was verified:** The acceptance path trusts an unmarked GNU `ls` listing, while a GNU build without ACL support cannot mark an ACL that grants another account access.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR permits provider homes beneath group-writable private-group ancestors while adding ownership, name-service, and ACL checks and expanding the tests and documentation.

  • The ACL check cannot distinguish an ACL-free directory from a GNU ls build that lacks ACL support.

Reviews (2) · Last reviewed commit: "fix(runtime): refuse a provider-home anc..."

@aviggiano
aviggiano requested a review from a team as a code owner September 30, 2026 20:01
Comment thread packages/runtime/src/templates/smithers/agents/provider-home.tsx Outdated
aviggiano and others added 2 commits September 30, 2026 21:36
…rator's private group

Under Ubuntu's default umask 0002, ~/.local is 0775 and belongs to the
user's private group. assertSafeDirectory refused every group-writable,
non-sticky ancestor, so the default provider-home root
~/.local/state/ultrafuzz/provider-homes was refused, and OpenRouterAgent,
DeepSeekAgent and any agent with a config_dir could not start.

A group-writable ancestor is now accepted when the operator owns it, its
group is the operator's primary group, /etc/group lists no member of that
group other than the operator (under any /etc/passwd name with the
operator's UID), and no other /etc/passwd account has it as its primary
group. The /etc/passwd half matters: primary-group members are not listed
in /etc/group, so a shared primary group such as `users` has an empty
member list there. Anything the files cannot show fails closed: an
unreadable file, an unrecognized entry, or an operator account or group
missing from them (LDAP). World-writable ancestors without the sticky bit
stay refused. Both refusals now name the directory and the remedy.

Tests pin umask 0002 (test/process-umask.ts, the same file as #1235) and
point the adapter at test-written account files by rewriting its
/etc/passwd and /etc/group literals. They cover the accepted private
group, eight shared or unprovable cases, world-writable ancestors, and the
OpenRouter adapter contract with the default root.

Closes #1236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… account source or another owner

Review of the private-group check found that it read a directory's group
permission bits as the owning group's. With a POSIX ACL they are the ACL
mask, so a named user or group entry could grant another account write
access to an accepted ~/.local, which main refused.

A group-writable ancestor is now accepted only when /bin/ls -ld, run with
LC_ALL=C and no other environment, shows that it has no ACL: GNU ls with
no mark or only ".", or uutils ls 0.1.0 or later, which Ubuntu 26.04
uses, with no mark at all, since uutils marks any extended attribute
with "+" and prints "." in its place under a security context. A "+",
another ls, a failed or unparseable listing, or any diagnostic refuses
it. Node and Bun cannot read ACLs, and getfacl is not installed by
default.

The check also fails closed when /etc/nsswitch.conf takes passwd, group
or initgroups from anything but files, compat or systemd, since LDAP,
SSSD and similar sources can share the group with accounts the files do
not list. A member name that another UID also has counts as that
account's. Every ancestor must now be owned by root or the operator,
since a directory's owner can change its permissions.

The boundary test lets a helper's policy declare the system tools it
runs, so provider-home.tsx's ls argument lists are not counted as agent
argv. The tests use a fake ls and injected account and nsswitch files,
pin the owner and primary-group conditions by stubbing the process IDs,
and no longer assume the temporary directory is not group writable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aviggiano
aviggiano force-pushed the claude/provider-home-private-group branch from 71f7b3f to 8b19c67 Compare September 30, 2026 22:10
@aviggiano
aviggiano marked this pull request as draft September 30, 2026 22:13
@aviggiano

Copy link
Copy Markdown
Collaborator Author

Holding this in draft. It fixes #1236 correctly, but by loosening the ancestor check, which takes about 150 lines of account-file, NSS and ACL probing. A simpler root fix is being built instead: the default provider-home root moves to a directory Ultrafuzz creates itself, mode 0700, directly under $HOME, and the ancestor check stays exactly as strict as on main. This PR closes once that replacement is green; its security analysis, including the ACL bypass and the primary-group gap in /etc/group, is why the check is being left strict rather than relaxed.

mark = /^d[-rwxsStT]{9}([.+]?) /u.exec(listing.stdout)?.[1];
if (!gnu && !version.stdout.startsWith("ls (uutils coreutils) ")) return undefined;
if (mark === "+") return true;
return mark === "" || (gnu && mark === ".") ? false : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Missing ACL mark permits access

If a Linux host's GNU /bin/ls was built without ACL support, it prints no + even when an ACL lets another account write to a group-writable ancestor. This code treats the missing mark as proof that there is no ACL and accepts the ancestor, allowing that account to modify the provider-home path. The GNU version string does not establish whether ACL detection is available.

How this was verified: The acceptance path trusts an unmarked GNU ls listing, while a GNU build without ACL support cannot mark an ACL that grants another account access.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/templates/smithers/agents/provider-home.tsx
Line: 200

Comment:
**Missing ACL mark permits access**

If a Linux host's GNU `/bin/ls` was built without ACL support, it prints no `+` even when an ACL lets another account write to a group-writable ancestor. This code treats the missing mark as proof that there is no ACL and accepts the ancestor, allowing that account to modify the provider-home path. The GNU version string does not establish whether ACL detection is available.

**How this was verified:** The acceptance path trusts an unmarked GNU `ls` listing, while a GNU build without ACL support cannot mark an ACL that grants another account access.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@aviggiano

Copy link
Copy Markdown
Collaborator Author

Superseded by #1242, which fixes #1236 by moving the default provider-home root to ~/.ultrafuzz-provider-homes, a direct child of $HOME that Ultrafuzz creates with mode 0700. The ancestor check stays exactly as strict as before, so the account-file, NSS and ACL probing this PR needed is unnecessary. Its security analysis, including the ACL bypass and the primary-group gap in /etc/group, is what showed that relaxing the check safely would take a lot of code.

@aviggiano aviggiano closed this Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider homes are refused on Ubuntu's default umask because ~/.local is group-writable

1 participant