Skip to content

Navigation Menu

Sign in
Sign up

fix(skills,tools): stop two tools deleting data while reporting success - #3358

Open
kovtcharov wants to merge 5 commits into
amd:main from
kovtcharov:fix/skill-name-and-replace-function
Open

fix(skills,tools): stop two tools deleting data while reporting success #3358
kovtcharov wants to merge 5 commits into
amd:main from
kovtcharov:fix/skill-name-and-replace-function

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Summary

Two tools that destroy user data and report success now refuse, or replace only
what they were asked to.

Why

gaia skill remove . deleted the entire skills root — signing keys, trust store
and lock included — printed "✅ Removed skill" and exited 0; .. took ~/.gaia
and its config.json with it. Every skill entry point resolved root / name
without validating the name, and pathlib collapses . back to the root, leaves
.. at its parent, and lets an absolute name replace the root outright. The
import path matters most: without --name the name comes from the imported
bundle's own
SKILL.md, so it is supplied by whatever the user downloaded.

replace_function found the end of its target by scanning forward for the next
same-indent def/class, so module constants and the next function's decorators
sat inside the replaced span and were deleted while the tool returned success.
It also left the target's own decorators behind and re-applied them to the
replacement, and resolved the name via the first ast.walk hit anywhere in the
module — so on a file with two classes that each define run, it rewrote the
wrong one.

Neither is reachable by an attacker; both are mistakes a user or the model makes
on the way to something else. Both are silent, and both are unrecoverable.

Linked issue

Closes #3356

Changes

  • New gaia.skills.naming guards every join that can be created, overwritten or
    deleted (remove / install / create / import / migrate). It validates against
    the canonical NAME_PATTERN and then asserts the target resolves to a direct
    child of the root — the assert is the load-bearing half, since it catches the
    symlink and \?\ shapes a name pattern never sees.
  • replace_function takes its span from the AST (end_lineno,
    decorator_list[0].lineno) and resolves the target by module-level name or an
    explicit Class.method. A bare name that exists only as a method is now an
    error listing the qualified alternatives, not a guess.
  • Behaviour change worth a look: the replaced span now includes the target's
    decorators, so new_implementation must repeat any decorator the function
    keeps. The tool docstring and docs/spec/file-io-tools-mixin.mdx both say so.
    skill_library_tools._reject_bad_name's docstring claimed the substrate did no
    validation — corrected, and the guard kept so the model still gets a structured
    tool error rather than a raised exception.

Test plan

  • python -m pytest tests/unit/test_skills_name_containment.py tests/unit/test_replace_function_spans.py -q60 passed. Reverting only the source changes turns 26 of those 60 red, including all five replace_function defect tests.

  • python -m pytest tests/unit/test_skills_install.py tests/unit/test_skills_cli.py tests/unit/test_skills_migrate.py tests/unit/test_skills_marketplace.py tests/unit/test_skills_format.py tests/unit/test_skills_manager.py tests/unit/test_file_write_guardrails.py tests/unit/test_skills_name_containment.py tests/unit/test_replace_function_spans.py -q571 passed, 3 failed, 4 skipped. The 3 failures are test_skills_cli.py::test_real_cli_*, which shell out to gaia and hit RuntimeError: Could not determine home directory on Windows; they fail identically on the unmodified base commit.

  • python util/lint.py --all --fix then python util/lint.py --all → black, isort, flake8, bandit, import validation and the convention checks all pass. It exits 1 on 9 pre-existing pylint E1101s for os.killpg / os.getpgid / os.geteuid in daemon/sidecars/{ledger,manager}.py and installer/lemonade_installer.py — POSIX-only members flagged because pylint ran on Windows. Same 9, same three files, none in this diff.

  • Agent eval run — tool_selection, the category covering a tool-schema change. ANTHROPIC_API_KEY= gaia eval agent --category tool_selection, judged by claude-opus-5, against Lemonade 11.5.0 with Gemma-4-E4B-it-GGUF. Run eval-20260904-224526: 3/5 passed, avg 7.5. Four scenarios pass; smart_discovery fails, and it fails identically on unmodified upstream/main — see Evidence.

    The empty ANTHROPIC_API_KEY= is load-bearing: load_dotenv() reaches up into the parent checkout's .env and picks up an exhausted key, which flips eval/runner.py into --bare and cuts OAuth off. An earlier "Credit balance is too low" on this PR was that, not an account limit.

Evidence

CLI — gaia skill remove (the surface this PR changes). Scratch GAIA_CONFIG_DIR, one demo-skill installed, signing key in skills/keys/.

Before (base abe87edc):

$ ls /tmp/before /tmp/before/skills
config.json skills
demo-skill keys
$ gaia skill remove .
✅ Removed skill '.' from ...\before\skills
 (it was not hub-installed, so no lock entry was tracked)
$ echo $?
0
$ ls /tmp/before/skills
ls: cannot access '/tmp/before/skills': No such file or directory

After:

$ gaia skill remove .
❌ remove '.': name '.' is not a valid skill name. Use lowercase letters and digits
 separated by single hyphens (e.g. 'web-research') — no slashes, no '.', no '..',
 no absolute path. Pass the name exactly as 'gaia skill list' reports it.
 See https://amd-gaia.ai/docs/plans/skill-format#naming
$ echo $?
4
$ ls /tmp/fakehome /tmp/fakehome/skills
config.json skills
demo-skill keys
$ gaia skill remove demo-skill
✅ Removed skill 'demo-skill' from ...\fakehome\skills\demo-skill
$ echo $?
0

.. and ../x refuse identically; a real name still removes.

replace_function — before/after file dump, same input file and same call
(replace_function(path, "foo", "def foo():\n return 99")), both reporting
success:

----- mod.py BEFORE ----- ----- AFTER (base abe87edc) ----- ----- AFTER (this PR) -----
import functools import functools import functools
@functools.cache @functools.cache def foo():
def foo(): def foo(): return 99
 return 1 return 99
 def bar(): CONSTANT = 42
CONSTANT = 42 return CONSTANT
 @functools.cache
@functools.cache def bar():
def bar(): return CONSTANT
 return CONSTANT

CONSTANT = 42 and bar's decorator are gone on the left; foo's own decorator
survived and now decorates the new body. On the right only foo changed.

Agent eval — smart_discovery is red, and it is not this change.

The load-bearing evidence is a control run: revert every source file in this PR to upstream/main and the scenario fails the same way.

source index state smart_discovery
upstream/main (abe87edc), reverted 180's paths FAIL 2.35
upstream/main (abe87edc), reverted empty FAIL 3.77
this branch 180's paths FAIL 2.72 / 2.40 / 3.40
this branch cleared → my paths FAIL 2.30

Six runs, two branches, three index states, never a pass. The judge attributes it to the environment: "the eval corpus directory is not in the agent's allowed_paths, so PathValidator.is_path_allowed (src/gaia/security.py:375) denies both index_document and read_file... while list_files on the same directory succeeds" — filed as #3370. There is also a mechanical reason this PR cannot be the cause: smart_discovery runs the doc profile, whose tool_groups=('doc_rag',) never registers file_io, and file/full pop replace_function out (agent.py:1962) — so the docstring this PR edits is in no chat profile's tool schema.

Same category, same machine, same day, same judge, different branches:

scenario #3364 #3359 this PR
data_vs_recall_disambiguation FAIL 6.40 PASS 9.05 FAIL 6.50 (flaky — also red on #3364)
known_path_read PASS 9.55 PASS 9.75 PASS 9.70
multi_step_plan PASS 9.82 PASS 8.98 PASS 9.50
no_tools_needed PASS 9.97 PASS 9.98 PASS 10.0
smart_discovery PASS 8.07 PASS 9.88 FAIL 2.30

The two green smart_discovery results ran against a warm document library whose indexed paths matched their worktree. The scenario only passes from warm state; its cold discover-index-answer path does not work on this machine (#3370).

The committed baseline is deliberately not quoted: gemma-4-e4b-d71cd914 was judged by claude-sonnet-4-6 over 4 scenarios, while these runs use claude-opus-5 over 5, so a delta against it measures judge and corpus drift (#3371).

Footnote on the middle row: the "cleared → my paths" run required manually deleting three rows from ~/.gaia/chat/gaia_chat.db that pointed at another worktree. That is a manual environment fix, not routine — it removed the Access denied failure and exposed a second one underneath (find_files restricted to pdf,docx,txt, excluding the .md handbook), so the scenario still failed.

The replace_function docstring was trimmed 1110 → 631 chars while investigating this. It did not move the number — see above for why it could not — and is kept only because a tool docstring ships in the schema every turn.

  • Agent exposed in the Agent UI — N/A. Neither surface is rendered in the Agent UI; replace_function is a tool the agent calls, and gaia skill remove is CLI-only.
  • MCP tools / servers — N/A. Nothing here is exposed over MCP.
  • HTTP API / REST — N/A. No router or endpoint touched.

Ovtcharov added 2 commits September 4, 2026 11:18
`gaia skill remove .` deleted the entire skills root — signing keys, trust
store and lock included — and reported "✅ Removed skill" with exit 0; `..`
took `~/.gaia` with it. Every entry point resolved `root / name` unvalidated,
and pathlib collapses `.` back to the root, leaves `..` at its parent, and
lets an absolute name replace the root outright, with `is_dir()` true for all
three.
One helper now guards every join that can be created, overwritten or deleted:
remove, install, create, import and migrate. It validates against the
canonical NAME_PATTERN and then asserts the target resolves to a direct child
of the root — the assert is the load-bearing half, since it catches the
symlink and `\?\` shapes a name pattern never sees.
`import` matters most: without --name the name comes from the imported
bundle's own SKILL.md, so it is supplied by whatever the user downloaded.
Refs amd#3356 
...scan
replace_function found the end of its target by scanning forward for the next
same-indent `def`/`class`, so everything in between — module constants, the
next function's decorators — sat inside the replaced span and was deleted,
while the tool returned success. A user who does not know code vanished has no
reason to look for the .bak.
Two more defects rode in the same routine. The target's own decorators sat
above `start_line`, so they survived and were silently re-applied to the
replacement — a semantic change nobody asked for. And `ast.walk` took the
first same-named function anywhere in the module, so on a file with two
classes that each define `run`, `replace_function("run")` rewrote the first.
The span now comes from `node.end_lineno` and `decorator_list[0].lineno`, and
the target resolves by module-level name or an explicit `Class.method`
qualified name. A bare name that exists only as a method is an error listing
the qualified alternatives rather than a guess.
The tool docstring and its spec page both state the new contract: the
replacement fully defines the function, decorators included.
Refs amd#3356 

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

Two tools that quietly destroyed data now refuse or edit precisely: gaia skill remove . no longer wipes the whole skills directory (signing keys and trust store included), and the "replace one function" tool stops eating the code that happens to sit after its target. The fixes are the right shape — every place a skill name becomes a directory goes through one guarded helper, and the function edit now takes its boundaries from the parsed file rather than guessing by indentation — and the tests actually check the file on disk afterwards, which is what the old code lied about.

One thing worth a follow-up before or after merge: this PR newly lets you target a method by its full Class.method name, but if the replacement text isn't indented to sit inside the class, the edit can still land as a valid file with the method quietly moved out of its class — success reported, wrong result. A cheap check after the edit would close the last hole of the same kind this PR is about.

Nothing else blocking. No security concerns.

Real-world evidence

No automated evidence bundle was produced for this run (evidence-bundle.md absent), but the PR description carries the matching evidence itself and I did not re-run it:

  • CLI — before/after transcripts of gaia skill remove . showing the old run printing ✅ Removed skill with exit 0 and an empty skills directory, and the new run refusing with exit 4 and everything intact, plus gaia skill remove demo-skill still working.
  • Tool behaviour — a side-by-side file dump for the same replace_function call showing CONSTANT = 42 and the next function's decorator deleted on base, and only the target changed after.
  • Agent UI / MCP / HTTP — marked N/A with a reason; neither surface is exposed there. Agreed.

The author flagged one gap honestly: gaia eval agent could not run here — the eval judge's structured-output call fails with a Claude billing error on that box — so the tool-docstring change has no eval behind it. The description also notes no tool_selection scenario exercises this tool, so the closest category wouldn't have covered it anyway. I'd still want one eval pass on a box with working judge auth before this is considered fully verified; it doesn't change the verdict, since the docstring change is additive guidance and the behavioural evidence above is direct.

I could not execute the test suite in this environment (pytest isn't installed on the runner), so the pass counts in the test plan are taken as reported, not reproduced.

🔍 Technical details

🟡 Important

A de-indented replacement can still silently move a method out of its class (src/gaia/agents/tools/file_io_tools.py:1142)

The span is now exact, but nothing checks that the replacement lands where the original was. With Class.method targeting newly supported, this is the likely model mistake:

class Alpha:
 x = 1
 def run(self):
 return "alpha"

replace_function(path, "Alpha.run", 'def run(self):\n return "PATCHED"') (no leading indent) produces a file that parses cleanly — so the existing _validate_python_syntax gate passes — with run relocated to module level and Alpha.run gone. Status: success. Verified locally against the new span logic.

The helper this PR already added makes the check a two-liner. After the syntax validation and before the write:

 if not validation["is_valid"]:
 return {
 "status": "error",
 "error": "Replacement would result in invalid syntax",
 "syntax_errors": validation.get("errors", []),
 }
 # The span is exact, but a mis-indented replacement can still parse
 # cleanly with the definition relocated out of its class.
 try:
 _resolve_function_node(ast.parse(modified_content), function_name)
 except FunctionLookupError as e:
 return {
 "status": "error",
 "error": (
 f"Replacement no longer defines '{function_name}' in the "
 f"same place ({e}). Check the indentation of "
 "new_implementation."
 ),
 }

One caveat to decide on: this also rejects using replace_function to rename a function. The docstring says "replace one function definition", so I read that as out of contract — but it's your call, and if renaming is meant to be supported the check should compare parent scope only.

🟢 Minor

  1. The duplicate-name error suggests a fix that doesn't apply to @overload (file_io_tools.py:79). @overload stacks are a normal reason for three same-named defs, and "rename one" is impossible there. Worth naming the real out:
 raise FunctionLookupError(
 f"'{name}' is defined more than once (lines {where}). Refusing to "
 "guess which definition to replace — for an @overload stack or a "
 "conditional definition, edit the file with edit_python_file instead."
 )
  1. _handle_create writes the unvalidated name into the manifest (src/gaia/skills/cli.py:557, 575). skill_directory strips the name, but Skill(name=args.name, ...) and the success print still use the raw argument, so a name with surrounding whitespace produces a directory and a manifest that disagree. Reuse the validated value:
 name = validated_skill_name(args.name, source="create")
 target = skill_directory(parent, name, source="create")

(then use name for the Skill(...), _scaffold_body, and the printed messages)

  1. Symlinked skill directories become unmanageable. skill_directory's containment assert means a skill dir that is a symlink can no longer be removed or upgraded through gaia skill, not just protected from rmtree. The error text tells the user to delete the link themselves, which is the right answer for remove — just flagging that install --force over a linked dev checkout now fails too, in case that's a workflow anyone relies on.

Strengths

  • One choke point, applied everywhere. I grepped every rmtree/join site under src/gaia/skills/cli.py:570, cli.py:601, install.py:272, install.py:522, migrate.py:948 — and all five now derive their target from skill_directory. No unguarded root / name survives in the package. Putting the resolved-parent assert after the pattern match is the right ordering, and the module docstring says why (the pattern never sees symlinks or \\?\).
  • Validating the bundle-supplied name, not just the typed one. gaia skill import without --name takes the name from the downloaded SKILL.md; catching that is the part a narrower fix would have missed, and test_import_refuses_an_escaping_bundle_supplied_name pins it.
  • Tests assert on the disk, not the return value. assert_intact() checking the signing key, trust store and config.json after every refusal is exactly right for this bug class — a guard that raised after the rmtree would pass a return-value-only test. Same for the replace_function tests asserting CONSTANT = 42 survives rather than status == "success".
  • Docs kept in step. The decorator-inclusion behaviour change is stated in the tool docstring, docs/spec/file-io-tools-mixin.mdx, and called out under its own heading in the PR description. The stale claim in skill_library_tools._reject_bad_name's docstring was corrected rather than left to rot.

Ovtcharov added 2 commits September 4, 2026 11:33
...dated
`test_the_substrate_deletes_outside_the_skills_root` asserted the bug as a
documented fact, to justify why the model-facing `_reject_bad_name` guard
exists. The substrate refuses those names now, so the assertion is false and
CI caught it.
Its docstring said to drop `_reject_bad_name` if this ever happened. Keeping
it instead, for two reasons the test now records: `load_skill` resolves
through the manager and never reaches the guarded joins, and a tool has to
answer the model with a structured error rather than raise. Defence in depth,
not a duplicate.
Also corrects the module docstring that still called this an unfixed
"substrate flaw reported upstream".
Refs amd#3356 
An exact span was not enough. A de-indented `Class.method` replacement is a
valid module-level `def`, so the syntax gate passed while the method quietly
left its class — `replace_function(path, "Alpha.run", 'def run(self): ...')`
with no leading indent reported success, relocated `run` to module level and
lost `Alpha.run`. That is the same destroy-and-report-success failure this
branch exists to end, and supporting qualified targets is what makes it the
likely mistake.
The rewritten module is now re-parsed before the write and the target must
still resolve at the same qualified name — the name encodes the scope, so this
checks placement, not just presence. The error names the cause: which scope
the definition moved to, or that the replacement defines a different name.
Renaming through this tool is refused rather than silently accepted; the
docstring already scoped it to replacing one definition.
Also from review: the duplicate-name error pointed at "rename one", which is
impossible for an @overload stack — it now names edit_python_file instead; and
`gaia skill create` wrote the raw argument into the manifest and its output
while the directory used the stripped name, so a name with surrounding
whitespace produced a manifest that disagreed with its own directory.
Refs amd#3356 

Copy link
Copy Markdown
Contributor Author

Closed in d58990a.

🟡 De-indented replacement relocating a method — fixed. The rewritten module is re-parsed before the write, and the target must still resolve at the same qualified name. The qualified name encodes the scope, so that check is the "parent scope unchanged" one, not just presence. Your exact case is pinned by test_a_de_indented_method_replacement_is_refused, with test_the_correctly_indented_replacement_of_the_same_method_succeeds beside it so the guard doesn't cost the legitimate edit. Both fail if I disable the check.

I went for a diagnosing message rather than wrapping _resolve_function_node, because reusing it directly gives "Function 'Alpha.run' not found in file" — the nested-name hint only fires for bare names, so it never mentions that run now exists at module level. The version I landed names where it went:

The replacement moves 'Alpha.run' to 'run'. Indent new_implementation to match the definition it replaces — nothing was written.

On the rename caveat: agreed, out of contract — refused. The docstring said "replace one function definition", and a silent rename leaves callers dangling with status: success, which is the failure class this PR is about. test_a_replacement_that_renames_the_function_is_refused pins it, and the message says so explicitly rather than reporting a generic miss. The tool docstring and the spec page now both state it.

🟢 1 — @overload. Taken as suggested. "Rename one" was wrong advice for a stack that is supposed to repeat a name; it now points at edit_python_file.

🟢 2 — _handle_create. Real bug, taken. validated_skill_name once at the top, then name for Skill(...), _scaffold_body, the scaffold-parse source, and both printed lines.

🟢 3 — symlinked skill directories. Correct reading, and I'm leaving it. install --force over a linked dev checkout now fails where it used to succeed. That path did rmtree(target), and on Windows rmtree follows a directory junction, so "succeeded" there meant deleting the checkout's contents — the same data loss this PR is closing, just via a link instead of a name. Refusing with a message that says to remove the link is the honest behaviour. If a real workflow depends on it, the fix is for install to resolve the link and refuse only when the target escapes the root — worth its own issue rather than widening this one.

Verification: pytest hub/agents/gaia/python/tests/ tests/unit/test_skill_loader.py tests/unit/test_agent_lazy_skill_prompt.py tests/unit/test_starter_skills.py tests/unit/test_skill_sets.py tests/unit/test_skills_{install,cli,migrate,marketplace,format,manager}.py tests/unit/test_file_write_guardrails.py tests/unit/test_skills_name_containment.py tests/unit/test_replace_function_spans.py -q1110 passed, 3 failed, 16 skipped. The 3 are the test_real_cli_* subprocess tests hitting RuntimeError: Could not determine home directory on Windows; identical on the base commit.

python util/lint.py --all → black, isort, flake8, bandit, conventions all pass. It exits 1 on 9 pre-existing pylint E1101s for os.killpg / os.getpgid / os.geteuid in daemon/sidecars/{ledger,manager}.py and installer/lemonade_installer.py — POSIX-only members flagged because pylint ran on Windows. Same 9, same three files, none in this diff.

... rules
Every tool docstring is serialized into the schema sent to the model on each
turn. This one had grown to 1110 characters — 2.6x the median of its
neighbours in the file, and the longest by a wide margin — because the
scope-placement fix put its rationale inline.
Keeps what the model needs to call it correctly: what it replaces, that
'Class.method' is accepted, and that new_implementation carries the decorators
and the indentation of the definition it replaces. The rationale and failure
modes were already written up in docs/spec/file-io-tools-mixin.mdx, which is
where they belong — the spec is for humans, the docstring is for the model.
Found while investigating a tool_selection eval result. It was not the cause
(see the PR): the scenario runs the `doc` profile, which never registers
file_io, and `file`/`full` pop replace_function out — so this docstring is in
no chat profile's schema. Trimming did not move the number. Kept because a
1110-char docstring for one rarely-reachable tool is worth removing anyway.
Refs amd#3356 

Copy link
Copy Markdown
Contributor Author

Your last open item — "one eval pass on a box with working judge auth" — now has results.

ANTHROPIC_API_KEY= gaia eval agent --category tool_selection (judge claude-opus-5, Lemonade 11.5.0 / Gemma-4-E4B-it-GGUF): 3/5 passed, avg 7.5. The auth failure I reported earlier was not an account limit — load_dotenv() walks up into the parent checkout's .env and picks up an exhausted key, which flips eval/runner.py into --bare and cuts OAuth off. Setting the variable explicitly empty fixes it.

smart_discovery is red, and it is not this change. Reverting every source file in this PR to upstream/main fails it the same way:

upstream/main (reverted) FAIL 2.35 / FAIL 3.77 (empty index)
this branch FAIL 2.72 / 2.40 / 3.40 / 2.30

Six runs, two branches, three index states, never a pass. The judge attributes it to the allowlist — index_document and read_file denied on a directory where list_files succeeds (src/gaia/security.py:375) — filed as #3370, with #3369 and #3371 for two related gaps in the eval gate.

There is also a mechanical reason this PR could not have moved it: smart_discovery runs the doc profile, whose tool_groups=('doc_rag',) never registers file_io, and file/full pop replace_function out (agent.py:1962). The docstring this PR edits is in no chat profile's tool schema.

One change you did not ask for, in 8830646f: the replace_function docstring is trimmed 1110 → 631 chars, back in line with its neighbours. I made it the outlier in the scope-placement fix, and every tool docstring ships in the schema each turn. It did not move the eval — I checked before keeping it — and the rationale it lost is already in docs/spec/file-io-tools-mixin.mdx.

Full numbers, the sibling comparison, and why the committed baseline is not a valid comparator are in the PR's Evidence section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@kovtcharov-amd kovtcharov-amd Awaiting requested review from kovtcharov-amd kovtcharov-amd is a code owner

Assignees

No one assigned

Labels

agents documentation Documentation changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Two tools destroy data and report success: gaia skill remove . and replace_function

1 participant

AltStyle によって変換されたページ (->オリジナル) /