-
Notifications
You must be signed in to change notification settings - Fork 162
fix(skills,tools): stop two tools deleting data while reporting success - #3358
fix(skills,tools): stop two tools deleting data while reporting success #3358kovtcharov wants to merge 5 commits into
Conversation
`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
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 skillwith exit 0 and an empty skills directory, and the new run refusing with exit 4 and everything intact, plusgaia skill remove demo-skillstill working. - Tool behaviour — a side-by-side file dump for the same
replace_functioncall showingCONSTANT = 42and 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
- The duplicate-name error suggests a fix that doesn't apply to
@overload(file_io_tools.py:79).@overloadstacks are a normal reason for three same-nameddefs, 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."
)
_handle_createwrites the unvalidated name into the manifest (src/gaia/skills/cli.py:557,575).skill_directorystrips the name, butSkill(name=args.name, ...)and the successprintstill 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)
- 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 throughgaia skill, not just protected fromrmtree. The error text tells the user to delete the link themselves, which is the right answer forremove— just flagging thatinstall --forceover 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 undersrc/gaia/skills/—cli.py:570,cli.py:601,install.py:272,install.py:522,migrate.py:948— and all five now derive their target fromskill_directory. No unguardedroot / namesurvives 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 importwithout--nametakes the name from the downloadedSKILL.md; catching that is the part a narrower fix would have missed, andtest_import_refuses_an_escaping_bundle_supplied_namepins it. - Tests assert on the disk, not the return value.
assert_intact()checking the signing key, trust store andconfig.jsonafter every refusal is exactly right for this bug class — a guard that raised after thermtreewould pass a return-value-only test. Same for thereplace_functiontests assertingCONSTANT = 42survives rather thanstatus == "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 inskill_library_tools._reject_bad_name's docstring was corrected rather than left to rot.
...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
kovtcharov
commented
Sep 4, 2026
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 -q → 1110 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
kovtcharov
commented
Sep 4, 2026
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.
Uh oh!
There was an error while loading. Please reload this page.
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 storeand lock included — printed "✅ Removed skill" and exited 0;
..took~/.gaiaand its
config.jsonwith it. Every skill entry point resolvedroot / namewithout validating the name, and
pathlibcollapses.back to the root, leaves..at its parent, and lets an absolute name replace the root outright. Theimport path matters most: without
--namethe name comes from the importedbundle's own
SKILL.md, so it is supplied by whatever the user downloaded.replace_functionfound the end of its target by scanning forward for the nextsame-indent
def/class, so module constants and the next function's decoratorssat 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.walkhit anywhere in themodule — so on a file with two classes that each define
run, it rewrote thewrong 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
gaia.skills.namingguards every join that can be created, overwritten ordeleted (remove / install / create / import / migrate). It validates against
the canonical
NAME_PATTERNand then asserts the target resolves to a directchild of the root — the assert is the load-bearing half, since it catches the
symlink and
\?\shapes a name pattern never sees.replace_functiontakes its span from the AST (end_lineno,decorator_list[0].lineno) and resolves the target by module-level name or anexplicit
Class.method. A bare name that exists only as a method is now anerror listing the qualified alternatives, not a guess.
decorators, so
new_implementationmust repeat any decorator the functionkeeps. The tool docstring and
docs/spec/file-io-tools-mixin.mdxboth say so.skill_library_tools._reject_bad_name's docstring claimed the substrate did novalidation — 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 -q→ 60 passed. Reverting only the source changes turns 26 of those 60 red, including all fivereplace_functiondefect 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 -q→ 571 passed, 3 failed, 4 skipped. The 3 failures aretest_skills_cli.py::test_real_cli_*, which shell out togaiaand hitRuntimeError: Could not determine home directoryon Windows; they fail identically on the unmodified base commit.python util/lint.py --all --fixthenpython util/lint.py --all→ black, isort, flake8, bandit, import validation and the convention checks all pass. It exits 1 on 9 pre-existing pylintE1101s foros.killpg/os.getpgid/os.geteuidindaemon/sidecars/{ledger,manager}.pyandinstaller/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 byclaude-opus-5, against Lemonade 11.5.0 withGemma-4-E4B-it-GGUF. Runeval-20260904-224526: 3/5 passed, avg 7.5. Four scenarios pass;smart_discoveryfails, and it fails identically on unmodifiedupstream/main— see Evidence.The empty
ANTHROPIC_API_KEY=is load-bearing:load_dotenv()reaches up into the parent checkout's.envand picks up an exhausted key, which flipseval/runner.pyinto--bareand 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). ScratchGAIA_CONFIG_DIR, onedemo-skillinstalled, signing key inskills/keys/.Before (base
abe87edc):After:
..and../xrefuse 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 reportingsuccess:CONSTANT = 42andbar's decorator are gone on the left;foo's own decoratorsurvived and now decorates the new body. On the right only
foochanged.Agent eval —
smart_discoveryis red, and it is not this change.The load-bearing evidence is a control run: revert every source file in this PR to
upstream/mainand the scenario fails the same way.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 bothindex_documentandread_file... whilelist_fileson the same directory succeeds" — filed as #3370. There is also a mechanical reason this PR cannot be the cause:smart_discoveryruns thedocprofile, whosetool_groups=('doc_rag',)never registersfile_io, andfile/fullpopreplace_functionout (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:
The two green
smart_discoveryresults 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-d71cd914was judged byclaude-sonnet-4-6over 4 scenarios, while these runs useclaude-opus-5over 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.dbthat pointed at another worktree. That is a manual environment fix, not routine — it removed theAccess deniedfailure and exposed a second one underneath (find_filesrestricted topdf,docx,txt, excluding the.mdhandbook), so the scenario still failed.The
replace_functiondocstring 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.replace_functionis a tool the agent calls, andgaia skill removeis CLI-only.