feat(skills): both name forms for tool→script binding + validate lint (#418) - #419
Conversation
…te lint (#418) A `## Tool: some_name` previously bound ONLY to scripts/some-name.<ext> (underscores forced to hyphens); a file named scripts/some_name.sh silently did not register. Now it binds to EITHER scripts/some_name.<ext> or scripts/some-name.<ext> (hyphenated wins only if both exist). Resolution is refactored around a single SkillScriptCandidatePaths generator shared by the runtime resolver and the validator so they can never drift. `forge skills validate` now scans the whole skill tree (main SKILL.md + skills/*/SKILL.md) and surfaces the registration failures the runtime otherwise handles silently, each with a non-zero exit so CI can gate: - invalid **Input:** property key(s) outside ^[a-zA-Z0-9_.-]{1,64}$ (the runtime drops the whole tool — it would 400 every LLM call) - a script-runtime `## Tool:` with no backing script (never registers) - orphan scripts with no `## Tool:` heading (dead weight; warning) This replaces the external lints customers maintained as static approximations of forge's parser. Docs updated (skills-cli, writing-custom- skills, skill-md-format, cli-reference).
78175dd to
2da8d80
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Review — both name forms for tool→script binding + validate lint (closes #418)
Clean, well-engineered fix. Traced to the branch source; CI all green (Build ×6, Integration, Lint, Test, Doc-link). Notable for preserving and extending the security invariants from the earlier skill-import arc rather than regressing them:
- The #406 traversal fix survives the refactor and is correctly extended to both name forms. The
filepath.IsLocal+ separator guard now lives in the sharedSkillScriptCandidatePathsand runs on every name variant before anyos.Stat— see inline. I confirmed the-↔_swap can't introduce an escape. - Single-source-of-truth is maintained — resolver, read-skill catalog (
skillEntryHasScript), and the new validator all go throughSkillScriptCandidatePaths, so "what's a first-class tool" can't drift across the three (now with a third consumer). - Existing skills resolve identically — hyphen-form candidates are ordered first within each extension, so the only behavior change is that a previously-silently-broken underscore-named file now registers (the #418 win).
- The lint uses the real parser (
InvalidSchemaPropertyKeys(InputSpecToSchema(...))) — no reimplementation — and the non-zero exit is properly wired (hasErrorsgates the error return, composing with the existing env-error path), so "CI can gate on it" actually holds. - Orphan WARN is variant-aware (marks all candidate paths
claimed) and correctly a WARN, not ERROR, so legitimate helper scripts don't fail validation.
One tiny scope note: the second commit (2da8d8, a default-deny PDP example in platform-policy.md) is unrelated to #418 — harmless docs, just flagging it's bundled. Approve-grade; nothing blocking.
| // derived stem to be a bare, non-escaping path segment before it touches | ||
| // the filesystem. Mirrors the import-path traversal hardening. | ||
| for _, n := range names { | ||
| if !filepath.IsLocal(n) || strings.ContainsRune(n, filepath.Separator) || strings.ContainsRune(n, '/') { |
There was a problem hiding this comment.
Verified this preserves the #406 traversal hardening through the refactor, and correctly extends it to both name forms: the guard now runs on every variant (for _, n := range names) before any candidate path is built or os.Stated, and returns nil if any escapes. I checked that the -↔_ substitution can't sneak past it — neither char is a path separator or ., so filepath.IsLocal / separator results are identical for the hyphen and underscore forms; a ## Tool: ../../x fails on both. Because this generator is the single source shared by the resolver, skillEntryHasScript, and the new validator, the guard can't be bypassed by any one consumer. 👍
Closes #418.
Two Forge skill-registration behaviors failed silently — a tool was discarded at runtime with (at best) one error-log line, and no author/build-time signal. Customers had to reimplement Forge's parser as external lints and re-confirm them against every
FORGE_VERSION. This makes Forge the source of truth.1. Tool→script binding accepts both name forms
A
## Tool: some_namepreviously bound only toscripts/some-name.{sh,py,js}— underscores were forced to hyphens, so an author who named the filescripts/some_name.shto match the tool got a silently-missing tool.Now it binds to either
scripts/some_name.<ext>orscripts/some-name.<ext>(and symmetrically for a hyphenated tool name). The hyphenated form keeps priority only if both files exist, so existing skills resolve identically. Priority order is otherwise unchanged: skill-localscripts/before sharedskills/scripts/, shell → python → node.Resolution is refactored around one exported generator,
runtime.SkillScriptCandidatePaths, shared by the runtime resolver (resolveSkillScript), the read-skill catalog (skillEntryHasScript), and the new validator — so the "what's a first-class tool" decision can't drift between them. Traversal hardening (.., path separators) is preserved.2.
forge skills validatesurfaces the silent failuresvalidatenow scans the whole skill tree (mainSKILL.mdand everyskills/*/SKILL.md, not just the root as before) and reports, per## Tool::**Input:**property key outside^[a-zA-Z0-9_.-]{1,64}$## Tool:headingrun_skill_script; dead weight if unreferenced.The property-key check runs the real parser (
InputSpecToSchema+InvalidSchemaPropertyKeys), so the lint can't approximate the rule wrong.validateexits non-zero on any ERROR, so CI can gate on it — replacing the external lints.Sample output:
Tests
runtime:TestSkillScriptNameVariants,TestSkillScriptCandidatePaths_bothForms(both name forms + priority order),_traversalRejected.cmd:TestLintSkillTools— a temp skill tree exercising hyphen-backed, underscore-backed (the skills: relax tool→script name binding + surface silent registration failures atforge skills validate#418 win), invalid-key, missing-script, and orphan-script cases.forge skills validateconfirms wiring + exit code.Docs
skills-cli.md(new Validate section),writing-custom-skills.md(binding rule + silent-failure guard),skill-md-format.md(runtime table),cli-reference.md.Follow-up (noted in #418)
Wire the same lint into the
forge buildvalidate stage so an invalid property key fails the build, not justvalidate.