feat(kyberforge): add agent-audit skill (closes #11)
## Why agent-author produces paired agent definition files (Claude Code .md + Copilot .agent.md) but had no companion audit skill to validate them. agent-audit fills that gap, giving the same structured PASS/FAIL report that skill-audit provides for SKILL.md files. ## Implementation Notes - validate.sh uses scope detection (walk up for plugin.json / .git) to locate the counterpart file and determine whether plugin-silently-ignored fields (hooks, mcpServers, permissionMode) should be flagged - CC-only and silently-ignored field lists are read from references/field-inventory.md at runtime rather than hardcoded — provenance back to the research corpus; see ADR-0019 - Single-file invocation (pass either file, counterpart derived) chosen over directory or name+root — see ADR-0018 - 12 bats tests cover provider detection, scope detection, all FAIL paths, and clean-pair pass ## Impact - kyberforge bumped to v1.1.2 - agent-author close step should be updated to reference agent-audit (#11) - Provenance/sources chain check deferred to #60 ADR: docs/adr/0018-agent-audit-single-file-invocation.md ADR: docs/adr/0019-agent-audit-field-inventory-reference.md Refs: #11 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147vXtL5sP6vorDdqXGJJU9
This commit is contained in:
210
plugins/kyberforge/skills/agent-audit/scripts/validate.sh
Executable file
210
plugins/kyberforge/skills/agent-audit/scripts/validate.sh
Executable file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate.sh <agent-file>
|
||||
|
||||
Validate a Claude Code or Copilot agent file pair against the agent definition spec.
|
||||
|
||||
Arguments:
|
||||
agent-file Path to either the Claude Code .md or Copilot .agent.md agent file.
|
||||
|
||||
Exit codes:
|
||||
0 All checks passed
|
||||
1 One or more checks failed
|
||||
2 Script error (unrecognized file extension or missing field-inventory.md)
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Error: agent-file is required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
python3 -u - "$1" "$SCRIPT_DIR" <<'PYTHON'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
agent_file = os.path.abspath(sys.argv[1])
|
||||
script_dir = sys.argv[2]
|
||||
|
||||
fname = os.path.basename(agent_file)
|
||||
|
||||
# --- Detect provider (check .agent.md before .md) ---
|
||||
if fname.endswith('.agent.md'):
|
||||
provider = 'copilot'
|
||||
name_stem = fname[:-len('.agent.md')]
|
||||
elif fname.endswith('.md'):
|
||||
provider = 'claude-code'
|
||||
name_stem = fname[:-len('.md')]
|
||||
else:
|
||||
print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --- Load field-inventory.md ---
|
||||
inv_path = os.path.normpath(os.path.join(script_dir, '..', 'references', 'field-inventory.md'))
|
||||
if not os.path.isfile(inv_path):
|
||||
print(f"Error: field-inventory.md not found at {inv_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
with open(inv_path) as f:
|
||||
inv_content = f.read()
|
||||
|
||||
def parse_section_tokens(content, section_name):
|
||||
lines = content.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == f'## {section_name}':
|
||||
for j in range(i + 1, len(lines)):
|
||||
stripped = lines[j].strip()
|
||||
if stripped and not stripped.startswith('#') and not stripped.startswith('---'):
|
||||
return set(stripped.split())
|
||||
return set()
|
||||
|
||||
cc_only_fields = parse_section_tokens(inv_content, 'claude-code-only-fields')
|
||||
plugin_ignored_fields = parse_section_tokens(inv_content, 'plugin-silently-ignored-fields')
|
||||
|
||||
# --- Detect scope ---
|
||||
def detect_scope(start_dir):
|
||||
current = os.path.abspath(start_dir)
|
||||
while True:
|
||||
if os.path.isfile(os.path.join(current, 'plugin.json')):
|
||||
return 'plugin', current
|
||||
if os.path.isdir(os.path.join(current, '.git')):
|
||||
return 'project', current
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return 'user', os.path.expanduser('~')
|
||||
current = parent
|
||||
|
||||
agent_dir = os.path.dirname(agent_file)
|
||||
scope, scope_root = detect_scope(agent_dir)
|
||||
|
||||
# --- Derive counterpart path ---
|
||||
if scope == 'plugin':
|
||||
if provider == 'copilot':
|
||||
counterpart = os.path.join(agent_dir, name_stem + '.md')
|
||||
counterpart_provider = 'claude-code'
|
||||
else:
|
||||
counterpart = os.path.join(agent_dir, name_stem + '.agent.md')
|
||||
counterpart_provider = 'copilot'
|
||||
elif scope == 'project':
|
||||
if provider == 'claude-code':
|
||||
counterpart = os.path.join(scope_root, '.github', 'agents', name_stem + '.agent.md')
|
||||
counterpart_provider = 'copilot'
|
||||
else:
|
||||
counterpart = os.path.join(scope_root, '.claude', 'agents', name_stem + '.md')
|
||||
counterpart_provider = 'claude-code'
|
||||
else: # user
|
||||
home = os.path.expanduser('~')
|
||||
if provider == 'claude-code':
|
||||
counterpart = os.path.join(home, '.copilot', 'agents', name_stem + '.agent.md')
|
||||
counterpart_provider = 'copilot'
|
||||
else:
|
||||
counterpart = os.path.join(home, '.claude', 'agents', name_stem + '.md')
|
||||
counterpart_provider = 'claude-code'
|
||||
|
||||
# --- Helpers ---
|
||||
failed = False
|
||||
|
||||
def fail(msg):
|
||||
global failed
|
||||
failed = True
|
||||
print(f"FAIL {msg}")
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
|
||||
|
||||
def parse_frontmatter(content):
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None, content
|
||||
return m.group(1), content[m.end():]
|
||||
|
||||
def extract_field(fm, field):
|
||||
m = re.search(rf'^{re.escape(field)}:\s*(.+)', fm, re.MULTILINE)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
def get_frontmatter_keys(fm):
|
||||
keys = set()
|
||||
for line in fm.splitlines():
|
||||
m = re.match(r'^([a-zA-Z][a-zA-Z0-9_-]*):', line)
|
||||
if m:
|
||||
keys.add(m.group(1))
|
||||
return keys
|
||||
|
||||
def check_file(fpath, file_provider, is_plugin_scope):
|
||||
local_fname = os.path.basename(fpath)
|
||||
with open(fpath) as f:
|
||||
content = f.read()
|
||||
|
||||
fm, body = parse_frontmatter(content)
|
||||
if fm is None:
|
||||
fail(f"no valid YAML frontmatter (---...---) — {local_fname}")
|
||||
return
|
||||
|
||||
# name
|
||||
name_val = extract_field(fm, 'name')
|
||||
if not name_val:
|
||||
fail(f"name field is missing or empty — {local_fname}")
|
||||
else:
|
||||
if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name_val):
|
||||
fail(f"name '{name_val}' is not kebab-case — {local_fname}")
|
||||
# stem check
|
||||
if file_provider == 'copilot':
|
||||
stem = local_fname[:-len('.agent.md')]
|
||||
else:
|
||||
stem = local_fname[:-len('.md')]
|
||||
if name_val != stem:
|
||||
fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}")
|
||||
|
||||
# description
|
||||
desc_val = extract_field(fm, 'description')
|
||||
if not desc_val:
|
||||
fail(f"description field is missing or empty — {local_fname}")
|
||||
else:
|
||||
if PLACEHOLDER_RE.search(desc_val):
|
||||
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
|
||||
|
||||
# body
|
||||
if not body.strip():
|
||||
fail(f"system prompt body is empty — {local_fname}")
|
||||
else:
|
||||
if PLACEHOLDER_RE.search(body):
|
||||
fail(f"body contains unfilled FILL IN: placeholder — {local_fname}")
|
||||
|
||||
# CC-only fields in Copilot file
|
||||
if file_provider == 'copilot':
|
||||
fm_keys = get_frontmatter_keys(fm)
|
||||
for key in sorted(fm_keys):
|
||||
if key in cc_only_fields:
|
||||
fail(f"CC-only field '{key}' present in Copilot file — {local_fname}")
|
||||
|
||||
# Silently-ignored fields in plugin-scope CC file
|
||||
if file_provider == 'claude-code' and is_plugin_scope:
|
||||
fm_keys = get_frontmatter_keys(fm)
|
||||
for key in sorted(fm_keys):
|
||||
if key in plugin_ignored_fields:
|
||||
fail(f"plugin-silently-ignored field '{key}' present in plugin-scope CC file — {local_fname}")
|
||||
|
||||
# --- Check counterpart exists ---
|
||||
if not os.path.isfile(counterpart):
|
||||
fail(f"counterpart file not found: {counterpart}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check both files ---
|
||||
is_plugin = (scope == 'plugin')
|
||||
check_file(agent_file, provider, is_plugin)
|
||||
check_file(counterpart, counterpart_provider, is_plugin)
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
PYTHON
|
||||
Reference in New Issue
Block a user