Skip to content

Shield patterns are not escaped, so a seeded filename with gitignore metacharacters shields the wrong set #401

Description

@pbean

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions