apply-base-claude-settings converges the session home when run inside a native shadow, so a hand run silently no-ops on the host #1248

Closed
opened 2026-08-26 01:47:17 +00:00 by coilyco-ops · 2 comments
Owner

What

scripts/apply-base-claude-settings.py resolves its target as SETTINGS_PATH = Path.home() / ".claude" / "settings.json". Under a native session shadow, $HOME is the per-session home, so the script converges that instead of the host config.

Observed on a sy55 native session:

$ just apply-base-claude-settings
wrote   /tmp/aos/native/sy55/home/.claude/settings.json (base settings: permissions.deny, permissions.allow)

The session home is a real file there, not a symlink onto /Users/kai/.claude/settings.json, so nothing reached the host. The host file kept the stale rule the run was meant to prune, and the command reported success.

Why it matters

The ansible path is fine, because the claude-hooks role runs as the host user with the real home. The trap is the hand run, which is exactly what an operator or agent reaches for after landing a change to BASE_DENIED_PERMISSIONS or RETIRED_DENIED_PERMISSIONS. They get a success line naming a path they are unlikely to read closely, a converged throwaway home, and an unconverged host.

It also writes the fleet permissions.allow wildcard into the session home, which widens the running session rather than the host it was aimed at.

Fix directions

Any of these, or a combination:

  • Resolve the host home explicitly when AOS_NATIVE_SESSION is set, rather than trusting $HOME. The session already exports the marker, so the script can detect the case it is in.
  • Refuse to run inside a shadow without an explicit opt-in flag, and say which home the caller probably meant.
  • Take an explicit --home or --settings path, so the justfile verb and the ansible role each name their target instead of inheriting it.

Refusing by default is the safer shape, because the failure mode here is a silent success rather than an error.

Surfaced converging the change from #1244, which added the prune pass this run was supposed to apply.

## What `scripts/apply-base-claude-settings.py` resolves its target as `SETTINGS_PATH = Path.home() / ".claude" / "settings.json"`. Under a native session shadow, `$HOME` is the per-session home, so the script converges that instead of the host config. Observed on a `sy55` native session: ``` $ just apply-base-claude-settings wrote /tmp/aos/native/sy55/home/.claude/settings.json (base settings: permissions.deny, permissions.allow) ``` The session home is a real file there, not a symlink onto `/Users/kai/.claude/settings.json`, so nothing reached the host. The host file kept the stale rule the run was meant to prune, and the command reported success. ## Why it matters The ansible path is fine, because the `claude-hooks` role runs as the host user with the real home. The trap is the hand run, which is exactly what an operator or agent reaches for after landing a change to `BASE_DENIED_PERMISSIONS` or `RETIRED_DENIED_PERMISSIONS`. They get a success line naming a path they are unlikely to read closely, a converged throwaway home, and an unconverged host. It also writes the fleet `permissions.allow` wildcard into the session home, which widens the running session rather than the host it was aimed at. ## Fix directions Any of these, or a combination: - Resolve the host home explicitly when `AOS_NATIVE_SESSION` is set, rather than trusting `$HOME`. The session already exports the marker, so the script can detect the case it is in. - Refuse to run inside a shadow without an explicit opt-in flag, and say which home the caller probably meant. - Take an explicit `--home` or `--settings` path, so the justfile verb and the ansible role each name their target instead of inheriting it. Refusing by default is the safer shape, because the failure mode here is a silent success rather than an error. ## Related Surfaced converging the change from https://forgejo.coilysiren.me/coilyco-flight-deck/agentic-os/pulls/1244, which added the prune pass this run was supposed to apply.
Author
Owner

Correcting my own diagnosis above before the fix lands.

The report blamed Path.home() resolving to the session home. That is true and it is not the cause. A staged shadow home makes every entry under .claude a symlink back to the host file, so the session settings.json already pointed at /Users/kai/.claude/settings.json and the run should have converged the host.

The cause is os.replace(tmp, path) at the end of write_settings. It swaps the link itself instead of writing through it, so the run severed the symlink, dropped a real file in the throwaway session home, and printed success while the host kept the settings the run was meant to change. Confirmed against the pre-fix function: the link is gone and the host file still reads its original content.

That also retires the fix directions I proposed. Shadow detection and a refuse-by-default flag would have papered over a writer that violates the staging contract docs/native-shadow.md already states, and would have blocked a hand run that write-through makes correct.

scripts/install-session-name.py has the identical bug against the same file, which the report missed entirely.

Fix in #1250

Correcting my own diagnosis above before the fix lands. The report blamed `Path.home()` resolving to the session home. That is true and it is not the cause. A staged shadow home makes every entry under `.claude` a symlink back to the host file, so the session `settings.json` already pointed at `/Users/kai/.claude/settings.json` and the run should have converged the host. The cause is `os.replace(tmp, path)` at the end of `write_settings`. It swaps the link itself instead of writing through it, so the run severed the symlink, dropped a real file in the throwaway session home, and printed success while the host kept the settings the run was meant to change. Confirmed against the pre-fix function: the link is gone and the host file still reads its original content. That also retires the fix directions I proposed. Shadow detection and a refuse-by-default flag would have papered over a writer that violates the staging contract `docs/native-shadow.md` already states, and would have blocked a hand run that write-through makes correct. `scripts/install-session-name.py` has the identical bug against the same file, which the report missed entirely. Fix in https://forgejo.coilysiren.me/coilyco-flight-deck/agentic-os/pulls/1250
Author
Owner

Landed on main via #1250. Both writers resolve the path before the atomic write, four tests pin it, and the suite is at 723 passed. Closing.

Landed on main via https://forgejo.coilysiren.me/coilyco-flight-deck/agentic-os/pulls/1250. Both writers resolve the path before the atomic write, four tests pin it, and the suite is at 723 passed. Closing.
Sign in to join this conversation.
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
coilyco-flight-deck/agentic-os#1248
No description provided.