fix(fsmonitor): policy-check symlink leaf ops on the link, fall through to target eval#313
Conversation
|
Thanks, this looks like a real issue to me, especially for ordinary Python venv usage. I agree with fixing the leaf-symlink behavior for operations like For the second part, I think we should avoid keeping the escaped-symlink target behavior as an unconditional hard-deny. My preference would be to make this controlled by a Something along these lines would work: policy:
symlink_escape: evaluate # evaluate | denySo: enable target policy evaluation by default, but give deployments a clear flag to restore the blanket deny if they want it. |
556f721 to
87f24d8
Compare
…gh to target eval (knob-gated) Two related issues with how the FUSE policy layer handles symlinks in the workspace, both triggered by ordinary Python venv usage (`python -m venv venv` -- the most common thing an agent does with Python). 1. Leaf-symlink subject mismatch. check() always called resolveRealPathUnderRoot with mustExist=true, which dereferenced the leaf symlink before policy lookup. For ops whose subject is the path itself (stat, readlink, delete, rmdir), this re-routed the check to the symlink's target. So `ls venv/bin` (lstat on each entry) and `rm -rf venv` (unlink each entry) denied on the `venv/bin/python -> /usr/bin/python3` link with EACCES, because the policy has no rule for /usr/bin/**. Fix: extend the mustExist=false set in check() to include stat, readlink, delete, and rmdir. The resolver then only walks the parent directory and leaves the leaf symlink alone. unlink/rmdir remove the symlink; the target is untouched, so checking the target was the wrong subject. Always-on, not gated -- it was a straightforward bug. 2. Workspace-escape blanket deny on system-symlink targets, now controlled by policies.symlink_escape. When the resolved target of a symlink falls outside the workspace root, checkWithExist previously returned a hardcoded "workspace-escape" deny. That made it impossible to express "allow read via /usr/bin/python3" in policy -- every venv read/exec was an automatic deny regardless of file_rules. New behavior (default): on resolveRealPathUnderRoot failure, call a new helper evalEscapedSymlink(realRoot, virtPath, virtualRoot) that fully resolves through symlinks even when the result lies outside the workspace. If it returns a resolvable real path, evaluate the policy on that path. Operators who want to block system symlinks express that via a regular file_rule deny on /usr/bin/** etc. Strict opt-in: policies.symlink_escape: "deny" restores the historical workspace-escape blanket deny. Per maintainer review, the default is "evaluate" (venv-friendly) and "deny" is a one-line config flip for deployments that prefer the stricter posture. "..":-style escapes and unresolvable links remain a hard deny in both modes since there is no useful real path to evaluate.
87f24d8 to
cb558f1
Compare
|
@erans this should do it. |
|
Checked the follow-up fix. The config knob is mostly wired correctly: Two things still need fixing before merge:
Verification on |
|
@es-fabricemarie gentle ping — just wanted to make sure my May 15 review didn't get lost. Two items still pending before merge:
Everything else looks good — the leaf-symlink fix and the |
Two related issues with how the FUSE policy layer handles symlinks in the workspace, both triggered by ordinary Python venv usage (
python -m venv venv-- the most common thing an agent does with Python):Leaf-symlink subject mismatch.
check() always called resolveRealPathUnderRoot with mustExist=true, which dereferenced the leaf symlink before policy lookup. For ops whose subject is the path itself (stat, readlink, delete, rmdir), this re-routed the check to the symlink's target. So
ls venv/bin(lstat on each entry) andrm -rf venv(unlink each entry) denied on thevenv/bin/python -> /usr/bin/python3link with EACCES, because the policy has no rule for /usr/bin/**.Fix: extend the mustExist=false set in check() to include stat, readlink, delete, and rmdir. The resolver then only walks the parent directory and leaves the leaf symlink alone. unlink/rmdir remove the symlink; the target is untouched, so checking the target was the wrong subject.
Workspace-escape blanket deny on system-symlink targets.
When the resolved target of a symlink falls outside the workspace root, checkWithExist returned a hardcoded "workspace-escape" deny. That made it impossible to express "allow read via /usr/bin/python3" in policy -- every venv read/exec was an automatic deny regardless of file_rules.
Fix: on resolveRealPathUnderRoot failure, call a new helper evalEscapedSymlink(realRoot, virtPath, virtualRoot) that fully resolves through symlinks even when the result lies outside the workspace. If it returns a resolvable real path, evaluate the policy on that path. Operators who want to block system symlinks can still do so via a regular file_rule deny on /usr/bin/** etc. "..":-style escapes and unresolvable links remain a hard deny since there is no useful real path to evaluate.
Tests:
Note: part 2 is a policy-semantics change. If maintainers prefer the fallthrough gated behind a config flag (e.g. policy.symlink_escape: "evaluate" | "deny"), happy to add that -- but the current default (evaluate, with operators expressing deny via regular file_rules) seems closer to the principle of least surprise: agentsh shouldn't have hardcoded path policy that operators can't override.