Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/sync-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ jobs:
- uses: actions/checkout@v5
- name: Check for sync drift
run: |
./sync.py --dry-run
# placeholder, permission, and command-shape checks, reported together;
# skill names are gated on every path, including the --dry-run below
./sync.py --validate
# exits non-zero if any files would be written
./sync.py --dry-run
2 changes: 2 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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 — but only when the override resolves to a real path. If its placeholder is set to an empty value the entry renders as an ordinary skill and is held to the spec like any other; if the placeholder is undefined entirely, that is reported as its own error rather than exempted. The write path enforces this over the group being synced — those are the skills that would produce bad output. Run from inside the Code Cannon repo itself, `--validate` widens the audit to every skill group, so a name or description violation in a group nobody has turned on still fails CI, which runs `--validate`. A work-in-progress skill in an unrelated group therefore doesn't block a local sync. Override placeholders resolve against the enabled group's config, so a non-enabled group's `output_path_override` is checked only for its raw value; an undefined placeholder in one surfaces when that group is enabled.

## Supported adapters

| Adapter | Output | Read natively by |
Expand Down
218 changes: 175 additions & 43 deletions sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
import subprocess
from pathlib import Path

CODECANNON_DIR = Path(__file__).parent
# Resolved so it can be compared against Path.cwd(), which is always symlink-
# resolved. An unresolved value here makes the in-repo check below silently
# false whenever the checkout path contains a symlink.
CODECANNON_DIR = Path(__file__).resolve().parent
MARKER = "generated by CodeCannon/sync.py"
LEGACY_MARKERS = [
"generated by CodeCannon/sync.sh",
Expand Down Expand Up @@ -258,7 +261,12 @@ def parse_frontmatter(text):
# The directive lines are always removed from the output.
# Nesting is supported (inner blocks are evaluated innermost-first).

_IF_OPEN = re.compile(r'^\s*\{\{#if\s+(!?)([A-Z_]+)\}\}\s*$')
# Key charset matches find_unresolved's, but that is for consistency only — it
# is not a safety net: find_unresolved's pattern cannot match a `{{#if X}}` line
# (the `#` and the space), so a key this misses is not reported as unresolved
# either. It silently fails to open a block, which strands the matching
# `{{/if}}` and stops conditional processing for the whole file.
_IF_OPEN = re.compile(r'^\s*\{\{#if\s+(!?)([A-Z_][A-Z0-9_]*)\}\}\s*$')
_IF_CLOSE = re.compile(r'^\s*\{\{/if\}\}\s*$')


Expand All @@ -282,7 +290,13 @@ def apply_conditionals(text, values):
open_idx = i
break
if open_idx is None:
break # malformed — stop processing
# Malformed: this {{/if}} has no matching open. Stopping here leaves

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 "

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

"blocks in this skill were left unprocessed")
break

m = _IF_OPEN.match(lines[open_idx])
negated = m.group(1) == '!'
Expand Down Expand Up @@ -315,8 +329,15 @@ def apply_placeholders(text, values):


def find_unresolved(text):
"""Return list of placeholder names that were not substituted."""
return re.findall(r'\{\{([A-Z_]+)\}\}', text)
"""Return list of placeholder names that were not substituted.

Keys may contain digits but may not start with one: the output-path check
treats an unresolved override as an error, so a key this misses (e.g.
`{{PROMPT_PATH2}}`) would slip through as a literal output directory, while
excluding a leading digit keeps all-digit tokens (`{{1}}` in a documented
regex backreference or Handlebars snippet) from reading as placeholders.
"""
return re.findall(r'\{\{([A-Z_][A-Z0-9_]*)\}\}', text)


# ── Hash and change detection ─────────────────────────────────────────────────
Expand Down Expand Up @@ -517,27 +538,95 @@ def sync_skill(skill_path, adapter, project_config, project_root, args):
return False


def report_errors(header, errors, leading_blank=True):
"""Print a validation failure header and its error lines.

Shared by every validation block so the wording and spacing can't drift
between the write-path gate and the --validate report. The first block in
a run passes leading_blank=False; the rest are separated by a blank line.
"""
if leading_blank:
print()
print(header + '\n')
for e in errors:
print(e)


def skill_label(skill_path):
"""Group-qualified label for a skill file, e.g. `github-agile/start/SKILL.md`.

The validators audit every group when run inside the CodeCannon repo, and
two groups can hold the same skill name, so a bare directory name would
point at the wrong file.
"""
return f"{skill_path.parent.parent.name}/{skill_path.parent.name}/{skill_path.name}"


# Agent Skills spec (agentskills.io): lowercase alphanumerics and hyphens, no
# leading/trailing/consecutive hyphens, max 64 chars, must match the directory.
_SKILL_NAME_RE = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')


def validate_skill_names(skill_files):
"""Check each SKILL.md's frontmatter against the Agent Skills spec."""
def validate_skill_names(skill_files, project_config=None):
"""Check each SKILL.md's frontmatter against the Agent Skills spec.

Entries declaring `output_path_override` are skipped: they render as bare
files with no frontmatter (see `sync_skill`), so they are not skills in the
spec sense and neither `name` nor `description` reaches their output.
Holding them to the spec would block the sync over fields nobody reads.

The exemption is decided on the *substituted* override, matching the branch
`sync_skill` takes. An override that resolves to empty (e.g. a consumer sets
its placeholder to "") sends `sync_skill` down the frontmatter branch, so the
entry does render as a skill and must be held to the spec after all. Pass
project_config to get that resolution; without it the raw value is used.

An override left holding an undefined placeholder is neither: no frontmatter
fix repairs it, so it is skipped here and reported by validate_output_paths.
"""
errors = []
for skill_path in skill_files:
fm, _ = parse_frontmatter(skill_path.read_text())
label = skill_label(skill_path)
override = fm.get('output_path_override', '')
if override and project_config is not None:
override = apply_placeholders(override, project_config)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if find_unresolved(override):
continue
if override:
continue

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dir_name = skill_path.parent.name

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

name = fm.get('name', '')
if not name:
errors.append(f" {dir_name}/SKILL.md: missing required 'name' field")
errors.append(f" {label}: missing required 'name' field")
elif not _SKILL_NAME_RE.match(name) or len(name) > 64:
errors.append(f" {dir_name}/SKILL.md: name '{name}' violates the spec "
errors.append(f" {label}: name '{name}' violates the spec "
"(lowercase alphanumerics and single hyphens, max 64 chars)")
elif name != dir_name:
errors.append(f" {dir_name}/SKILL.md: name '{name}' must match its directory name")
errors.append(f" {label}: name '{name}' must match its directory name")
if not fm.get('description'):
errors.append(f" {dir_name}/SKILL.md: missing required 'description' field")
errors.append(f" {label}: missing required 'description' field")
return errors


def validate_output_paths(skill_files, project_config):
"""Check that every `output_path_override` resolves to a usable path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_skill does out_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 writes REVIEW_AGENT_PROMPT: /Users/me/.claude/prompt.md in .codecannon.yaml gets a file written outside their repo, silently, with a line.
  • Parent traversal. Path("/proj") / "../../etc/x"/proj/../../etc/x, which mkdir(parents=True) + write_text will 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.


An override still holding an undefined placeholder would have `sync_skill`
write to a literal `{{FOO}}` directory. Reported separately from the name
check because the fix is a missing `config:` key in .codecannon.yaml, not
a frontmatter change.
"""
errors = []
for skill_path in skill_files:
fm, _ = parse_frontmatter(skill_path.read_text())
override = fm.get('output_path_override', '')
if not override:
continue
unresolved = sorted(set(find_unresolved(apply_placeholders(override, project_config))))
if unresolved:
errors.append(f" {skill_label(skill_path)}: output_path_override has "
f"undefined placeholder(s): {', '.join(unresolved)}")
return errors


Expand All @@ -554,7 +643,7 @@ def validate_placeholders(skill_files, project_config):
text_to_check += '\n' + apply_conditionals(fm['description'], project_config)
missing = [p for p in find_unresolved(text_to_check) if p not in project_config]
for p in missing:
errors.append(f" {skill_path.name}: {{{{{p}}}}} not defined in config")
errors.append(f" {skill_label(skill_path)}: {{{{{p}}}}} not defined in config")
return errors


Expand Down Expand Up @@ -599,7 +688,7 @@ def validate_permissions(skill_files):
# For simple commands (git, make), check the prefix
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")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


return errors

Expand Down Expand Up @@ -650,7 +739,7 @@ def validate_command_shapes(skill_files):
if bad in line:
lineno = fence_line + 1 + i
errors.append(
f" {skill_path.parent.name}/{skill_path.name}:{lineno}: "
f" {skill_label(skill_path)}:{lineno}: "
f"'{bad}' — {msg}\n {line}")
break

Expand Down Expand Up @@ -820,50 +909,93 @@ def main():
else:
skill_files = all_skill_files

# Skills held to the spec. Scope mirrors the permission check: when sync.py
# runs from inside the CodeCannon repo itself (rather than as a consumer
# submodule), every group is checked so a violation in a non-enabled group
# 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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

audit_skill_files = sorted(skills_dir.glob('*/*/SKILL.md'))
else:
audit_skill_files = all_skill_files

# Placeholders resolve against the enabled group's config, so only that
# group's overrides can be substituted. Other groups are checked with the
# raw value — the same reason validate_placeholders stays scoped to
# skill_files — otherwise a group nobody enabled would hard-fail the sync
# over a placeholder the current config was never meant to define.
enabled_group = set(all_skill_files)
enabled_files = [f for f in audit_skill_files if f in enabled_group]
other_files = [f for f in audit_skill_files if f not in enabled_group]

# Spec-compliance gate. A frontmatter-name/directory mismatch makes
# sync_skill write output under a directory the spec says shouldn't exist,
# so the enabled group must fail before any write rather than only under
# --validate — that is the hole this gate closes.
#
# Other groups are never handed to sync_skill, so they cannot produce bad
# output and are not a reason to block a write. They are still audited, but
# only under --validate, matching the permission and command-shape checks.
# CI runs --validate, so the cross-group guarantee holds without a WIP skill
# in an unrelated group blocking a local ./sync.py.
name_errors = validate_skill_names(enabled_files, project_config)
path_errors = validate_output_paths(enabled_files, project_config)
NAME_FAILURE_HEADER = ("Skill-name validation failed — frontmatter not "

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

"spec-compliant (see agentskills.io):")
PATH_FAILURE_HEADER = ("Output-path validation failed — output_path_override "
"placeholders not defined in config:")
if not args.validate and (name_errors or path_errors):
if name_errors:
report_errors(NAME_FAILURE_HEADER, name_errors, leading_blank=False)
if path_errors:
report_errors(PATH_FAILURE_HEADER, path_errors, leading_blank=bool(name_errors))
sys.exit(1)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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).


# --validate: pre-flight placeholder check + permissions check, no writes
if args.validate:
failed = False
errors = validate_placeholders(skill_files, project_config)
if errors:
print("Placeholder validation failed — undefined placeholders:\n")
for e in errors:
print(e)
name_errors += validate_skill_names(other_files)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — Two enforcement holes in the non-enabled-group audit that the success messages hide.

  1. validate_skill_names(other_files) is called without project_config, so the exemption is decided on the raw override. A non-enabled group skill with name: Bad_Name and output_path_override: "{{X}}" is silently exempted from both the name and description checks. Whoever later enables that group with X set 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.

  2. validate_output_paths is only ever run on enabled_files, never on other_files, yet line 959 prints "Output-path validation passed — all override placeholders are defined." unconditionally. In-repo --validate therefore 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.

if name_errors:
report_errors(NAME_FAILURE_HEADER, name_errors, leading_blank=False)
failed = True
else:
print("Placeholder validation passed — all placeholders are defined.")

name_errors = validate_skill_names(skill_files)
if name_errors:
print("\nSkill-name validation failed — frontmatter not spec-compliant "
"(see agentskills.io):\n")
for e in name_errors:
print(e)
# Other groups are checked without placeholder resolution, so an
# entry there whose override is a placeholder stays exempt on the
# raw value. Say so rather than implying full coverage.
caveat = (" (other groups checked without placeholder resolution)"
if other_files else "")
print("Skill-name validation passed — frontmatter follows the "
f"Agent Skills spec{caveat}.")

if path_errors:
report_errors(PATH_FAILURE_HEADER, path_errors)
failed = True
else:
print("Skill-name validation passed — frontmatter follows the Agent Skills spec.")
# Only the enabled group's overrides are resolvable against this config.
print(f"Output-path validation passed — all override placeholders in "
f"{skill_group} are defined.")

# When sync.py runs from inside the CodeCannon repo itself (rather than
# as a consumer submodule), validate permissions across every skill group
# so a gap in a non-enabled group can't ship unnoticed.
if CODECANNON_DIR == project_root:
perm_skill_files = sorted(skills_dir.glob('*/*/SKILL.md'))
errors = validate_placeholders(skill_files, project_config)
if errors:
report_errors("Placeholder validation failed — undefined placeholders:", errors)
failed = True
else:
perm_skill_files = all_skill_files
perm_errors = validate_permissions(perm_skill_files)
print("Placeholder validation passed — all placeholders are defined.")

perm_errors = validate_permissions(audit_skill_files)
if perm_errors:
print("\nPermission validation failed — commands not in permissions.yaml:\n")
for e in perm_errors:
print(e)
report_errors("Permission validation failed — commands not in permissions.yaml:",
perm_errors)
failed = True
else:
print("Permission validation passed — all command prefixes are listed.")

shape_errors = validate_command_shapes(perm_skill_files)
shape_errors = validate_command_shapes(audit_skill_files)
if shape_errors:
print("\nCommand-shape validation failed — un-allowlistable shell shapes "
"(these prompt on every run and can't be 'always allowed'):\n")
for e in shape_errors:
print(e)
report_errors("Command-shape validation failed — un-allowlistable shell shapes "
"(these prompt on every run and can't be 'always allowed'):",
shape_errors)
failed = True
else:
print("Command-shape validation passed — all commands are allowlist-friendly.")
Expand Down
Loading
Loading