feat(skills): skill-relative file read + multi-language script execution (closes #251)#257
Conversation
…ion (#251) A SKILL.md body routinely references sibling files by a path relative to the skill's own directory ("read reference/runbook.md", "run scripts/check.py"). Those files ship in skills/<skill>/ and reach the agent, but nothing resolved a skill-relative reference: cli_execute and the skill-script executor run with CWD = agent root, and only .sh `## Tool:` entries were runnable at all. So a reasonable skill silently failed at runtime. This adds skill-scoped resolution (issue #251, approach 1): - read_skill gains an optional `file` argument — reads a file relative to the skill's directory (confined; no `..`/absolute escape) instead of SKILL.md. Handles "read reference/runbook.md". - New run_skill_script builtin — executes a script bundled in the skill by path relative to the skill dir, picking the interpreter by extension (.sh->bash, .py->python3, .js->node) and running it WITH THE SKILL DIRECTORY AS CWD so the script's own relative reads resolve. JSON args are passed as the script's first positional argument ($1). Path is confined to the skill dir; the subprocess inherits the same egress proxy + env passthrough as cli_execute / skill scripts. Registered independently of cli_execute (a skill may ship only non-.sh helpers). - Shared helpers builtins.SkillDir + builtins.SafeSkillJoin resolve a skill's loadable name to its directory and confine a relative path, used by both read_skill and run_skill_script. - skillEntryHasScript stays .sh-aligned by design: a .py/.js tool is now runnable via run_skill_script while correctly remaining in the catalog `provides:` list — closing the #250 "falls between not-callable and in-provides" note without a code change (comment updated to explain). Docs: the Skill Designer prompt (forge-ui) and writing-custom-skills.md explain skill-relative references and which languages are runnable. Tests: read (file arg) + execute for shell/python/javascript (each proves CWD=skill dir by reading a sibling file), traversal/absolute-escape rejection, unsupported-extension error, unknown-skill, and the SkillDir/SafeSkillJoin helpers. TypeScript is intentionally unsupported (ship compiled .js).
naveen-kurra
left a comment
There was a problem hiding this comment.
Review — 5 findings, 1 security, 4 cleanup / correctness / UX
Read the diff line-by-line, cross-checked path confinement, subprocess wire, and JSON return-value shape against the surrounding code, reproduced two of the concerns locally. This is a security-sensitive feature (new script executor) — bulk of my time went into the path guard.
Ranked most severe first:
1. forge-core/tools/builtins/read_skill.go:497 — SafeSkillJoin doesn't resolve symlinks, so a bundled symlink escapes confinement
SafeSkillJoin guards with filepath.Rel + textual .. checks:
full := filepath.Join(skillDir, rel)
within, err := filepath.Rel(skillDir, full)
if err != nil || within == ".." || strings.HasPrefix(within, ".."+string(filepath.Separator)) {
return "", errors.New("path escapes the skill directory")
}
return full, nilThat is a textual guard — it never touches the filesystem. Both consumers then use symlink-following syscalls:
read_skill.goline 435:os.ReadFile(full)— follows symlinks.run_skill_script.goline 185:os.Stat(full)— follows symlinks; thenexec.Run(bash, ["link", …])with CWD = skill dir, and bash resolves the link.
Attack: a skill package (which ships whatever the skill author writes via COPY . .) contains skills/owl/leak as a symlink to /etc/shadow / /root/.ssh/id_rsa / any host file readable by the container UID. SafeSkillJoin(dir, "leak") returns <dir>/leak — filepath.Rel says leak, no .., no absolute — allowed. os.ReadFile follows the symlink and returns the target contents. Same for run_skill_script with a link pointing at any executable script the container can reach.
Attacker trust model: yes, a malicious skill author can already ship arbitrary payloads. But symlinks are stealthier (they don't show up in cat review) and turn the tools into a general filesystem-read/execute primitive limited only by container FS perms. The tests only cover ../.., .., and /etc/hostname — no symlink case.
Fix: after filepath.Join, call filepath.EvalSymlinks(full) and re-run the Rel check against the resolved path. Reject if the resolved path is outside skillDir. Add a symlink test case.
2. forge-cli/tools/run_skill_script.go:210 — error return uses %q, which produces Go-quoted strings (\xff escapes) that fail JSON parsing
return fmt.Sprintf(`{"error": %q, "output": %q}`, runErr.Error(), truncate(out, 8192)), nilGo's %q verb escapes non-printable bytes as \xNN. JSON accepts only \uNNNN. Reproduced locally:
fmt.Sprintf(`{"out": %q}`, "\xff\xfe hello")
→ {"out": "\xff\xfe hello"}
json.Unmarshal(...) → invalid character 'x' in string escape code
Any script that emits raw bytes on stderr (a binary tool, a curl -s | head -c1 sort of pipeline, or just a badly-truncated UTF-8 sequence at the 8 KiB boundary from truncate) returns a malformed JSON string. The Anthropic/OpenAI tool-result parser then either drops the response or errors.
Fix: build the return with json.Marshal(map[string]any{"error": runErr.Error(), "output": truncate(out, 8192)}). Handles all encoding edge cases and the JSON encoder replaces invalid UTF-8 with U+FFFD by default.
Note: line 191 (return fmt.Sprintf(\{"error": %q}`, ierr.Error())`) has the same shape but the input is a Go-crafted error string with only ASCII → not exploitable in practice.
3. forge-cli/tools/run_skill_script.go:138 — execution timeout hardcoded at 120s with no override; sibling cli_execute accepts a config knob
func NewRunSkillScriptTool(workDir, proxyURL string, envVars []string) *RunSkillScriptTool {
return &RunSkillScriptTool{ …, timeout: 120 * time.Second }
}cli_execute — the peer tool — accepts TimeoutSeconds from the tool config (forge-cli/tools/cli_execute.go:22 and :558). A skill script that legitimately runs longer than 120s (large data process, slow HTTP fetch, model inference) gets SIGKILLed with no way for the operator to lift the cap. This is a design inconsistency, not a bug per se, but users will hit it and there's no yaml knob to fix it.
Fix: thread a TimeoutSeconds int through NewRunSkillScriptTool (or a RunSkillScriptToolConfig struct), plumb from a forge.yaml block, and default to 120.
4. forge-core/tools/builtins/read_skill.go:457 — SkillDir duplicates the existing ReadSkillTool.resolvePath resolution algorithm
SkillDir and resolvePath both implement the same three-step lookup: (1) direct subdir with hyphen variant, (2) flat <name>.md for resolvePath (skipped by SkillDir since dir-only), (3) frontmatter-name normalized index. If someone ever fixes a bug in one — a new normalization rule, another separator variant, a case-sensitivity fix — they'll forget the other. Two divergent implementations of the same resolution over time.
Fix: keep SkillDir as the primitive; refactor resolvePath to build on top of it (e.g. if p := filepath.Join(SkillDir(...), "SKILL.md"); fileExists(p) { return p } after also trying <name>.md at the flat-skill layer).
5. forge-cli/tools/run_skill_script.go:157 + line ~195 — schema declares args: {type: object} but no runtime type check; non-object args land in the script's $1 verbatim
"args": {"type": "object", "description": "Optional JSON object …"}jsonArgs := "{}"
if len(input.Args) > 0 && string(input.Args) != "null" {
jsonArgs = string(input.Args)
}If the LLM sends args: "hello" (a string) or args: 42 (a number) or args: [1,2] (an array), the code doesn't validate; the script's json.loads(sys.argv[1]) gets a non-dict and either crashes or misinterprets. The test scripts all send an object so this stays uncaught.
Fix: after unmarshal, peek the RawMessage's first non-whitespace byte. If it's not {, return {"error": "args must be a JSON object"}. Cheap defense.
Not blockers, but worth noting:
- Path arg passed to bash/python/node is
input.Pathverbatim — since it'sSafeSkillJoin-validated and exec-based (no shell), no injection. Correct. interpreterForScriptaccepts.SH,.PYviastrings.ToLower— matches builder prompt guidance.- Test
TestRunSkillScript_UnsupportedExt— a.tsfile returns the clear error. Nice touch. skillEntryHasScriptnarrative inforge-cli/runtime/runner.go:3261is now internally consistent — the comment now correctly explains why.py/.jsscripts staying inprovides:is not a gap.
Approving with the security fix + the JSON-safety fix requested (#1 and #2). #3–#5 are follow-ups.
…#257 review) Addresses the #257 review: (1) SafeSkillJoin now resolves symlinks and re-checks containment against the resolved skill dir, rejecting a bundled symlink that escapes (leaf or intermediate); (2) run_skill_script builds its error/output JSON via json.Marshal instead of fmt %q so raw/invalid-UTF-8 script output stays valid JSON; (5) args must be a JSON object, rejected before exec. Tests added for all three. Deferred as reviewer follow-ups: #3 configurable timeout, #4 SkillDir/resolvePath dedup (flat-skill frontmatter caveat).
|
Thanks — genuinely useful review, especially the symlink attack path. Pushed 851b0bc addressing the two requested fixes plus the cheap #5: #1 symlink escape (security). #2 JSON-safe output. All dynamic returns in #5 args must be a JSON object. Non-object Deferred as you scoped them:
|
|
The two non-blocking items deferred from this review (per commit 851b0bc) are now tracked as backlog issues:
Both are follow-ups on top of this PR and depend on it merging first. The three blocking review points (symlink confinement, JSON-safe errors, args validation) are addressed in 851b0bc with tests. |
Closes #251.
Problem
A
SKILL.mdbody references sibling files by a path relative to the skill's own directory ("readreference/runbook.md", "runscripts/check.py"). Those files ship inskills/<skill>/and reach the agent, but nothing resolved a skill-relative reference:cli_executeand the skill-script executor run with CWD = agent root, and only.sh## Tool:entries were runnable. A reasonable skill silently failed at runtime.Change (approach 1 — skill-scoped tools)
read_skillgains an optionalfileargument — reads a file relative to the skill's directory (confined; no../absolute escape) instead ofSKILL.md. Handles "readreference/runbook.md".run_skill_scriptbuiltin — executes a bundled script by path relative to the skill dir, picking the interpreter by extension (.sh→bash,.py→python3,.js→node) and running it with the skill directory as CWD so the script's own relative reads resolve. JSONargsare passed as the script's first positional argument ($1). Path-confined; the subprocess inherits the same egress proxy + env passthrough ascli_execute/skill scripts. Registered independently ofcli_execute(a skill may ship only non-.shhelpers).builtins.SkillDir+builtins.SafeSkillJoinresolve a skill's loadable name → directory and confine a relative path, used by both.skillEntryHasScriptstays.sh-aligned by design: a.py/.jstool is now runnable viarun_skill_scriptwhile correctly staying in the catalogprovides:list — closing the fix(skills): align skill catalog names with read_skill resolution #250 "falls between not-callable and in-provides" note without a code change (comment updated).Acceptance criteria
read_skillfile).skillEntryHasScript/provides:classification reconciled (rationale documented;run_skill_scriptmakes py/js runnable soprovides:is correct as-is).forge-ui/skill_builder_context.go) +docs/skills/writing-custom-skills.mdexplain skill-relative references and runnable languages.Tests
run_skill_script_test.goexecutes real shell/python/javascript scripts, each proving CWD=skill dir by reading a siblingreference/data.txt, plus traversal/absolute-escape rejection, unsupported-extension (.ts) error, and unknown-skill.read_skill_test.goadds thefile-arg read + its traversal guard and theSkillDir/SafeSkillJoinhelpers.Full
forge-core/tools/builtins,forge-cli/tools, andforge-cli/runtimesuites green;gofmt+golangci-lintclean.Notes / scope
nodecan't run raw.ts) — ship compiled.js. Error is explicit..py/.js## Tool:entries as first-class callable tools (Model A) remains out of scope —run_skill_script(Model B) covers the referenced-by-path case the issue describes.