Skip to content

fix(skills): treat only top-level scripts/ paths as executable in skill_manage - #7088

Merged
WillemJiang merged 3 commits into
bytedance:mainfrom
BlueX888:fix/skill-manage-scripts-prefix-gate
Oct 22, 2026
Merged

WillemJiang merged 3 commits into
bytedance:mainfrom
BlueX888:fix/skill-manage-scripts-prefix-gate

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Why

A skill_manage write_file call for a legitimate support file was moderated as executable content whenever the path merely contained the substring "scripts/" anywhere — for example references/scripts/overview.md or templates/my-scripts/setup.md. Both are ordinary static support paths: ensure_safe_support_path allows any file under references/, templates/, assets/, or scripts/, and only the skill's top-level scripts/ directory is executable content according to the package rule in skills/package_files.py (is_code_path: a scripts/ member is posix.parts[0] == "scripts").

The pain is over-blocking: the executable gate in _scan_or_raise is much stricter than the static one. For executable content the moderation scan must return an exact "allow" decision (a "warn" raises Security scan rejected executable content), the moderation prompt is told the file is executable, and when the moderation model is unavailable the write hard-blocks with Security scan unavailable for executable content — even on deployments configured to fail open for ordinary skill content. No write escapes moderation; the misrouted write just receives the wrong (strictly stricter) tier than its path was supposed to get, so writing an innocuous reference document could be rejected.

Root cause: backend/packages/harness/deerflow/tools/skill_manage_tool.py classified the write with

executable = "scripts/" in path or path.startswith("scripts/")

The second operand is redundant, and the first is a plain substring test, so any support path containing "scripts/" — including inside other directory names — was misclassified as executable.

What changed

skill_manage write_file now classifies a support file with the complete shared package rule, deerflow.skills.package_files.is_code_file(path, head): executable content is a file under the skill's top-level scripts/ directory, a code-suffixed file anywhere in the package, or an extensionless file whose content starts with a shebang. Everything else is static support content and gets the ordinary moderation.

That fixes both misclassifications, not just one:

  • over-classification (the original bug): references/scripts/overview.md, templates/my-scripts/setup.md and other Markdown/data files under a scripts/-looking directory are static content again;
  • under-classification (raised in review): runnable support files that the scripts/-member rule alone left on the static tier — references/scripts/helper.py, templates/my-scripts/run.sh, assets/my-scripts/tool.js, extensionless shebang scripts such as references/scripts/hook — are executable content again, so a warn decision rejects them and an unavailable moderation model blocks them even with skill_evolution.security_fail_closed: false.

Nothing else changes: the deterministic SkillScan still runs for every write_file, top-level scripts/... files keep the strict executable gate, and SKILL.md writes (create/patch/edit) stay on the static tier.

The rule lives only in skills/package_files.py: is_code_path() now reuses a small is_scripts_member() helper, and the tool imports is_code_file instead of re-deriving a scripts/ test. That is the same predicate skills/mutations/scanner.py and skills/skillscan/orchestrator.py consume, so the write gate, the installer/export guard and SkillScan can no longer disagree. The shebang sniff reuses the first bytes of the content being written, so no extra file read is introduced, and Windows scripts\ separators are normalized by the shared _posix helper.

Behavior note: a code-suffixed or shebang file placed outside scripts/ (for example references/tools/probe.py) is now executable content where the old substring test left it static. That is the stricter direction and the rule the rest of the package pipeline already applies; Markdown, JSON/YAML and other data files are unaffected.

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandbox — docker/ or sandboxed execution
  • Skills — change under skills/ (the backend skills-management tool, packages/harness/deerflow/tools/skill_manage_tool.py, and the shared package-file rule in packages/harness/deerflow/skills/package_files.py)
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json
  • Default behavior change — changes existing behavior without the user opting in
  • Docs / tests / CI only — no runtime behavior change

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_skill_manage_write_file_executable_gate.py (41 cases, both directions pinned):
    • test_write_file_support_path_containing_scripts_substring_is_scanned_as_static — references/scripts/overview.md, templates/my-scripts/setup.md must be moderated as executable=False, including on a warn decision and on an unavailable provider under security_fail_closed: false;
    • test_write_file_non_code_support_file_stays_static — Markdown, references/schema.json and an extensionless non-shebang file stay writable in both degraded situations;
    • test_write_file_code_support_file_outside_top_level_scripts_is_scanned_as_executable — the review-named runnable files (references/scripts/helper.py, templates/my-scripts/run.sh, assets/my-scripts/tool.js, shebang references/scripts/hook) must reach moderation as executable content;
    • test_write_file_rejects_code_support_file_when_moderation_warns and test_write_file_rejects_code_support_file_when_moderation_unavailable_and_fail_open — those files must not be written on warn, nor when the provider is unavailable even with security_fail_closed: false;
    • test_write_file_top_level_scripts_file_is_scanned_as_executable, test_is_scripts_member_uses_the_shared_posix_rule, test_is_code_file_covers_code_suffixes_and_shebangs_anywhere_in_the_package — unchanged top-level scripts/ behavior and the shared rule itself.
  • Did it go red on main and green on this branch? Yes.
    • On main: 17 failed, 24 passed — every static-tier case records executable=True, and a code file outside scripts/ records executable=False.
    • On this branch: 41 passed.

Validation

Ran from the repo root with the frozen backend environment (uv run --frozen --project backend ...):

  • python -m pytest backend/tests/test_skill_manage_write_file_executable_gate.py -q → red on main (17 failed, 24 passed), green on this branch (41 passed)
  • python -m pytest backend/tests/test_skill_manage_write_file_executable_gate.py backend/tests/test_skill_manage_tool.py backend/tests/test_skill_storage_support_files.py backend/tests/test_skill_package_paths.py backend/tests/test_security_scanner.py backend/tests/test_skill_export.py backend/tests/test_skills_installer.py backend/tests/test_skill_mutation_scanner.py -q → 269 passed, 42 skipped (the same battery is 228 passed, 42 skipped on main)
  • Independent review of this head: a separately written 33-case gate regression (classification, warn, unavailable-provider fail-open over the same paths plus references/tools/probe.py) → 33 passed, and 151 passed when run together with test_skill_manage_tool.py + test_skills_installer.py
  • python -m ruff check and python -m ruff format --check on both changed files → clean

AI assistance

Tool(s) used: Claude Code

How you used it: AI located the misclassification, wrote the regression tests (red on main) and the classification fix. Review follow-ups moved the scripts/-member rule into the shared package_files helper and then replaced it with the complete shared is_code_file rule plus warn/unavailable regressions; every test result above was actually executed against main and this branch.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/M PR changes 100-300 lines labels Oct 19, 2026
@BlueX888
BlueX888 force-pushed the fix/skill-manage-scripts-prefix-gate branch from 3fb2ee9 to 3a35f4e Compare October 19, 2026 10:00

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified the fix: Path(path).parts[:1] == ("scripts",) matches the scripts/-member clause of is_code_path in skills/package_files.py, and I traced the edge shapes. ./scripts/x, scripts//x, and scripts/sub/x stay executable; bare scripts, //scripts/x, Scripts/, and backslash spellings never reach the gate because ensure_safe_support_path rejects them first (absolute path, single segment, top-level dir not in the allowed set), so the host-native Path here agrees with the storage parse on both POSIX and Windows hosts. Releasing nested references/scripts/... and templates/my-scripts/... writes to the static tier is sound: the deterministic SkillScan still runs over the whole candidate dir and still classifies code-suffixed support files as code via is_code_file, and the publication path (mutations/scanner.CandidateScanner) re-classifies with is_code_file and fails closed, so the relaxation does not recreate a hole downstream. No twin unfixed instances found: the installer and the mutations scanner already consume the shared package_files rule. Two non-blocking notes below.

# the storage layer already parsed this path with the same Path
# semantics. A substring test here also caught support files such as
# references/scripts/overview.md and templates/my-scripts/setup.md.
executable = Path(path).parts[:1] == ("scripts",)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: this re-derives the scripts/-member rule locally, which the skills module's AGENTS.md explicitly says not to do (package_files.py is the single definition of code-file rules; the installer, export guard, and SkillScan all import it). For every path that actually reaches this line the predicate does agree with the shared rule, since ensure_safe_support_path has already rejected absolute paths, traversal, the single-segment scripts spelling, and (on POSIX) backslash forms, and Path parses with the same semantics the storage check used. But package_files._posix additionally normalizes backslashes to /, so the two derivations are not equivalent in general. A tiny shared helper in package_files.py (e.g. is_scripts_member(path) built on _posix) would keep this gate, the installer, and SkillScan in lockstep if the member rule ever evolves.

"write_file",
"demo-skill",
content,
"scripts/run.sh",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test-boundary nit: the positive case pins only a direct scripts/run.sh child. Two cheap additions would pin the anchoring from the remaining sides: scripts/sub/run.sh (a file nested under the top-level scripts/ dir must stay executable=True — the half of the old substring behavior the new predicate intentionally keeps) and the ./scripts/run.sh spelling (Path normalizes the leading . away, so it stays executable). Both follow from the predicate today, but they are exactly the shapes a future refactor to PurePosixPath or manual split('/') could regress.

@github-actions github-actions Bot added the area:skills Skills under skills/ or the skills harness label Oct 21, 2026
@BlueX888

Copy link
Copy Markdown
Contributor Author

Both non-blocking notes are addressed in 0cc106e8b:

  • The write gate now calls the shared is_scripts_member helper in skills/package_files.py (built on _posix), and is_code_path uses the same helper, so the gate, installer, and SkillScan no longer have a second definition to drift from.
  • test_write_file_top_level_scripts_file_is_scanned_as_executable is now parametrized over scripts/run.sh, scripts/sub/run.sh, and ./scripts/run.sh (all executable=True). A small helper test also pins scripts\run.sh, bare scripts (the storage layer rejects it before classification), references/scripts/..., and /scripts/run.sh.

Validation on 0cc106e8b with the frozen backend environment:

  • test_skill_manage_write_file_executable_gate.py + test_skill_manage_tool.py → 33 passed
  • test_skill_storage_support_files.py + test_skill_package_paths.py + test_security_scanner.py → 99 passed
  • test_tool_args_schema_no_pydantic_warning.py + test_loop_detection_middleware.py + blocking_io/test_skill_manage_tool.py → 193 passed
  • ruff check and ruff format --check on the three changed files → clean

Current head: 0cc106e8b.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed 0cc106e8b84182207e472f3a8dbbef5902df89a8. Both earlier suggestions are addressed: the directory-membership rule is shared, and nested/dot-prefixed script paths have coverage. One P2 moderation regression remains, detailed inline.

Correction to my earlier review: I incorrectly stated that CandidateScanner supplies a downstream executable-content check for this tool's publication path. skill_manage writes directly through write_custom_skill; that scanner does not run here.

Validation: 325 related tests passed. Eight additional offline policy regressions fail at this head and pass with the exact base tool implementation. A temporary in-memory change to the complete shared is_code_file classifier passes those eight probes plus all 11 PR regression cases (19 passed). Lint and format checks passed; production source files were unchanged.

# of re-deriving it; a substring test here also caught support files
# such as references/scripts/overview.md and
# templates/my-scripts/setup.md.
executable = is_scripts_member(path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve executable moderation for nested code files

is_scripts_member(path) also downgrades runnable support files such as references/scripts/helper.py, templates/my-scripts/run.sh, assets/my-scripts/tool.js, and extensionless shebang files under references/scripts/. These are code according to the shared is_code_file rule and previously received executable=True, but now get written when moderation returns warn, or when the provider is unavailable with security_fail_closed=false.

I reproduced both outcomes through the real tool and storage with the deterministic package scan retained. That scan does not enforce the allow-only model verdict, and the following write_custom_skill call does not invoke CandidateScanner, so no later check restores executable moderation. All eight policy probes reject the writes on the base revision and accept them at this head.

Please classify with the complete shared code-file rule, including shebang detection, and add warn/unavailable regressions while keeping ordinary Markdown support files on the static tier.

…ll_manage

write_file classified a support file as executable content with a substring
test ("scripts/" in path), so any support path merely containing "scripts/"
(e.g. references/scripts/overview.md or templates/my-scripts/setup.md) was
moderated as executable: that scan requires an exact "allow" decision and
hard-blocks when the moderation model is unavailable. Executable content is
now exactly a file under the skill's top-level scripts/ directory, matching
the scripts/ member rule in skills/package_files.py and the same Path
semantics the storage layer already used to validate the support path.
The write_file executable gate re-derived the top-level scripts/ member rule
locally. Use package_files.is_scripts_member instead so the gate, installer,
and SkillScan stay in lockstep.

Extend the regression test to pin scripts/sub/run.sh and ./scripts/run.sh as
executable content, plus the shared helper's normalization.
…l_manage

The write_file executable gate classified only top-level <scripts/> members as
code, so runnable support files elsewhere in the package
(references/scripts/helper.py, templates/my-scripts/run.sh,
assets/my-scripts/tool.js) and extensionless shebang files fell back to static
moderation: a warn decision, or an unavailable moderation model with
security_fail_closed=false, wrote them to disk. The deterministic package scan
does not enforce the allow-only model verdict, and this write path publishes
through write_custom_skill without running the mutations CandidateScanner, so no
later check restores executable moderation.

Classify with the complete shared code-file rule from
deerflow/skills/package_files.py (scripts members, code suffixes, and
extensionless shebang files) instead of the scripts-member rule alone, and keep
Markdown/data support files on the static tier. Extend the regression suite with
the code-suffix/ shebang cases plus the warn and unavailable-provider outcomes.
@BlueX888
BlueX888 force-pushed the fix/skill-manage-scripts-prefix-gate branch from 0cc106e to 46c6c16 Compare October 22, 2026 12:44
@github-actions github-actions Bot added size/L PR changes 300-700 lines and removed size/M PR changes 100-300 lines labels Oct 22, 2026
@BlueX888

Copy link
Copy Markdown
Contributor Author

P2 confirmed — thanks. The write_file gate no longer re-derives a scripts/ test; it classifies with the complete shared rule:

executable = is_code_file(path, content[:8].encode("utf-8", "surrogatepass"))

deerflow.skills.package_files.is_code_file is the same predicate skills/mutations/scanner.py and SkillScan use: a member of the top-level scripts/ directory, or a code-suffixed file anywhere in the package, or an extensionless file whose content starts with a shebang. is_code_path() now reuses a small is_scripts_member() helper, so the rule stays defined in one place.

Effect on the paths you listed: references/scripts/helper.py, templates/my-scripts/run.sh, assets/my-scripts/tool.js, shebang scripts such as references/scripts/hook (and code files outside scripts/-looking directories, e.g. references/tools/probe.py) reach moderation as executable content again — rejected on a warn decision, and blocked when the provider is unavailable even with security_fail_closed: false. Markdown/data support files stay on the static tier: references/scripts/overview.md, templates/my-scripts/setup.md, references/schema.json, and extensionless files without a shebang.

New head: 46c6c1604fae29e0cda4e1c967ee7feb41d19e66 (rebased onto current main 66508d75).

Measured on this head with uv run --frozen --project backend ...:

  • python -m pytest backend/tests/test_skill_manage_write_file_executable_gate.py -q → 41 passed; the same file on main → 17 failed, 24 passed
  • python -m pytest backend/tests/test_skill_manage_write_file_executable_gate.py backend/tests/test_skill_manage_tool.py backend/tests/test_skill_storage_support_files.py backend/tests/test_skill_package_paths.py backend/tests/test_security_scanner.py backend/tests/test_skill_export.py backend/tests/test_skills_installer.py backend/tests/test_skill_mutation_scanner.py -q → 269 passed, 42 skipped (the same battery on main: 228 passed, 42 skipped)
  • independent reviewer regression over the same paths (classification, warn, unavailable-provider fail-open, plus static-tier controls) → 33 passed
  • ruff check and ruff format --check on both changed files → clean

@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 22, 2026
@WillemJiang
WillemJiang merged commit a1eb397 into bytedance:main Oct 22, 2026
15 checks passed
@BlueX888
BlueX888 deleted the fix/skill-manage-scripts-prefix-gate branch October 23, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:skills Skills under skills/ or the skills harness risk:medium Medium risk: regular code changes size/L PR changes 300-700 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants