Found while evaluating an alternative fix shape on #385; deliberately not fixed there, since it lives in worktree_flow.py rather than the shield.
The defect
provision_worktree builds the shield's patterns by interpolating paths straight into gitignore syntax (src/bmad_loop/worktree_flow.py:295-301):
patterns = {f"/{p.skill_tree}" for p in profiles}
patterns |= {f"/{p.hooks.config_path}" for p in profiles if not p.hookless}
patterns |= {f"/{rel}" for rel in seeded}
rel comes from operator-configured seed_files / seed_globs, so it is an arbitrary legal filename. gitignore patterns are globs, and nothing escapes *, ? or [...] — so a seeded file whose name contains one produces a pattern that does not match it.
Measured (git 2.55.0)
A seeded file literally named foo[1].json, shielded with the pattern this code would generate:
$ printf '/foo[1].json\n' > .git/info/exclude
$ git status --porcelain -uall
?? foo[1].json <- the seeded file is NOT shielded
$ git check-ignore -v --no-index 'foo[1].json'
rc=1 <- not ignored
$ git check-ignore -v --no-index 'foo1.json'
.git/info/exclude:1:/foo[1].json foo1.json <- an UNRELATED file is
rc=0 hidden instead
Harm
Both directions, and both silent:
- the provisioned tool file stays stageable by the unit's
git add -A — the harm the shield exists to prevent, with reason is None because nothing failed;
- an unrelated path matching the accidental glob is excluded inside the worktree.
Same family as #384 round 17 (a shield that reports success and does not shield), and the return value cannot bite here either — it would need a test asserting through git status --porcelain -uall.
Shape of a fix
Escape gitignore metacharacters when rendering a path as a pattern (gitignore supports \ escaping, so foo\[1\].json matches the literal name — worth confirming at the 2.20 floor as well as current). skill_tree and hooks.config_path come from profile TOML and are equally uncontrolled.
Note the existing guard nearby is for a different failure: hookless profiles are excluded because an empty config_path would render as /, excluding the whole worktree. That is the empty case; this is the metacharacter case.
Found while evaluating an alternative fix shape on #385; deliberately not fixed there, since it lives in
worktree_flow.pyrather than the shield.The defect
provision_worktreebuilds the shield's patterns by interpolating paths straight into gitignore syntax (src/bmad_loop/worktree_flow.py:295-301):relcomes from operator-configuredseed_files/seed_globs, so it is an arbitrary legal filename. gitignore patterns are globs, and nothing escapes*,?or[...]— so a seeded file whose name contains one produces a pattern that does not match it.Measured (git 2.55.0)
A seeded file literally named
foo[1].json, shielded with the pattern this code would generate:Harm
Both directions, and both silent:
git add -A— the harm the shield exists to prevent, withreason is Nonebecause nothing failed;Same family as #384 round 17 (a shield that reports success and does not shield), and the return value cannot bite here either — it would need a test asserting through
git status --porcelain -uall.Shape of a fix
Escape gitignore metacharacters when rendering a path as a pattern (gitignore supports
\escaping, sofoo\[1\].jsonmatches the literal name — worth confirming at the 2.20 floor as well as current).skill_treeandhooks.config_pathcome from profile TOML and are equally uncontrolled.Note the existing guard nearby is for a different failure:
hooklessprofiles are excluded because an emptyconfig_pathwould render as/, excluding the whole worktree. That is the empty case; this is the metacharacter case.