paths-within
A gate that refuses a call whose path operands leave a set of directories known only at run time. A glob can list paths to deny, but not "anywhere except the session shadow", because the shadow's location arrives in the environment. paths-within reads the environment when the call happens, so the guardfile states the rule and the launcher supplies the roots.
wrap rm {
exec rm
replace
can run "*" {
gate paths-within {
operands "positional"
root-parent-env "AOS_NATIVE_SESSION_PROJECTS"
root-env "AOS_FS_GUARD_ROOTS"
root "/private/tmp/claude-{uid}"
reason "work on a copy inside the shadow, or hand the operation to Kai"
}
}
}
Options
operands(required) - how to read the paths out of argv.positionalis every word that is not a flag,--aware, forrm.positional-and-targetadds the value of-tand--target-directoryformv, in each spelling a getopt parser accepts (-t DIR,-tDIR,-vtDIR,--target-directory=DIR, a unique prefix such as--target=DIR) and skips the value of-S/--suffix.findjudges a call only when it carries-delete,-exec,-execdir,-ok,-okdiror an-fprint,-fprint0,-fprintfor-flspredicate. It then checks every start path (after any leading-H,-L,-P,-Dand-O),.when there is none, and each file an-fprintfamily predicate names. Afindthat only reads passes anywhere.root-env- a variable holding a:-separated list of allowed directories. Repeatable.root-parent-env- a variable holding one directory, whose parent is allowed. A shadow holdsprojects/,home/andseat/side by side, and the variable names onlyprojects/. Repeatable.root- a fixed directory, counted only when it exists.{uid}expands to the numeric uid. Repeatable.reason- appended to the refusal, so the recovery rides with the message.
At least one of root-env and root-parent-env is required, and anything unknown fails the build. A gate that no variable could switch on would never fire.
What it does
- Inert while every named variable is empty. One binary can sit on a PATH shared with callers that never set them, such as CI jobs in the same image. A fixed
rootdoes not switch it on. - A root of
/or the home directory is dropped, so a wrongly set variable cannot turn "inside" into "anywhere". When every root is dropped, nothing is inside and every operand is refused. - A path is resolved the way
rmandmvact on it. Symlinks in the parent are followed and the leaf is left alone, because those tools act on a link rather than its target. A trailing slash follows the link. A relative path resolves against the working directory, and a path that does not exist yet resolves as far as it does. - Refusal is
policy_denied, exit 2, audited asreject, and reaches no binary. The refusal names the offending operand.
What it does not do
- It reads argv, and argv is the only thing it sees. Shell redirection,
cp,tee,dd of=,rsync --delete,git clean, and an interpreter callingunlinknever pass through it. Thefindarguments after-execrun a command the gate cannot read. - It is a replacement's gate, so it is UX over an enforcement floor umbra does not own.
/bin/rmwalks around a shim, as the replacement doc says. - Negative controls do not cover it.
umbra controlsinvokesnever,cannotandwithholdrules, and a gate's answer depends on the environment it runs in, so the tests beside the gate carry that proof.