fix(hooks): resolve guard paths as the kernel does #175

Merged
claudiomaradonna merged 3 commits from fix/guard-resolve into main 2026-10-29 21:16:22 +01:00
Collaborator

Why

After #169, the main-checkout guard still let an agent write into a main
checkout on its main branch in several ways, by mistake or following
injected text:

  • through a symlink into the checkout followed by .., such as
    out/lnk/../README.md with out/lnk -> repo/docs, since the guard
    entered directories with a logical cd that drops the .. before
    following the link, while the kernel follows it first;
  • through a directory the guard cannot enter, where the failed cd
    ended the guard with status 1, which Claude Code lets through;
  • by editing a symlink, or a chain of them, that points to a file in the
    checkout, since the guard never resolved a path's last name, including
    a link reached past a missing directory or named with a trailing
    newline, which command substitution cut.

Checking only as the kernel resolves (cd -P) was set aside: a symlink
inside the checkout pointing out, followed by .., would then pass,
though a client that normalizes the path as text writes into the
checkout, and the guard refuses that case today.

What changed

The guard checks each edited path both as the kernel resolves it and as
text, refusing when either lands in a guarded checkout, and follows the
last name link by link, up to 40 as the kernel does, checking each
target. A path it cannot resolve is refused, with a message naming it.
Paths keep their trailing newlines throughout. Every path the guard
passed before that lands in no guarded checkout either way still passes.

How to verify

In a scratch directory, make a repository on main and run the guard
with an edit through a symlink to its README:

git init -q -b main repo && echo x > repo/README.md
git -C repo add . && git -C repo commit -q -m init
mkdir out && ln -s "$PWD/repo/README.md" out/lnk
jq -n --arg p "$PWD/out/lnk" '{tool_name:"Edit",cwd:"/",tool_input:{file_path:$p}}' |
  <checkout>/.agents/hooks/main-checkout-guard.sh; echo "exit $?"

The branch prints the refusal and exit 2; main prints exit 0. With
mkdir repo/docs; ln -s "$PWD/repo/docs" out/into and the path
$PWD/out/into/../README.md, the same pair shows for the .. case.

The source's Maintenance checks pass on the tip:
bash tests/verify-agent-config.sh (328 passed),
bash tests/check-work-folder.sh, .agents/hooks/shellcheck.sh repo,
prettier --check '**/*.md', scripts/verify-agent-config.sh, and
git diff --check main. In tests/agent-config/main-checkout-guard.sh
the six new cases fail on main, and the pin for the text case passes on
both. How OpenCode resolves a written path was not checked, as no
install was at hand; Claude Code trims the path before hooks see it.

Considerations

The case for a directory the guard cannot enter prints a skip line
where the user can enter a mode 0600 directory, as root does.

## Why After #169, the main-checkout guard still let an agent write into a main checkout on its main branch in several ways, by mistake or following injected text: - through a symlink into the checkout followed by `..`, such as `out/lnk/../README.md` with `out/lnk -> repo/docs`, since the guard entered directories with a logical `cd` that drops the `..` before following the link, while the kernel follows it first; - through a directory the guard cannot enter, where the failed `cd` ended the guard with status 1, which Claude Code lets through; - by editing a symlink, or a chain of them, that points to a file in the checkout, since the guard never resolved a path's last name, including a link reached past a missing directory or named with a trailing newline, which command substitution cut. Checking only as the kernel resolves (`cd -P`) was set aside: a symlink inside the checkout pointing out, followed by `..`, would then pass, though a client that normalizes the path as text writes into the checkout, and the guard refuses that case today. ## What changed The guard checks each edited path both as the kernel resolves it and as text, refusing when either lands in a guarded checkout, and follows the last name link by link, up to 40 as the kernel does, checking each target. A path it cannot resolve is refused, with a message naming it. Paths keep their trailing newlines throughout. Every path the guard passed before that lands in no guarded checkout either way still passes. ## How to verify In a scratch directory, make a repository on `main` and run the guard with an edit through a symlink to its README: ```bash git init -q -b main repo && echo x > repo/README.md git -C repo add . && git -C repo commit -q -m init mkdir out && ln -s "$PWD/repo/README.md" out/lnk jq -n --arg p "$PWD/out/lnk" '{tool_name:"Edit",cwd:"/",tool_input:{file_path:$p}}' | <checkout>/.agents/hooks/main-checkout-guard.sh; echo "exit $?" ``` The branch prints the refusal and `exit 2`; main prints `exit 0`. With `mkdir repo/docs; ln -s "$PWD/repo/docs" out/into` and the path `$PWD/out/into/../README.md`, the same pair shows for the `..` case. The source's Maintenance checks pass on the tip: `bash tests/verify-agent-config.sh` (328 passed), `bash tests/check-work-folder.sh`, `.agents/hooks/shellcheck.sh repo`, `prettier --check '**/*.md'`, `scripts/verify-agent-config.sh`, and `git diff --check main`. In `tests/agent-config/main-checkout-guard.sh` the six new cases fail on main, and the pin for the text case passes on both. How OpenCode resolves a written path was not checked, as no install was at hand; Claude Code trims the path before hooks see it. ## Considerations The case for a directory the guard cannot enter prints a skip line where the user can enter a mode 0600 directory, as root does.
The guard's walk entered the existing directory with a logical cd,
which drops a .. before following the symlink it follows, so a link
into a main checkout followed by .. was checked outside it; and a cd
that failed ended the guard with status 1, which Claude Code lets
through. Enter with cd -P and refuse when it fails, and check each
path normalized as text too, since a client may write either.
The guard resolved only a path's directories, so an edit of a symlink
outside a main checkout that points to a file in it passed, while the
write went through the link into the checkout. Check each link's
target too, from the path the guard resolved, link by link with
readlink, which Linux and macOS share, and refuse after 40 links, as
the kernel stops. Keep the trailing newlines of the edited path and of
each link's target, which command substitution cut, so the guard checks
the name the client writes.
claudiomaradonna deleted branch fix/guard-resolve 2026-10-29 21:16:25 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
claudiomaradonna/agent-foundry!175
No description provided.