Enforce Agent Skills name validation on every sync path, not only --validate - #222
sebastientaggart wants to merge 10 commits into
Conversation
| "(see agentskills.io):\n") | ||
| for e in name_errors: | ||
| print(e) | ||
| sys.exit(1) |
There was a problem hiding this comment.
Low severity — --validate loses its aggregate reporting.
The --validate block below deliberately accumulates failures (failed = True for placeholder, permission, and command-shape checks) so one run surfaces everything wrong. Hoisting the name check above it with a hard sys.exit(1) breaks that for --validate specifically: a single name error now short-circuits before validate_placeholders, validate_permissions, and validate_command_shapes ever run.
Scenario: a contributor renames a skill directory but forgets the frontmatter name, and the same skill also references an undefined {{PLACEHOLDER}}. make check reports only the name error; they fix it, re-run, and only then discover the placeholder error — two round trips where the pre-existing behavior reported both at once.
The write/--dry-run hard stop is correct and should stay; only the --validate path needs to fall through:
| sys.exit(1) | |
| if name_errors and not args.validate: | |
| print("Skill-name validation failed — frontmatter not spec-compliant " | |
| "(see agentskills.io):\n") | |
| for e in name_errors: | |
| print(e) | |
| sys.exit(1) |
(then in the --validate block, report name_errors and set failed = True instead of the unconditional "passed" print at line 838, which currently prints "passed" only because a failure could never reach it).
Code Cannon reviewScope: The core change is sound. The gate sits after skill-group and Sensitive-area gate: not triggered — no authentication, payments, secrets handling, production configuration, or destructive operations in this diff. Findings[WARNING] [NOTE] Verdict: APPROVE |
| # report, and short-circuiting here would hide the placeholder, permission, | ||
| # and command-shape results behind a name error, costing a round trip per | ||
| # class of problem. The block below accumulates instead. | ||
| name_errors = validate_skill_names(skill_files) |
There was a problem hiding this comment.
Medium — output_path_override entries are now hard-blocked by a gate they are documented to be exempt from.
sync_skill's own docstring (sync.py:446-448) says a skill declaring output_path_override "is not a skill in the spec sense — it renders as a bare file with no frontmatter": neither name nor description reaches the output, and the output path comes from the override, not from <name>/SKILL.md. But validate_skill_names has no exemption for those entries, and this PR promotes it from a --validate report into a gate that aborts the whole run.
Scenario: add a prompt-only entry alongside review-agent — say skills/github-agile/pr-summary-prompt/SKILL.md with output_path_override: .github/prompts/summary.md and name: pr-summary (or no description, since neither is emitted). ./sync.py now exits 1 before writing anything, so every skill stops syncing over a field that is never used for that entry's output. review-agent only passes today by coincidence — it happens to carry a matching name and a description.
Suggest skipping entries with output_path_override in validate_skill_names (or asserting only the fields that actually render for them).
| # and command-shape results behind a name error, costing a round trip per | ||
| # class of problem. The block below accumulates instead. | ||
| name_errors = validate_skill_names(skill_files) | ||
| if name_errors and not args.validate: |
There was a problem hiding this comment.
Low — the gate is scoped to the enabled group, so CI still cannot catch a bad name in a non-enabled skill group.
validate_skill_names(skill_files) runs over the post---skill, post-skill_group list. A few lines below, the permission check deliberately widens for exactly this reason:
When sync.py runs from inside the CodeCannon repo itself ... validate permissions across every skill group so a gap in a non-enabled group can't ship unnoticed.
The name check gets no equivalent widening. Scenario: a second group is added under skills/ (say skills/foo/) containing a skill whose frontmatter name doesn't match its directory. CI runs ./sync.py --validate + --dry-run from the CodeCannon repo with skill_group: github-agile, so skill_files never includes it — the violation ships green, and only breaks later in a consumer repo that enables that group. docs/adapters.md in this PR states enforcement happens "on every sync path", which reads stronger than what is implemented.
Mirroring the perm_skill_files widening (skills_dir.glob('*/*/SKILL.md') when CODECANNON_DIR == project_root) for the name check under --validate would close this.
| failed = False | ||
| if name_errors: | ||
| print("Skill-name validation failed — frontmatter not spec-compliant " | ||
| "(see agentskills.io):\n") |
There was a problem hiding this comment.
Low — the failure header and error-printing loop are now duplicated verbatim two blocks apart.
The same four lines (header string + for e in name_errors: print(e)) appear at sync.py:834-837 and again here. They will drift: a future edit to the wording, the doc link, or the formatting in one copy leaves the other stale, and the two are reachable on mutually exclusive paths so no test would catch the divergence — test_name_directory_mismatch_blocks_dry_run asserts on the first copy, test_validate_reports_all_checks_despite_name_error on the second.
Lifting it into a small local (e.g. def _report_name_errors(): ...) or restructuring so one block handles both paths keeps them in sync.
Also cosmetic: the --validate name block prints with no leading \n while the permission and command-shape blocks below still use "\nPermission validation...", so the name section runs flush against the placeholder section while the later ones are spaced.
Code Cannon review — round 2 (
|
…edupe error reporting
| for skill_path in skill_files: | ||
| fm, _ = parse_frontmatter(skill_path.read_text()) | ||
| if fm.get('output_path_override'): | ||
| continue |
There was a problem hiding this comment.
Low — The exemption keys off the raw frontmatter value, but sync_skill decides the same question after placeholder substitution (output_path_override = apply_placeholders(...) then if output_path_override:). The two disagree when the override is a placeholder that resolves to empty.
Concrete: review-agent/SKILL.md declares output_path_override: "{{REVIEW_AGENT_PROMPT}}". A consumer that explicitly sets REVIEW_AGENT_PROMPT: "" in .codecannon.yaml (to disable the prompt file) makes the substituted value falsy, so sync_skill falls through to the skill-directory branch and writes <output_dir>/<frontmatter name>/SKILL.md with built frontmatter — for an entry the gate deliberately skipped. With the exact frontmatter the new test uses (name: Some_Other_Name), that writes .claude/skills/Some_Other_Name/SKILL.md: precisely the spec-violating skill directory this gate exists to prevent. Today it is harmless only because review-agent's name happens to match its directory.
Cheapest fix: exempt only entries whose override is still truthy after substitution, or validate name/description whenever sync_skill would take the frontmatter branch.
| # can't ship unnoticed; a consumer repo only has the enabled group. The | ||
| # --skill filter deliberately does not narrow this — syncing one skill must | ||
| # not weaken enforcement over the rest. | ||
| if CODECANNON_DIR == project_root: |
There was a problem hiding this comment.
Low — This equality compares an unresolved path against a resolved one: CODECANNON_DIR = Path(__file__).parent (sync.py:27) is only abspath-normalized, while project_root = Path.cwd() comes from os.getcwd(), which returns the fully symlink-resolved path. Any symlink in the checkout path makes the comparison false.
Concrete: a checkout reachable as /Users/me/dev -> /Volumes/Code/dev (or CI that checks out under a symlinked workspace). Running ./sync.py --validate from the repo root takes the else branch, so audit_skill_files silently narrows to the enabled group only — no error, exit 0 — and the guarantee this PR adds to docs/adapters.md ("the check covers every skill group rather than just the enabled one, so a violation in a group nobody has turned on still fails CI") quietly does not hold. The same silent narrowing applies to the permission and command-shape checks that now share audit_skill_files.
The new tests already work around exactly this — _fake_checkout has to call .resolve() with a comment explaining that os.chdir() yields a resolved cwd. The fix is one word at line 27: Path(__file__).resolve().parent.
(Pre-existing, but this PR moves the branch and now leans a documented CI guarantee on it.)
Code Cannon review — round 3 (
|
…bstituted override
| fm, _ = parse_frontmatter(skill_path.read_text()) | ||
| override = fm.get('output_path_override', '') | ||
| if override and project_config is not None: | ||
| override = apply_placeholders(override, project_config) |
There was a problem hiding this comment.
Low — an unresolved placeholder override silently keeps the exemption, and still writes a junk path.
The exemption tests truthiness of the substituted override. apply_placeholders leaves an undefined token intact, so an override like {{FOO}} where FOO is in neither .codecannon.yaml nor config.schema.yaml stays "{{FOO}}" — truthy — and the entry is exempted. sync_skill then takes the same branch and writes to project_root / "{{FOO}}", literally creating a {{FOO}} directory in the consumer repo. That is exactly the class of "output under a directory that shouldn't exist" this gate was added to prevent, and the new resolution logic is the natural place to catch it: treat an override still containing {{...}} after substitution as a failure (or at minimum surface it the way sync_skill's unresolved-placeholder warning does). Today it's shielded by the schema default for REVIEW_AGENT_PROMPT, so the risk is only for a future prompt-only entry whose placeholder has no default.
| override = apply_placeholders(override, project_config) | ||
| if override: | ||
| continue | ||
| dir_name = skill_path.parent.name |
There was a problem hiding this comment.
Low — error messages are ambiguous now that the audit spans every group.
dir_name is skill_path.parent.name, so every error reads <dir>/SKILL.md: .... That was unambiguous when the check only ever saw one group's skills. With the new in-repo widening to skills/*/*/SKILL.md, two groups can each contain a start/SKILL.md, and a violation in skills/othergroup/start/ prints start/SKILL.md: name 'x' must match its directory name — pointing the maintainer at the wrong file, in a group they may not even have enabled. Include the group (e.g. skill_path.relative_to(skills_dir) or f"{skill_path.parent.parent.name}/{dir_name}"). validate_permissions has the same shape and would benefit from the same fix.
|
|
||
| Code Cannon skills follow the [Agent Skills](https://agentskills.io) open standard: each skill is a `<skill-name>/SKILL.md` folder with YAML frontmatter (`name`, `description`). Adapters no longer translate between per-tool formats — they just decide which standard skills directories to render into. | ||
|
|
||
| Spec compliance is enforced on every sync path, not just under `--validate`: if a skill's frontmatter `name` is missing, violates the spec, or doesn't match its directory name — or its `description` is missing — `sync.py` stops with a non-zero exit before writing anything. This keeps a rename on one side of the pair from silently shipping mismatched output to consumer repos. Entries declaring `output_path_override` are exempt, since they render as bare files with no frontmatter and so aren't skills in the spec sense. Run from inside the Code Cannon repo itself, the check covers every skill group rather than just the enabled one, so a violation in a group nobody has turned on still fails CI. |
There was a problem hiding this comment.
Low — the doc states the exemption unconditionally, but the code deliberately makes it conditional.
"Entries declaring output_path_override are exempt" is the rule a reader will act on, yet validate_skill_names exempts only entries whose substituted override is non-empty — the behavior test_override_resolving_to_empty_is_not_exempt pins. A consumer who sets REVIEW_AGENT_PROMPT: to an empty value reads this paragraph, expects review-agent to stay exempt, and instead gets a hard sync failure if its frontmatter isn't spec-compliant. Add the qualifier, e.g. "…are exempt, as long as the override resolves to a non-empty path; one whose placeholder resolves to empty renders as an ordinary skill and is held to the spec."
Code Cannon review — round 4 (
|
| # report, and short-circuiting here would hide the placeholder, permission, | ||
| # and command-shape results behind a name error, costing a round trip per | ||
| # class of problem. The block below accumulates instead. | ||
| name_errors = validate_skill_names(audit_skill_files, project_config) |
There was a problem hiding this comment.
The in-repo audit widens validate_skill_names to every skill group, but resolves each group's output_path_override against the enabled group's project_config. validate_placeholders was deliberately scoped to skill_files (enabled group only) for exactly this reason — a non-enabled group's placeholders are not guaranteed to be defined in this project's config.
Scenario: add skills/other-group/foo/SKILL.md with output_path_override: "{{OTHER_PATH}}", where OTHER_PATH has no config.schema.yaml default and is not in .codecannon.yaml. Running plain ./sync.py inside the CodeCannon repo now exits 1 with output_path_override has undefined placeholder(s): OTHER_PATH — blocking all syncs over a group nobody enabled and whose override is never rendered. Name/description checks are fine to widen; the placeholder-resolution half is not.
One fix: only resolve/report the override placeholder for files in the enabled group, and fall back to the raw-value exemption for out-of-group files.
| override = fm.get('output_path_override', '') | ||
| if override and project_config is not None: | ||
| override = apply_placeholders(override, project_config) | ||
| unresolved = sorted(set(find_unresolved(override))) |
There was a problem hiding this comment.
The new undefined-placeholder branch is only as complete as find_unresolved, whose regex is r"\{\{([A-Z_]+)\}\}" — no digits. So a placeholder name containing a digit escapes detection entirely.
Scenario: a skill declares output_path_override: "{{PROMPT_PATH2}}" and PROMPT_PATH2 is undefined. apply_placeholders leaves the token intact, find_unresolved returns [], the truthy raw string falls through to if override: continue, and the entry is treated as exempt. sync_skill then takes the same branch and writes to project_root / "{{PROMPT_PATH2}}" — a literal {{PROMPT_PATH2}} file in the consumer repo, which is exactly the failure this branch was added to prevent.
The regex predates this PR, but this is the first place its completeness is load-bearing for a correctness gate. Widening it to [A-Z0-9_]+ closes the hole.
Code Cannon review — round 5 (
|
| override as an error, so a key this misses (e.g. `{{PROMPT_PATH2}}`) would | ||
| slip through as a literal output directory. | ||
| """ | ||
| return re.findall(r'\{\{([A-Z0-9_]+)\}\}', text) |
There was a problem hiding this comment.
find_unresolved now accepts digits ([A-Z0-9_]+), but _IF_OPEN (sync.py:264) still matches [A-Z_]+. A key like PROMPT_PATH2 is therefore usable as a plain {{PROMPT_PATH2}} placeholder but not in a conditional: {{#if PROMPT_PATH2}} is not recognized as a directive, so apply_conditionals leaves the literal {{#if PROMPT_PATH2}} / {{/if}} lines in the body, and find_unresolved does not match them either (the #if prefix breaks the pattern) — so --validate stays green and the directive lines ship verbatim into the generated SKILL.md. If digit-bearing keys are now considered legal, widen _IF_OPEN to the same character class.
| if token not in allowed and token not in seen: | ||
| seen.add(token) | ||
| errors.append(f" {skill_path.parent.name}/{skill_path.name}: command '{token}' not in permissions.yaml") | ||
| errors.append(f" {skill_label(skill_path)}: command '{token}' not in permissions.yaml") |
There was a problem hiding this comment.
This PR moves validate_permissions and validate_command_shapes onto the new group-qualified skill_label(), but validate_placeholders (sync.py:616) was left on skill_path.name, which is always the literal string SKILL.md. Concretely: run ./sync.py --validate with an undefined placeholder and every error line reads SKILL.md: {{FOO}} not defined in config — with 11 skills in the group there is no way to tell which file to fix. Switching that f-string to skill_label(skill_path) makes it consistent with the rest of the report.
| name_errors = validate_skill_names( | ||
| [f for f in audit_skill_files if f in enabled_group], project_config) | ||
| name_errors += validate_skill_names( | ||
| [f for f in audit_skill_files if f not in enabled_group]) |
There was a problem hiding this comment.
Non-enabled groups are validated with project_config=None, so in validate_skill_names the if override and project_config is not None branch is skipped and any entry with an output_path_override is exempted unconditionally — including one holding an undefined placeholder. Scenario: a second skill group ships output_path_override: "{{OTHER_PATH}}" with no schema default; in-repo CI (--validate + --dry-run) passes green, and the failure only appears when a consumer sets skill_group to that group, at which point sync_skill writes the file into a literal {{OTHER_PATH}} directory. This is the one violation class the widened audit does not cover, so the docs/adapters.md line "a violation in a group nobody has turned on still fails CI" overstates it. A cheap fix is to check the raw override for find_unresolved hits in the non-enabled pass too (undefined-in-any-config is a bug regardless of which group is enabled), or to soften the doc sentence.
Code Cannon review — round 6 (
|
| [f for f in audit_skill_files if f in enabled_group], project_config) | ||
| name_errors += validate_skill_names( | ||
| [f for f in audit_skill_files if f not in enabled_group]) | ||
| NAME_FAILURE_HEADER = ("Skill-name validation failed — frontmatter not " |
There was a problem hiding this comment.
Low — Undefined output_path_override placeholders are reported under the wrong header.
validate_skill_names appends output_path_override has undefined placeholder(s): ... into the same name_errors list that is printed under NAME_FAILURE_HEADER ("frontmatter not spec-compliant (see agentskills.io)"). But that error is a config problem, not a spec problem — the fix is to define the key under config: in .codecannon.yaml, not to edit frontmatter.
Scenario: a consumer enables a skill group containing an entry with output_path_override: "{{PROMPT_PATH}}" and no PROMPT_PATH in their config (and no schema default). ./sync.py exits 1 with a header pointing them at agentskills.io and telling them their frontmatter violates the Agent Skills spec, while the actual remedy is a missing config key. That is the same class of error validate_placeholders already reports under "Placeholder validation failed — undefined placeholders", so the two now disagree on how to describe it.
Consider collecting override-placeholder errors into a separate list reported under the placeholder header (or its own), rather than folding them into the name gate.
| [f for f in audit_skill_files if f not in enabled_group]) | ||
| NAME_FAILURE_HEADER = ("Skill-name validation failed — frontmatter not " | ||
| "spec-compliant (see agentskills.io):") | ||
| if name_errors and not args.validate: |
There was a problem hiding this comment.
Low — The write path is blocked by violations in groups it will never write.
When run in-repo, audit_skill_files spans every group under skills/, and name_errors from non-enabled groups reach this hard exit on the write path (plain ./sync.py, --force, --dry-run), not just under --validate. The justification in the comment above — "a frontmatter-name/directory mismatch makes sync_skill write output under a directory the spec says shouldn't exist" — only holds for the enabled group. A non-enabled group is never passed to sync_skill, so nothing bad can be written on its account.
Scenario: a Code Cannon developer starts skills/experimental/ with a WIP skill whose frontmatter name does not yet match its directory. ./sync.py --force — which the pre-commit hook requires before every commit — now exits 1 for the enabled github-agile group, blocking all commits until the unrelated WIP group is fixed, even though not one byte of it would have been written. Note the asymmetry: the permission and command-shape audits, which are also group-wide, only fail under --validate.
Since CI already runs ./sync.py --validate (which keeps the widened scope), scoping the write-path exit to enabled_group errors and leaving cross-group enforcement to --validate would preserve the CI guarantee without the local friction.
…gate to the enabled group
| override as an error, so a key this misses (e.g. `{{PROMPT_PATH2}}`) would | ||
| slip through as a literal output directory. | ||
| """ | ||
| return re.findall(r'\{\{([A-Z0-9_]+)\}\}', text) |
There was a problem hiding this comment.
LOW — Widening find_unresolved to [A-Z0-9_]+ also makes it match all-digit tokens such as {{1}} or {{0}}, which the previous [A-Z_]+ charset could not match.
validate_placeholders feeds this result straight into missing = [p for p in find_unresolved(...) if p not in project_config], so any skill body that documents a numeric double-brace template — a regex backreference example like {{1}}, a Handlebars/Vue snippet, a positional-substitution illustration in a fenced block — is now reported as an undefined placeholder and hard-fails ./sync.py --validate, which CI runs. Nothing in the tree trips it today, so this is latent rather than broken, but it is a false-positive class the old charset was immune to.
The digit support that motivated the change is only needed for keys like PROMPT_PATH2, which always start with a letter.
| return re.findall(r'\{\{([A-Z0-9_]+)\}\}', text) | |
| return re.findall(r"\{\{([A-Z][A-Z0-9_]*)\}\}", text) |
| print("Placeholder validation failed — undefined placeholders:\n") | ||
| for e in errors: | ||
| print(e) | ||
| name_errors += validate_skill_names(other_files) |
There was a problem hiding this comment.
LOW — Two enforcement holes in the non-enabled-group audit that the success messages hide.
-
validate_skill_names(other_files)is called withoutproject_config, so the exemption is decided on the raw override. A non-enabled group skill withname: Bad_Nameandoutput_path_override: "{{X}}"is silently exempted from both the name and description checks. Whoever later enables that group withXset to""gets the spec violation at their own sync — the very escape this PR set out to close, except it now passes CodeCannon's own CI green first. -
validate_output_pathsis only ever run onenabled_files, never onother_files, yet line 959 prints"Output-path validation passed — all override placeholders are defined."unconditionally. In-repo--validatetherefore claims a pass over a set it did not check. An undefined override placeholder in a non-enabled group surfaces only when someone enables it.
Both follow from the deliberate config-scoping decision (config resolves against the enabled group only), so the fix is probably not to widen the checks but to stop claiming the wider guarantee — e.g. qualify the two pass messages with the scope actually audited when other_files is non-empty.
| # Key charset must stay in step with find_unresolved: a key one matches and the | ||
| # other doesn't is neither expanded as a directive nor reported as unresolved, | ||
| # so the literal line ships into generated output. | ||
| _IF_OPEN = re.compile(r'^\s*\{\{#if\s+(!?)([A-Z][A-Z0-9_]*)\}\}\s*$') |
There was a problem hiding this comment.
The new comment claims _IF_OPEN and find_unresolved staying in step is what keeps an unmatched directive from shipping literally, but find_unresolved can never report a directive line: its pattern is \{\{([A-Z][A-Z0-9_]*)\}\}, and {{#if FOO}} / {{/if}} contain #, / and a space, so they never match regardless of charset. There is no safety net here.
That matters because this hunk narrows the charset — [A-Z_]+ accepted a leading underscore, [A-Z][A-Z0-9_]* does not. Concrete failure: a skill containing {{#if _INTERNAL}}...{{/if}} (or any key _IF_OPEN misses). apply_conditionals finds the first {{/if}}, scans backwards for an open, finds none, and breaks out of the whole while changed loop — so every conditional block in that file is left unprocessed. The literal {{#if}}/{{/if}} lines ship into generated output, and the body of blocks that should have been stripped (e.g. {{#if !BRANCH_DEV}}) leaks into the synced skill. Exit code 0, no warning.
No current skill uses such a key, so nothing is broken today. But if the charsets are meant to be load-bearing, apply_conditionals should report a {{/if}} with no matching open as an error instead of silently breaking — otherwise the comment overstates the guarantee.
| break | ||
| if open_idx is None: | ||
| break # malformed — stop processing | ||
| # Malformed: this {{/if}} has no matching open. Stopping here leaves |
There was a problem hiding this comment.
The mirror case is still silent. This PR adds a warning for a {{/if}} with no matching open, but the opposite malformation — a {{#if KEY}} with no {{/if}} at all — hits the close_idx is None: break at line 285 and returns the text unchanged with no warning and no error.
Verified:
>>> sync.apply_conditionals("{{#if FOO}}\nbody", {"FOO": "yes"})
"{{#if FOO}}\nbody"
>>> sync.find_unresolved("{{#if FOO}}")
[]
So a skill that loses its closing directive in an edit ships the literal {{#if FOO}} line straight into every generated SKILL.md, and (as the new comment on _IF_OPEN notes) find_unresolved cannot see it either, so --validate reports nothing. Given the PR's stated goal of not failing silently at exit 0, this branch deserves the same warning as line 297.
| # every remaining conditional in the file unprocessed, so literal | ||
| # directive lines and blocks that should have been stripped ship | ||
| # into the output — say so rather than failing silently at exit 0. | ||
| print(" Warning: {{/if}} with no matching {{#if}} — conditional " |
There was a problem hiding this comment.
Two problems with this warning as written.
1. It cannot be traced to a skill. apply_conditionals has no access to skill_path, and it is called once per skill per adapter (plus twice more per skill during --validate, once for the body and once for the description). On a repo-wide sync that is one anonymous line among dozens of ✓ lines, with no way to tell which of the ~10 skills is malformed. Passing a label into apply_conditionals (or returning the condition and having sync_skill print it) would make it actionable.
2. It does not affect the exit code, so CI stays green. The scenario: someone drops a closing directive, ./sync.py --force regenerates every adapter output with the literal {{#if …}} lines in it, and both files get committed. In CI, --validate never sees it (find_unresolved cannot match a {{#if X}} line, per the new comment at line 264) and --dry-run compares the generated output against the equally-broken committed output, so it reports no drift. Exit 0 on both, broken directives shipped to consumer repos.
That is the same class of hole the rest of this PR closes for skill names. Collecting the condition and failing --validate on it would close it.
|
|
||
|
|
||
| def validate_output_paths(skill_files, project_config): | ||
| """Check that every `output_path_override` resolves to a usable path. |
There was a problem hiding this comment.
The docstring says this checks the override "resolves to a usable path", but it only checks for leftover placeholders. Two shapes still get through and both escape project_root:
- Absolute path.
sync_skilldoesout_path = project_root / output_path_override, and Python discards the left operand when the right is absolute:Path("/proj") / "/etc/x"→/etc/x. A consumer who writesREVIEW_AGENT_PROMPT: /Users/me/.claude/prompt.mdin.codecannon.yamlgets a file written outside their repo, silently, with a✓line. - Parent traversal.
Path("/proj") / "../../etc/x"→/proj/../../etc/x, whichmkdir(parents=True)+write_textwill happily create outside the checkout.
Since this validator now runs on the write path and hard-fails the sync, it is the natural place to reject both — e.g. flag Path(resolved).is_absolute() and any resolved path that is not under project_root. That would need project_root threaded in, but it turns a silent out-of-repo write into a named error.
What changed
validate_skill_names()previously ran only inside the--validatebranch ofmain(). The write path and--dry-runnever called it, so a skill whose frontmatternameno longer matched its directory (or that violated the Agent Skills spec, or was missing adescription) would sync silently —sync_skillwrites output underfm['name'], and CI's./sync.py --dry-runpassed. Mismatched output could ship to every consumer repo until someone manually ran--validate.The check is now a hard gate in
main(), placed right after the skill list resolves and before the--validatebranch, adapter resolution, or any write. Every path — write,--dry-run,--validate— fails with exit 1 and a spec-compliance message before producing output.sync.py— added the shared gate; removed the now-duplicated name-validation block from the--validatebranch, which keeps only its success line..github/workflows/sync-check.yml— added./sync.py --validateahead of the existing--dry-run, so CI explicitly covers the placeholder, permission, and command-shape checks too rather than relying on the implicit gate for names alone.docs/adapters.md— documents that spec compliance is enforced on every sync path.tests/test_sync.py— three newTestMainCLIcases plus a_fake_checkouthelper that builds a throwaway CodeCannon checkout (symlinking the realadapters/,config.schema.yaml, andpermissions.yaml) around a single synthetic skill.Behaviour note: the gate is a hard failure, not a per-skill skip. Sync stops entirely rather than silently omitting the offending skill, which is the intended reading of "run it as part of the normal sync path".
Test plan
make test— 176 tests, all passing.make check— all four validations pass.validate_skill_namesstubbed to return[](simulating pre-fix behaviour), both fail; the compliant-skill test passes either way, as the no-regression guard.--dry-runtest asserts on the failure message and the absence of "would write" rather than on the exit code alone, since--dry-runalready exits 1 on pending writes and would otherwise pass without the fix.Closes #221