fix(git-sync): keep container-only .claude/settings.json out by content, not name (trinity-enterprise#708) - #3019
Conversation
…nt, not name (trinity-enterprise#708) The #2036 file-level ignore outlived its reason (ent#345 stopped baking the file) and silently dropped every template's project settings - the marketplace add-git-sync hooks vanished from deployed agents until the skill learned to negate it. - .claude/settings.json leaves the canonical ignore list (and the 14 bundled templates' copies); the agent guide says it is committable. - every platform commit path guards by content after staging: if the index copy registers /opt/trinity/ hook paths, restore a clean HEAD copy or untrack it (heals pre-#2036 commits); the working-tree file is never touched. Agent server: heartbeat + Push; backend: a shell twin spliced into initialize_git_in_container. Covers the legacy copies startup.sh's exact-match removal leaves on long-lived volumes. - heartbeat commits only when something is STAGED (Push's rule), so a guarded-out untracked file never turns a cycle into an empty commit. - ruling recorded: the platform heartbeat owns durability on deployed agents; marketplace hooks are for local sessions (follow-up filed on the marketplace side). Related to Abilityai/trinity-enterprise#708 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
merge-train: ejected from this train — rides the next one, rebased on a
Minor: |
|
✅ Nightly unit-suite clean when this PR is merged into |
|
Resolve by merging |
…efusal helpers beside the settings guard, guard stays after staging The only git.py conflict was two independent helper blocks added at the same spot (#3016's _is_missing_remote_ref/_is_shared_source_branch/_rebase_onto_remote and this PR's _guard_container_only_settings/_has_staged_changes); both kept. _run_auto_sync_once auto-merged: dev's refusal-before-commit and fetch/rebase/lease-push are intact, with the guard between `git add -A` and the staged-only commit check. Docs keep both sides (git-sync-health 1b then 1c; agent-lifecycle carries #3010/#3011 text plus the ent#708 note). registry.json rebuilt from the index stages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (trinity-enterprise#708) reset_to_main_preserve_state_impl (reset route + MCP reset_to_main_preserve_state) ran `git add -A` -> commit -> force-push with no content guard, so a .claude/settings.json registering /opt/trinity/ hook paths landed in HEAD and on the remote there — including when the persistent-state allowlist overlaid a harmful copy over a clean baseline. It now runs _guard_container_only_settings between staging and commit, like the heartbeat and Push. Audit of every platform stage+commit path: heartbeat _run_auto_sync_once, sync_to_github, reset_to_main_preserve_state_impl (agent server) and both `git add .` steps in initialize_git_in_container (backend) — all guarded now. The provisioning write probe commits --allow-empty in a scratch repo and is out of scope. Tests drive the real reset impl against a real bare remote (untracked harmful file; allowlist-preserved harmful copy over a clean committed one) plus an AST add < guard < commit check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (trinity-enterprise#708)
HOME is the repo root, so .claude/settings.json is also Claude Code's USER
settings file. With the file-level ignore gone, a credential written there
would be committed and pushed. The content guard now refuses, the same way it
refuses /opt/trinity/ paths (restore an acceptable HEAD copy or untrack; the
working-tree file untouched), a settings file that:
- carries a non-empty top-level env, apiKeyHelper, awsAuthRefresh,
awsCredentialExport, gcpAuthRefresh or otelHeadersHelper — the keys the
Claude Code settings reference documents as holding a credential or naming
the command that produces one; or
- is not a JSON object, so it cannot be checked (fail closed).
The log line names the reason and the keys, never a value. The platform-written
plugin config (extraKnownMarketplaces, enabledPlugins) still commits.
One rule: the agent server's _settings_refusal_reason is the predicate; the
backend shell twin (initialize_git_in_container) runs the same predicate via the
container's python3 with the marker and keys as argv (no quotes/$/backslash, so
it survives the bash -c "..." splice and docker-py's shlex split), exiting
non-zero = keep out, so a missing python3 also fails closed. Key tuples are
parity-tested; the shared test matrix now runs the shell twin through shlex
exactly as production does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train (2026-09-27): three commits pushed, both follow-ups from my 09-25 note agreed with the operator.
Behaviour change to know about: any non-empty |
…ored (trinity-enterprise#708) The file left the canonical ignore list in this PR; the retirement rationale now says so instead of listing it as still injected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed The guard on |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Moving from a name-based ignore to a content guard is the right call, and the guard's own matrix is solid. I checked it against real repos, though, and it deletes a template's committed settings.json from the remote whenever that file has a harmless env block. It also doesn't reach any agent that already exists, because the old ignore line survives the .gitignore merge. Both need fixing before this lands.
-
[blocking]
docker/base-image/agent_server/routers/git.py:829-840: a committed, harmlesssettings.jsongets deleted from the remote by an unattended cycle.
When the index copy is refused and the HEAD copy is refused too, the guard runsgit rm --cached, and that commits a deletion.envis refused whenever it is non-empty, and the/opt/trinity/check is a plain substring match. So this fires on ordinary template content:"env": {"BASH_DEFAULT_TIMEOUT_MS": "600000"},DISABLE_TELEMETRY,MCP_TIMEOUT, or apermissions.denyentry likeRead(/opt/trinity/**).
Repro: I committed and pushed a template-stylesettings.jsonwith thatenvblock, made no other change, and ran_run_auto_sync_once. It made a new commit,Trinity auto-sync: …, whose only change is.claude/settings.json | 11 -----------, and pushed it. Thedeny-mention variant does the same. The same branch runs inreset_to_main_preserve_state_impl, which force-pushes, and in the shell twin at initialize, where it becomes the "Initial commit". So a template's project settings, which this PR exists to preserve, get removed from the agent's branch with only a container log line to show for it. vybe's 09-27 note says such a file is "held back". It is actually deleted from a remote that already had it.
Fix: only untrack when the refused content is new. If the index copy equals HEAD, leave it alone, because nothing leaks that isn't already in history. Keep "untrack a HEAD copy" for the/opt/trinity/hook-path case, which is the pre-#2036 heal this PR wants. Add a test with a committed harmlessenvplus an idle heartbeat that asserts no new commit. -
[should-fix]
src/backend/services/git_service/gitignore.py:229(_GITIGNORE_SUPERSEDED_LINES): removing the pattern is a no-op for every existing agent.
The merge strips only lines that are currently managed..claude/settings.jsonis no longer in that list, so on an agent whose.gitignorecame from the old list, the next merge moves the line into the user region instead of dropping it. I checked this against real repos: I ran the base merge and then the PR's merge on the same repo. Afterwardsgit check-ignore -vreports.gitignore:45:.claude/settings.json, sitting between the default block and the protected floor as if it were the user's own rule. That covers every agent created or pushed since #2069. Their template hooks stay dropped. The "expect one commit per auto-syncing agent after upgrade" warning and the §1c text don't describe what will actually happen. The file already documents this migration step for the retired.trinity/line and for marker strings (~line 311).
Adding the line to_GITIGNORE_SUPERSEDED_LINESis not free, though. The merge runs from the backend viadocker execon Push, start and creation, including on containers still running a pre-PR base image. Those have no guard, so the old Push or heartbeat would then commit a legacy/opt/trinity/copy or a credential. Pick one of two options and say so in the PR: supersede the line only for agents on a base image that has the guard, or accept that existing agents keep the ignore and correct the PR description andgit-sync-health.md§1c. -
[should-fix]
docker/base-image/agent_server/routers/git.py:818-819: a non-UTF-8settings.jsonstops all syncing, and the two copies of the guard disagree about it.
_blobcallsrun_registeredwith strict decoding, sogit showof a non-UTF-8 blob raisesUnicodeDecodeErrorbefore_settings_refusal_reasonever runs. Repro:{"note":"caf\xe9"}plus an unrelatedw.md. The heartbeat returnsfailed 'utf-8' codec can't decode byte 0xe9…on every cycle, so nothing else syncs until someone fixes the file, and Push fails the same way. The backend's shell version handles the same bytes by refusing the file (python exits non-zero), which is the fail-closed behaviour the comment promises. Fix: passerrors="replace"(the #2957 precedent) or decode bytes yourself, so the file falls into the existing "not valid JSON" refusal. Add that case to the shared matrix. -
[nit]
docker/base-image/agent_server/routers/git.py:841: an untracked file that gets refused is re-added bygit add -Aand refused again on every 15-minute cycle, so the same WARNING is logged forever. Nothing records it insync-state.json, so an operator never sees that the file isn't being synced. Worth a sync-state field, or log once per content hash.
What I checked
- Read the full diff and the prior merge-train and author comments. The reset-path guard, the credential keys and the
spec.pydocstring from earlier rounds are all in. There is no other stage-and-commit path inagent_serverorstartup.sh. The provisioning write probe is--allow-emptyin a scratch repo. - Ran 13 related suites (ent708, 2036, 2529, 1908, github_init_gitignore, auto_sync, ent345, 2069 ×2, 2742, the reset_preserve_state pair, 1966) with pytest-randomly: seed 12345 gave 358 passed and 1 skipped, seed 99999 gave 359 passed. The local venv is Python 3.14, not CI's 3.13.
- Tried three mutations. Using
k in datainstead ofdata.get(k), going back to the oldstatus.stdout.strip()check, and restoring any HEAD copy were each caught (1, 1 and 3 tests failed). - Findings 1–3 are reproduced on real temporary repos with a bare remote, driving the real
_run_auto_sync_onceand the real.gitignoremerge builder from both trees. CI onc0c60573bis green.
…ttings (trinity-enterprise#708) Review of #3019: - A refused index copy that equals HEAD is left alone: nothing new leaks, and untracking committed a deletion of a template's settings (harmless `env`, a deny rule naming /opt/trinity/). A different refused copy keeps the HEAD copy; untrack only when there is no HEAD copy or HEAD registers an /opt/trinity/ hook (the pre-#2036 heal). - /opt/trinity/ is matched inside the `hooks` subtree only, not anywhere in the file. - `git show` reads with surrogateescape, so a non-UTF-8 file is refused instead of failing every cycle; the shell twin decodes strictly too. - The refusal WARNING is logged once per content, not every cycle. - The .gitignore merge drops the pre-ent#708 `.claude/settings.json` line, but only inside a container whose agent server carries the guard (probe: grep /app/agent_server/routers/git.py); pre-guard images keep it. - git-sync-health.md §1c rewritten to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed the 09-28 review in
The Verification: |
Resolve tests/registry.json: dev's file plus this branch's ent#708 entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
Approving.
Checked locally on 60093e52 (py3.13): the related suites (ent708, 2036, 2529, 1908, 2069 x2, github_init_gitignore, auto-sync, reset_preserve_state x2, ent345, 3010, 3011) pass 358/358 on seeds 12345 and 99999.
Mutation check on each fix from the 09-28 review: hooks-only marker (3 red), shell-twin keep-HEAD (10 red), non-UTF-8 read (2 red), guard-gated .gitignore strip (1 red), log-once (1 red). The head == staged early return survives mutation because the fallback git reset on an identical index is a no-op; only the log line and return value differ. Asserting None there would pin it, optional.
Heartbeat, Push and reset all commit only on staged changes, so a guarded-out file never produces an empty commit.
After merge, ent#708 needs status-in-dev set by hand (cross-tracker ref).
All four points from this review are addressed in 3d79be1 (equal-to-HEAD copy left alone and hooks-only marker, guard-gated removal of the old ignore line, non-UTF-8 file refused instead of failing sync, log once per content). Re-verified with tests and mutations and approved; dismissing to unblock merge.
Summary
Implements the ruling on abilityai/trinity-enterprise#708, recorded on the issue:
add-git-synchooks are for local sessions. The marketplace half is filed as Abilityai/trinity-skills#1: the skill detects the platform and stands down, and drops its negation step..claude/settings.jsonignore is narrowed to its actual damage. The file leaves the canonical ignore list, removed as well from the agent guide's fence and the 14 bundled templates' copies. What bug: base image bakes container-only hook paths into ~/.claude/settings.json, git sync commits it, external clones of the agent repo are bricked #2036 fixed was one content: absolute/opt/trinity/hook paths, which brick any clone made outside the container. Every platform commit path now runs a guard after staging. If the index copy is refused (an/opt/trinity/hook command, a credential-bearing key, or content that is not UTF-8 JSON), the guard leaves it alone when it equals HEAD (already in history; untracking would only commit a deletion), keeps the HEAD copy when HEAD has no/opt/trinity/hook, and untracks the file only when there is no HEAD copy or HEAD is a pre-bug: base image bakes container-only hook paths into ~/.claude/settings.json, git sync commits it, external clones of the agent repo are bricked #2036 hook-path leak, which is then deleted from the remote on the next commit. The working-tree file is never touched._run_auto_sync_onceagent_server/routers/git.py::_guard_container_only_settingssync_to_githubinitialize_git_in_container(backend,docker execshell)gitignore.CONTAINER_ONLY_SETTINGS_GUARD, a shell twin of the same rule_has_staged_changes, the same rule Push already used). A guarded-out file stays untracked on disk, and the oldgit status --porcelaintest would otherwise have turned every later cycle into an empty-commit failure.startup.shremoves the legacy copy only on an exact byte match with the managed file, so older variants survive on long-lived volumes. The content guard covers them wherever they are..claude/settings.jsonin their.gitignore. The.gitignoremerge (Push, start, creation) drops it only inside a container whose agent server has the guard (it greps/app/agent_server/routers/git.pyfor_guard_container_only_settings). A container still on a pre-guard base image keeps the ignore until it is recreated on the new image, because its heartbeat and Push would commit an unchecked file.~/.claude/settings.json(extraKnownMarketplaces: abilityai,enabledPlugins: trinity@abilityai); user and project settings are the same file becauseHOMEis the repo root (refactor: agent git repo root is $HOME — 22 gitignore patterns exist only to compensate, and every new home-writing feature repeats the dance #1703). After the line is dropped, an auto-syncing agent commits its current settings once, unless the guard refuses them. The platform content is portable and byte-stable across restarts, so there is no churn. Accepted by the operator.Changes
src/backend/services/git_service/gitignore.py: pattern removed with the ent#345/ent#708 history;CONTAINER_ONLY_SETTINGS_GUARDaddedsrc/backend/services/git_service/provisioning.py: guard spliced after bothgit add .steps in initializedocker/base-image/agent_server/routers/git.py:_guard_container_only_settingsand_has_staged_changes; wired into the heartbeat and Pushconfig/agent-templates/*/.gitignore(14): the line removed so they stay a subset of the canonical list (the bug(templates): sage/scout/scribe ship no .gitignore — every agent created from them is born with 4 hard security findings #1908 guard)TRINITY_COMPATIBLE_AGENT_GUIDE.mdfence,git-sync-health.md§1c (the ruling and the guard),architecture/agent-lifecycle.md,requirements/github.mdtests/unit/test_ent708_settings_json_guard.py(19, new): the same matrix on real repos for both the Python guard and the shell twin (clean file commits; a harmful untracked file stays out and on disk; a harmful edit of a clean tracked file restores the clean copy; a harmful copy already in HEAD is untracked; fresh repo; no-op). It also runs the heartbeat end to end: harmful content never reaches the remote and the next cycle makes no empty commit; a clean file syncs; a legacy committed copy is deleted from the remote. AST wiring checks cover add < guard < commit in both agent paths and the guard after both initialize adds.test_2036_claude_settings_leak.py: rewritten to the new contract; a clean projectsettings.jsonnow stages without any negation.Test Plan
:latestuntouched), with a real repo in its home.content/b.mdpushed, remote kept the clean settings copy, local harmful file untouched, and the next cycle made no commit.sync_to_github:Synced to main,files_changed: 1, remote settings still clean.kept .claude/settings.json out of the commit (restored).lint_sys_modules,lint_root_test_placement, enterprise-docs-guard pattern cleanRelated to abilityai/trinity-enterprise#708 (cross-repo: this doesn't auto-close; close it at release)
🤖 Generated with Claude Code