Skip to content

fix(skills): render an explicit empty allowed-tools as no tools, not as all - #5593

Merged
WillemJiang merged 3 commits into
bytedance:mainfrom
Lesereingrape:fix/skill-describe-empty-allowed-tools
Sep 22, 2026
Merged

WillemJiang merged 3 commits into
bytedance:mainfrom
Lesereingrape:fix/skill-describe-empty-allowed-tools

Conversation

@Lesereingrape

Copy link
Copy Markdown
Contributor

Fixes #5590

Why

Trigger: while reading the skills package for another change I landed on _render_skill_metadata (skills/describe.py:136) and noticed it truthiness-tests s.allowed_tools, two lines below the code that escapes it for prompt safety. Skill.allowed_tools is tuple[str, ...] | None (skills/types.py:51), and those two empty-looking values mean opposite things — so the renderer collapses a restriction into a permission.

Pain: describe_skill is how the agent learns what a loaded skill permits. For allowed-tools: [] it prints Allowed tools: (all) while SkillToolPolicyMiddleware has stripped every business tool from that same skill — the model plans against tools that will be rejected, and the metadata it is reasoning over is the opposite of its enforced state. Reported as #5590.

Why it should be treated as a defect rather than a style choice: every other reader of this field already distinguishes the two states, and does so deliberately:

  • skills/tool_policy.py:41-47 — if skill.allowed_tools is None: continue, then a dedicated logger.info("Skill %s declared empty allowed-tools", …) for the empty case.
  • skills/parser.py:114 — the docstring for the function that produces the value: "Returns an empty tuple for an explicit empty value."
  • skills/export.py:305 — … if tools is not None else None.
  • Tests already pin the policy side: tests/test_skills_validation.py::TestValidateSkillFrontmatter::test_allows_empty_allowed_tools, tests/test_skills_parser.py::test_parse_empty_allowed_tools_list, tests/test_skill_tool_policy_middleware.py::test_explicit_empty_allowed_tools_keeps_only_framework_tools.

So describe.py is the only reader that gets it wrong, and the mislabel is observable inside one package: the same Skill object yields set() from the policy and (all) from the renderer.

What changed

  • "(all)" now renders only for the state that means unrestricted (None, field omitted). An explicit empty allowlist renders "(none)", and a non-empty one is unchanged.
  • The escaping of this line (html.escape(tools_line)) is untouched, so nothing about how untrusted front matter reaches the prompt changes.
  • One-line source change plus two fixtures. No adjacent cleanup is included; this diff is only the state distinction.

Surface area

  • Frontend UI
  • Backend API
  • Agents / LangGraph
  • Sandbox
  • Skills — backend/packages/harness/deerflow/skills/describe.py, backend/tests/test_skill_describe.py
  • Dependencies
  • Default behavior change — only the rendered text for a skill that declares an empty allowed-tools: Allowed tools: (all) → Allowed tools: (none). Omitted and non-empty declarations render exactly as before.
  • Docs / tests / CI only

Bug fix verification

  • Test paths:
    • backend/tests/test_skill_describe.py::test_render_explicit_empty_allowed_tools_does_not_claim_all
    • backend/tests/test_skill_describe.py::test_rendered_allowed_tools_agree_with_skill_tool_policy
run PYTHONUTF8=1 pytest tests/test_skill_describe.py
main @ aa4e43a2 + these tests (source file reverted) 2 failed, 25 passed — both fail on assert "Allowed tools: (all)" not in rendered
this branch 27 passed

The second fixture is a consistency test rather than a text assertion: it pins allowed_tool_names_for_skills([skill]) is None ⇔ (all) and == set() ⇔ not (all), so the two components cannot drift apart again without failing here. The pre-existing test_render_no_allowed_tools_shows_all and test_render_metadata_format are the controls and pass on both sides.

I also ran the reproduction from #5590 verbatim against aa4e43a2 — that is where the decl='[]' → policy=set() → rendered: (all) / decl=None → policy=None → rendered: (all) table comes from — and re-ran it on this branch, where the empty case prints (none) and the omitted case still prints (all).

Validation

  • PYTHONUTF8=1 pytest tests -k "skill or describe" on this branch: 1 failed, 1336 passed, 64 skipped (78 s). The one failure is
    tests/test_lark_cli_integration.py::test_install_lark_integration_installs_managed_gateway_cli_before_skill_pack,
    which fails identically after reverting describe.py to aa4e43a2 on this same machine (1 failed). It is an environment failure, not a behaviour one: the test drives an external lark CLI, and on this host the subprocess probe returns undecodable cp936 bytes, so lark_cli.py:474 raises AttributeError: 'NoneType' object has no attribute 'strip'. My diff does not touch that module.
  • PYTHONUTF8=1 pytest tests/test_skill_describe.py tests/test_skill_metadata_prompt_injection.py: 31 passed — the injection module asserts the escaped-tag behaviour of this same renderer, so it is the closest neighbour to guard.
  • Lint, run as the underlying commands because make is not available on this host: ruff check on both changed files → All checks passed!; ruff format --check → 2 files already formatted (ruff 0.15.12).
  • Not run: make lint / make test in full, the live and blocking_io marks, the frontend suites, Docker e2e. This is a local source checkout, not CI.

AI assistance

Tool(s) used: Qoder (agentic CLI).

How you used it: it wrote the two fixtures, ran them red on aa4e43a2 and green on the branch, ran the sweeps quoted above, and chose (none) as the rendering. It also wrote this description. A maintainer should read the diff with that in mind.

Human review status: this PR was opened during an unattended contribution run, so the account owner has not read the diff yet. That is why the box below is unticked, and I will tick it (or amend the PR) once they have.

  • The account owner should tick this once they have read the diff themselves.

Notes for the reviewer

  • The alternative resolution I did not pick: reject an empty allowed-tools at validation time instead of rendering it. I did not, because test_allows_empty_allowed_tools exists specifically to pin that install accepts the value, and tool_policy.py logs it as a supported state rather than treating it as an error — the empty list looks intentional ("this skill is metadata only"), which is exactly the case where the rendered text matters. Happy to switch to that variant if you consider the declaration itself a mistake.
  • get_skill_index_prompt_section (same file, below this function) renders only names, so it has no share of the bug; skills/export.py and tool_policy.py were checked for the same falsy pattern and are already correct.
  • fix(#4095): prevent eval fixture skills from stripping task tool from lead agent #4164 ("prevent eval fixture skills from stripping task tool from lead agent") argues about the policy semantics of allowed-tools and touches neither this function nor its tests, so there is no conflict; if that discussion ends up changing what () means, this rendering follows it with a one-line edit.

`_render_skill_metadata` truthiness-tested `Skill.allowed_tools`, so an
explicitly empty allowlist (`allowed-tools: []`, parsed to `()` by
`parse_allowed_tools`) rendered "Allowed tools: (all)" -- the same text an
omitted field (`None`, unrestricted) produces. `allowed_tool_names_for_skills`
distinguishes the two and strips every business tool for `()`, so the
describe_skill output contradicted the policy applied to the same skill.
@Lesereingrape

Copy link
Copy Markdown
Contributor Author

This is a draft by necessity, not because work is missing: PRs from accounts without write access are force-drafted here, and the create call only succeeds when it asks for a draft. Nothing is still in progress — the one-line change in skills/describe.py, the two fixtures in tests/test_skill_describe.py, and the red-on-aa4e43a2 / green-on-branch measurements in the description are complete, and the branch is a single commit (4d7fcb8d) off main. Marking it ready for review is all a maintainer has to do to start CI.

@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 against head 4d7fcb8. The state distinction is correct and matches the enforced semantics at this head: None renders (all), an explicit empty tuple falls through the falsy join to (none), and non-empty declarations render unchanged; the html.escape on the line below is untouched, and nothing in the repo parses the rendered text, so this is prompt-only output. I ran tests/test_skill_describe.py (27 passed) plus test_skill_metadata_prompt_injection.py and test_skill_tool_policy_middleware.py (50 passed) in a worktree at this exact SHA, and ruff check/format are clean on both files. Cross-reference: PR #5659 (explicit empty skill export requirements) touches only the frontend export dialog and i18n at its current head, so there is no file or semantic conflict with this change — both PRs fix different falsy readers of the same allowed_tools tri-state, and this backend half and that frontend half stack cleanly. One suggestion-level residual of the same mismatch family is posted inline on the new tools_line.

tools_line = ", ".join(s.allowed_tools) if s.allowed_tools else "(all)"
# `()` is an explicit empty allowlist — the policy middleware strips every
# business tool for it — so only an omitted field (`None`) means unrestricted.
tools_line = "(all)" if s.allowed_tools is None else (", ".join(s.allowed_tools) or "(none)")

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.

Correct for the pure cases, but note a residual of the same metadata-vs-enforced-state gap in mixed sets: per allowed_tool_names_for_skills (tool_policy.py), once any loaded skill declares allowed-tools, a legacy None skill contributes no tools to the union — yet this line still renders Allowed tools: (all) for it. So describing a legacy skill while a restricted skill is loaded repeats the #5590 mismatch the policy side deliberately implements (None only means allow-all when no loaded skill declares the field). Keeping the per-skill-declaration scope here is defensible and this framing is pre-existing, but the new consistency test pins single-skill lists only, so it cannot catch this drift — worth either a comment scoping (all) to the skill's own frontmatter declaration, or a mixed-list fixture spelling out the intended rendering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Scoped in 4d22605 — the comment above tools_line now states that (all) reports this skill's own frontmatter and names allowed_tool_names_for_skills for the mixed-set case; behaviour unchanged. I deliberately stopped short of the mixed-list fixture: pinning the current rendering would turn an open semantics question into a test, since as you say a legacy skill in a mixed set renders (all) while the middleware restricts it. If you would rather this line report the enforced union, the change is to pass the union (or the has_explicit_declaration flag) into _render_skill_metadata, and I will add that fixture as the regression test in this PR. At the new head tests/test_skill_describe.py + test_skill_metadata_prompt_injection.py + test_skill_tool_policy_middleware.py give 77 passed, and ruff check/format are clean on describe.py.

Review note: allowed_tool_names_for_skills makes a legacy None skill
contribute no tools once any loaded skill declares allowed-tools, so
"(all)" on that line reports the frontmatter of this skill rather than
the tool set the middleware will allow in a mixed set. Say so where the
tri-state is rendered; behaviour is unchanged.
@WillemJiang WillemJiang added this to the 2.2.0 milestone Sep 22, 2026
@WillemJiang
WillemJiang merged commit dde0595 into bytedance:main Sep 22, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:skills Skills under skills/ or the skills harness risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] describe_skill prints "Allowed tools: (all)" for a skill that declared an empty allowed-tools list

3 participants