Skip to content

Navigation Menu

Sign in
Sign up

fix(security): stop PowerShell sinks from executing untrusted text - #3359

Merged
kovtcharov-amd merged 3 commits into
amd:main from
kovtcharov:fix/powershell-command-injection
Sep 7, 2026
Merged

fix(security): stop PowerShell sinks from executing untrusted text #3359
kovtcharov-amd merged 3 commits into
amd:main from
kovtcharov:fix/powershell-command-injection

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Summary

Two Windows code paths pasted attacker-influenced text into a PowerShell command string, so an apostrophe in a filename or a notification message became code the machine ran. Both now keep the command constant and pass their values out-of-band.

Why

Upload a .pptx through the Agent UI whose name contains an apostrophe, and the rest of the name ran as PowerShell the moment the document was indexed — no tool call, no model decision, no confirmation modal. Receiving and indexing a shared deck was the whole attack. The same class sat one step behind a tool argument in notify_desktop, whose PowerShell fallback is the only path on every Windows install because plyer is in no dependency list; prompt-injected content steering that tool's message got code execution with no click.

After this change, a quote in either place is inert text. The apostrophe still survives in the document's display name — the fix is in how the value travels, not in rejecting the name.

Linked issue

Closes #3357

Changes

  • Neither PowerShell sink interpolates any more. convert_pptx_to_pdf and notify_desktop each send a fixed module-level script and hand their values to the child through its environment, where they are values and never code.
  • _sanitize_stem also replaces the apostrophe and backtick — defense in depth, so an uploaded name can't become code even if a future caller forgets the rule above.
  • notify_desktop joins the confirmation-gated set. It spawns a PowerShell child on a model's say-so; that belongs with the other process-spawning tools. This changes tool gating, an LLM-affecting surface — see Evidence.
  • The gate applies on every platform — a deliberate call, not a side effect. Only Windows spawns a process today, so on macOS/Linux the prompt precedes a call that errors anyway (plyer is in no dependency list). Gating everywhere still wins: the set is keyed on tool name, not host, so a platform-conditional entry would be a new mechanism and would silently stop applying the day a non-Windows backend becomes reachable. The reasoning is recorded on the set entry rather than left to be rediscovered.
  • eval/scenarios/web_system/desktop_notification.yaml follows the code. It counted "cannot send notifications" as a failure, which is exactly what a gated tool returns on a surface with no one to ask. It now passes on the refusal path — and fails if the agent narrates an approval it never got. GAIA_AUTO_APPROVE_TOOLS=1 exercises the tool body instead.
  • The regression tests now actually run in CI. A module-level importorskip raised Skipped during collection and took the whole file with it, including the PPTX half that needs no agent package. Scoped to the one class that needs the chat wheel, and added to test_gaia_agent.yml — the lane that installs it.

Test plan

  • pytest tests/unit/test_powershell_command_injection.py — 10 regression tests. Each asserts the rendered command is byte-for-byte the constant when the input carries a breakout payload; asserting only "the subprocess was invoked" would not have caught either bug, which is why both shipped.
  • Both CI lanes reproduced locally, showing the tests execute rather than skip. With the chat wheel uninstalled (the test_unit.yml shape, -e ".[api]"): 8 passed, 2 skipped — the PPTX-filename half, _sanitize_stem, and the gating check all run; only the two notification tests skip. With it installed (the test_gaia_agent.yml shape): 10 passed.
  • The same file on unpatched upstream/main: 8 of 10 fail. The 2 that pass are the deliberate controls (a benign filename, the pre-existing illegal-character set).
  • pytest tests/unit/rag tests/unit/agents tests/unit/api/test_sse_confirmation_gate.py hub/agents/chat/python/tests hub/agents/gaia/python/tests — 1039 passed, 50 skipped.
  • pytest tests/test_eval.py — 140 passed, and gaia eval agent --audit-only reports blocked_scenarios: [] after the scenario edit.
  • python util/lint.py --all — Black, isort, Flake8, Bandit, import validation, agent conventions all pass. Pylint reports 9 errors, all pre-existing POSIX-only os members (killpg, geteuid) flagged on Windows in files this PR does not touch.
  • pytest tests/integration/test_documents_router.py — 11 passed; 1 failure + 2 errors are identical with and without this change (they need a running Lemonade embedder).

Evidence

CLI / probe — before → after, both executed on Windows 11 + PowerShell 5.1.

Before, a payload in the filename escapes the literal and the injected statement really runs (the probe writes a marker instead of launching calc):

$x = 'C:\docs\quarterly'; Set-Content -LiteralPath '...\C3_MARKER.txt' -Value C3_INJECTED; '.pptx'; ...
rc: 0 marker written: True -> C3_INJECTED

...and the same for the notification message:

$null = @('hi'); Set-Content -LiteralPath '...\C2_MARKER.txt' -Value C2_INJECTED; ('x', 'title')
rc: 0 marker written: True -> C2_INJECTED

After, both commands are constants that PowerShell's own parser accepts, and the payload arrives as data:

$ErrorActionPreference = 'Stop'; $in = $env:GAIA_PPTX_CONVERT_IN; $out = $env:GAIA_PPTX_CONVERT_OUT; ...
-> PARSE OK
Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show([string]$env:GAIA_NOTIFY_MESSAGE, [string]$env:GAIA_NOTIFY_TITLE)
-> PARSE OK
PPTX_IN=[C:\docs\quarterly'; Set-Content -LiteralPath '...\AFTER_MARKER.txt' -Value PWNED; '.pptx]
TYPE=String marker written (must be False): False

Launching the real notify_desktop command with the payload as its title shows it rendered as text, not executed:

child alive (MessageBox displayed): True | exit code: None
MessageBox window title: "t'); calc; ('"

Agent eval — the harness now runs; a valid-for-this-branch result is still outstanding.

An earlier revision of this section blamed an exhausted judge account for an HTTP 400. That diagnosis was wrong — see #3367 . A stale ANTHROPIC_API_KEY in the parent checkout's .env reaches the eval process because load_dotenv() walks up the directory tree, which flips runner.py:949 into passing --bare, and --bare restricts auth to that key and never reads the working OAuth session. Unsetting the shell variable does not help; only an explicit empty value does, because load_dotenv() will not override a variable that is already present:

ANTHROPIC_API_KEY= gaia eval agent --category tool_selection

With that, the harness runs properly: 5 real judged scenarios where the same command previously produced 5 instant ERRORED. Recorded here so the next contributor who meets that 400 does not spend an afternoon on it.

The run so far is not yet evidence for this branch, so no scorecard is quoted. gaia eval agent --backend only reaches the prompt text (runner.py:501); the MCP server that carries every request is launched from eval/mcp-config.json with --stdio and no --backend, so it uses its hardcoded http://localhost:4200 default. On this multi-worktree machine that port was served by a different branch (87b9479c), which has no confirmation gate — and the judge's trace duly reported notify_desktop returning success with no denial, which is impossible here. A correctly-routed re-run is queued; its numbers will replace this paragraph.

What the eval would have been checking is instead established directly: the model-facing surface is byte-identical. Hashing (system prompt, registered tool set) for all 15 profilespec_characterization rows before and after the change gives no diff, because the confirmation set is read only by _execute_tool and never reaches prompt or schema assembly. The model sees exactly the tools, docstrings and prompt it saw before; what changed is what happens at call time.

The hash check below is corroboration, not a substitute — it covers the prompt surface only and does not exercise the changed call-time behaviour. Nothing about hashing a prompt says what happens when the model actually calls notify_desktop, and that is precisely the behaviour this PR changes. Two things carry that half instead: tests/unit/api/test_sse_confirmation_gate.py parametrizes over the whole base set and passes with notify_desktop in it (the tool body never runs when the surface cannot approve), and eval/scenarios/web_system/desktop_notification.yaml has been updated so the refusal path is the expected outcome rather than a failure — it now also fails if the agent reports a notification as sent when the tool returned a denial.

Which scenarios actually touch this change: desktop_notification is category web_system, not tool_selectiongrep -rl notify_desktop eval/scenarios/tool_selection/ returns nothing. So a tool_selection pass shows the gate causes no collateral regression; it does not exercise the gate. Only the web_system scenario does, and it is run separately. A clean tool_selection score must not be read as coverage of the gating change.

On that surface the denial arrives via timeout rather than an outright refusal: the Agent UI's handler (gaia/ui/sse_handler.py) can collect an approval — it raises a permission modal and blocks — but nothing answers it under the harness, so the prompt expires at TOOL_CONFIRM_TIMEOUT_SECONDS (60s) and the call is denied. The outright-refusal path belongs to gaia/api/sse_handler.py, the sidecar's /query. An earlier revision of this body conflated the two.

  • Agent exposed in the Agent UI — N/A. No UI surface changed; documents.py is touched only in _sanitize_stem, whose behaviour is covered by direct unit tests (the router's own suite needs a live embedder).
  • MCP tools / servers — N/A. No MCP tool or server definition changed.
  • CLI — the probe output above, plus the lint and pytest runs in the test plan.
  • HTTP API / REST — N/A. No endpoint, request shape or response shape changed.

Checklist

  • I have linked a GitHub issue above (Closes #3357).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally (python util/lint.py --all, pytest tests/unit/).
  • I have attached real-world evidence matched to the surface I changed, or marked each surface N/A.
  • I have updated documentation if user-visible behavior changed — docs/guides/gaia.mdx and the flagship's SKILL.md both said "six gated tools" and now say seven; the flagship's CHANGELOG.md carries the security entry.

A deck's filename and a notification message were pasted straight into a
single-quoted PowerShell literal inside `powershell -Command`. An apostrophe
closed the literal and the remainder ran as PowerShell.
The PPTX->PDF converter is the sharper of the two: `_sanitize_stem` strips
`<>:"/\|?*` but not `'`, so a deck uploaded through the Agent UI executed its
own name at index time -- no tool call, no model decision, no modal.
`notify_desktop` is the same class one step behind a tool argument, and since
`plyer` is in no dependency list its PowerShell fallback is the only path on
Windows.
Both now keep the command text a module constant and hand the values to the
child through its environment, where they are values and never code. Two
defensive follow-ons: `_sanitize_stem` also replaces the apostrophe and
backtick, and `notify_desktop` joins the confirmation-gated set alongside the
other tools that spawn a process.
Gating `notify_desktop` changes tool gating, which is LLM-affecting. The
model-facing surface is not: the system prompt and registered tool set hash
identically across all 15 profilespec characterization rows, because the
confirmation set is read only by `_execute_tool`, never by prompt or schema
assembly.
Closes amd#3357 
@github-actions github-actions Bot added documentation Documentation changes rag RAG system changes tests Test changes performance Performance-critical changes agents labels Sep 4, 2026

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Verdict: Request changes

The security fix itself is correct and complete — both PowerShell call sites now send a fixed script and pass the untrusted values through the child's environment, so a quote in a filename or a notification message is inert text. I also checked the rest of the codebase for the same pattern and found no other PowerShell sink that still builds its command from input.

Two things to fix before merge, both small:

The new regression tests never run. A guard at the top of the test file skips the whole file whenever the chat agent package isn't installed — and no CI job installs that package and runs this file. The job that runs the unit suite silently skips all ten tests, including the ones for the higher-severity filename path, which don't need that package at all. So the fix ships with no guard against it regressing. Scoping the skip to just the two notification tests fixes it.

The desktop-notification eval scenario now contradicts the code. Making the notification tool require approval means that scenario ends in a refusal on any surface that can't collect one, and its pass/fail criteria explicitly count that as a failure. The scenario file wasn't updated, so it's left in the repo as a known-broken check.

I want to flag one design question rather than block on it: the approval requirement is applied on every platform, but only Windows spawns a process. On macOS and Linux the tool has no working backend today, so the user now gets an approval prompt in front of a call that will error out either way. Gating it everywhere is defensible for consistency — just worth a deliberate decision, not a side effect.

Real-world evidence

No automated evidence bundle was produced for this run, so the evidence here is what the PR description reports. It shows before/after probe runs on Windows 11 + PowerShell 5.1: on the unpatched code the injected statement really executes (a marker file is written) for both the filename and the notification message; after the change both commands parse as constants, the payload arrives as a String env value, and the marker is not written. It also shows the real notification launched with a payload as its title rendering it as window-title text rather than executing it. That directly supports the verdict on the fix.

The agent eval was not run — the PR reports the judge subprocess failing on account credit, and substitutes a hash comparison of the prompt and tool set showing the model-facing surface is unchanged. That's a reasonable substitute for the prompt surface, but it doesn't exercise the changed call-time behaviour, which is part of why the stale eval scenario above matters.

🔍 Technical details

🟡 Important

1. Module-level importorskip makes the entire regression file inert in CI (tests/unit/test_powershell_command_injection.py:141)

pytest.importorskip("gaia_agent_chat", ...) at module scope raises Skipped during collection, aborting the whole module — including TestPptxToPdfConversion, TestSanitizeStem, and TestNotifyDesktopIsGated, none of which import gaia_agent_chat.

Confirmed against CI:

  • .github/workflows/test_unit.yml:104 installs only -e ".[api]" and runs pytest tests/unit/ (line 124) → whole file skipped.
  • .github/workflows/test_chat_agent.yml:74 installs the chat package but runs only hub/agents/chat/python/tests/ and tests/unit/agents/test_registry.py.
  • .github/workflows/test_gaia_agent.yml:74 installs it but runs four unrelated tests/unit/ files.

Net: no lane executes this file. The _sanitize_stem class already models the right pattern (tests/unit/test_powershell_command_injection.py:397-400) — mirror it on the two chat classes.

class TestNotifyDesktopScript:
 @pytest.fixture(autouse=True)
 def _requires_chat_wheel(self):
 pytest.importorskip(
 "gaia_agent_chat", reason="gaia-agent-chat wheel not installed in this env"
 )
 def test_script_constant_reads_both_values_from_the_environment(self):

Apply the same fixture to TestNotifyDesktopScript.test_quote_in_message_cannot_alter_the_command (covered by the class-level fixture above), drop the module-level gaia_agent_chat = pytest.importorskip(...) binding, and leave TestNotifyDesktopIsGated ungated since it only imports gaia.agents.base.agent.

Separately worth considering: adding this file to the test_gaia_agent.yml pytest list (line 97-102), which is the one lane that has the chat package installed.

2. eval/scenarios/web_system/desktop_notification.yaml contradicts the new gating

Its success_criteria says FAIL if agent claims it cannot send notifications at all, and expected_outcome expects a send or a graceful plyer error. With notify_desktop in TOOLS_REQUIRING_CONFIRMATION (src/gaia/agents/base/agent.py:211), any surface that can't collect approval terminates with exactly that refusal. The PR description acknowledges this but leaves the file unchanged, so the repo now carries a scenario that fails by design. Either update the criteria to expect the refusal on non-prompting surfaces, or set GAIA_AUTO_APPROVE_TOOLS=1 for the eval lane (src/gaia/agents/base/console.py:66) if the intent is to keep exercising the tool body.

🟢 Minor

3. Cross-platform gating cost (src/gaia/agents/base/agent.py:208-211) — the base set is platform-agnostic, but only the Windows branch of notify_desktop (hub/agents/chat/python/gaia_agent_chat/agent.py:1673-1694) spawns a process, and plyer is in no dependency list, so on macOS/Linux the prompt precedes a guaranteed plyer not installed error. Fine as a deliberate call; the comment on the set entry could say so.

4. Brittle quote assertion (tests/unit/test_powershell_command_injection.py:355) — assert "'" not in ps_script.replace("'Stop'", "") breaks the moment the constant gains another legitimate quoted literal. Asserting the payload's absence (already done on the two lines above) plus byte-equality with the constant covers the same ground without the maintenance edge.

Strengths

  • The tests assert the right thing. Byte-equality against the module constant, plus env[...] == payload, is what makes these real regression tests — "the subprocess was invoked" would have passed on the vulnerable code, which is exactly why both bugs shipped. The benign-filename control (test_benign_filename_still_converts_through_the_same_path) also pins that the fix isn't "reject anything interesting."
  • Fix at the sink, not the input. Sanitizing _sanitize_stem is kept explicitly as defense in depth (src/gaia/ui/routers/documents.py:241-246) while the actual correctness lives in the two constants. The apostrophe still survives in the display name, so the UX cost is zero.
  • Doc surfaces are in sync. docs/guides/gaia.mdx, hub/agents/gaia/npm/SKILL.md (three places), and the npm CHANGELOG.md all move six→seven together; I checked SPEC.md and README.md and neither enumerates the set, so there's no drift left behind.
  • -NoProfile added to the notification spawn, closing a separate hijack path via a writable user profile script.

... eval scenario
The module-level `importorskip("gaia_agent_chat")` raised Skipped during
collection, taking the whole file with it -- including the PPTX-filename and
`_sanitize_stem` cases, which import no agent package and are the
higher-severity half. `test_unit.yml` installs only `.[api]`, so it skipped all
ten; the two lanes that install the chat package run other paths. Net: nothing
guarded the fix.
The skip is now an autouse fixture on the one class that needs the wheel, so
8 of 10 run in `test_unit.yml` and a new step in `test_gaia_agent.yml` -- the
lane that has the chat package -- runs all 10.
`desktop_notification.yaml` counted "cannot send notifications" as a failure,
which is exactly what a gated tool returns on a surface with no one to ask. It
now passes on the refusal path and fails if the agent narrates an approval it
never got. `GAIA_AUTO_APPROVE_TOOLS=1` exercises the tool body instead.
Gating on every platform is deliberate: the set is keyed on tool name, not
host, and a platform-conditional entry would stop applying the day a
non-Windows backend becomes reachable. Recorded on the set entry.
Also drops a quote assertion that would break on any future legitimate quoted
literal in the constant -- byte-equality with the constant already covers it.
Refs amd#3357 
@github-actions github-actions Bot added the devops DevOps/infrastructure changes label Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Both blockers fixed in a023497f, and the design question answered deliberately.

The tests now actually run. You were right that nothing guarded the fix. The skip is scoped to the one class that needs the chat wheel, so the higher-severity filename half runs everywhere, and a new step in the gaia-agent lane — the one with the chat package installed — runs the whole file. I reproduced both lanes locally rather than assuming: with the chat wheel uninstalled, 8 pass and only the 2 notification tests skip; with it installed, all 10 pass.

The eval scenario follows the code now. It passes on the refusal path, and it gained a failure I think matters more than the one you flagged: the agent must not report a notification as sent when the tool returned a denial. GAIA_AUTO_APPROVE_TOOLS=1 still exercises the tool body for anyone who wants that.

On gating every platform — keeping it, on purpose. The set is keyed on tool name, not host, so a platform-conditional entry would be a new mechanism, and it would stop applying the day a non-Windows backend becomes reachable. The cost is a prompt in front of a call that errors anyway on macOS/Linux; I'd rather pay that than have the gate quietly lapse later. The reasoning is now on the set entry so the next reader doesn't have to reconstruct it.

Also took the two minor points: dropped the brittle quote assertion (byte-equality with the constant already covers it), and said plainly in the PR body that the hash comparison covers the prompt surface only and does not exercise the changed call-time behaviour — which is what the scenario and the SSE gate test are for.

🔍 Technical details

1. Module-level importorskip — dropped the module-level binding; TestNotifyDesktopScript carries an autouse _requires_chat_wheel fixture mirroring the TestSanitizeStem pattern. TestNotifyDesktopIsGated left ungated (it only imports gaia.agents.base.agent).

Both lanes reproduced locally:

# test_unit.yml shape — chat wheel uninstalled
8 passed, 2 skipped
 TestPptxToPdfConversion::* PASSED (x4)
 TestSanitizeStem::* PASSED (x3)
 TestNotifyDesktopIsGated::* PASSED
 TestNotifyDesktopScript::* SKIPPED (x2)
# test_gaia_agent.yml shape — chat wheel installed
10 passed

.[api] carries fastapi>=0.115.0 (setup.py:159-162), so the _sanitize_stem cases execute in test_unit.yml too rather than skipping on the import.

New step added after "Run Skill Framework Tests" in test_gaia_agent.yml rather than appended to that step's list — a command-injection file under a heading that says SKILL FRAMEWORK TESTS would misfile it for the next reader.

2. desktop_notification.yamlsuccess_criteria now PASSes on three outcomes (sent / graceful plyer error / refused-for-approval-and-said-so) and FAILs on two: never attempting the tool, or reporting a send when the tool returned a denial. expected_outcome names the refusal as the expected path on the eval's non-prompting surface and points at GAIA_AUTO_APPROVE_TOOLS=1.

pytest tests/test_eval.py → 140 passed; gaia eval agent --audit-onlyblocked_scenarios: [].

3. Cross-platform gating — comment on the set entry (src/gaia/agents/base/agent.py) now reads: "Gated on every platform on purpose: this set is keyed on tool name, not host, and a platform-conditional entry would silently stop applying the day a non-Windows backend (plyer) becomes reachable."

4. Brittle quote assertion — removed.

Verification: python util/lint.py --all — Black, isort, Flake8, Bandit, imports, agent conventions pass; the 9 Pylint errors are the pre-existing POSIX-only os.killpg / os.geteuid hits on Windows, in files this PR doesn't touch, unchanged from before. pytest tests/unit/test_powershell_command_injection.py tests/unit/rag tests/unit/agents tests/unit/api/test_sse_confirmation_gate.py hub/agents/{chat,gaia}/python/tests → 1039 passed, 50 skipped.

The scenario said the eval drives "a surface that cannot collect an approval".
That conflates two different `SSEOutputHandler` classes. `gaia/api/sse_handler`
(the sidecar's /query) denies outright. `gaia/ui/sse_handler` -- the one the
eval actually drives via /api/chat/send -- CAN collect an approval: it is
`blocking_confirmation = True`, raises a permission modal, and waits.
The denial under the harness is real but arrives a different way: nothing
answers the modal, so the prompt expires after TOOL_CONFIRM_TIMEOUT_SECONDS
(60s) and the call is denied. Says so now, including that the turn costs ~60s
more than an ungated one, so the next reader doesn't take the stall for a hang.
Refs amd#3357 

Copy link
Copy Markdown
Contributor Author

Follow-up on the eval, since my earlier note in the body was wrong twice over and the body is now corrected.

The harness runs — my "exhausted judge account" diagnosis was wrong. The real cause is #3367: a stale ANTHROPIC_API_KEY in the parent checkout's .env reaches the eval process because load_dotenv() walks up the tree, which flips runner.py:949 into --bare, and --bare never reads the OAuth session. ANTHROPIC_API_KEY= gaia eval agent ... fixes it with no key and no edit, and turns 5 instant ERRORED into 5 real judged scenarios.

I am still not quoting a scorecard, because the run I have does not measure this branch. --backend reaches only the prompt text; the MCP server that carries the traffic is launched from eval/mcp-config.json without --backend and falls back to a hardcoded localhost:4200. On this machine that port was served by a different worktree's branch. The tell was in the result itself: the judge recorded notify_desktop returning success with no denial, which cannot happen on a branch where the tool is confirmation-gated. A correctly-routed re-run is queued and its numbers will go in the body.

On what the eval can and cannot show here: desktop_notification is category web_system, not tool_selection. A clean tool_selection result would show the gate causes no collateral regression — it would not exercise the gate at all. Worth separating, since a green category score can quietly imply coverage it does not have.

Both of your blocking items remain fixed as of a023497f (tests now execute in two lanes; scenario updated), plus f4b253f9 correcting how the denial actually arrives.

🔍 Technical details

Routing evidence

$ cat eval/mcp-config.json # args: ["run","python","-m","gaia.mcp.servers.agent_ui_mcp","--stdio"]
$ python -m gaia.mcp.servers.agent_ui_mcp --help
 --backend BACKEND GAIA Agent UI backend URL (default: http://localhost:4200)
$ grep -nE "getenv|environ" src/gaia/mcp/servers/agent_ui_mcp.py | grep -i backend # no env override

runner.py:501 interpolates backend_url into the prompt (Backend: {backend_url}) and nowhere else, so the simulator is told one address and its tools talk to another. Worth a separate issue: thread --backend into the MCP launch, or have preflight_check fail loudly when the flag disagrees with mcp-config.json. Out of scope here.

Correction carried into f4b253f9 — there are two SSEOutputHandler classes and the body previously conflated them:

blocking_confirmation Behaviour with nobody to ask
gaia/api/sse_handler.py (sidecar /query) False denies outright
gaia/ui/sse_handler.py (Agent UI, what the eval drives) True raises a modal, blocks, denies at TOOL_CONFIRM_TIMEOUT_SECONDS = 60s

So under the harness the gated call costs a ~60s stall before its denial. eval/scenarios/web_system/desktop_notification.yaml now says that, so the stall is not mistaken for a hang.

Baseline caveat for whenever a valid run lands: the committed gemma-4-e4b-d71cd914 baseline was judged by claude-sonnet-4-6 over 4 scenarios; current runs use claude-opus-5 over 5 (data_vs_recall_disambiguation postdates it). The compare tool emits its own judge-mismatch warning. #3371 tracks refreshing the baselines.

kovtcharov-amd added this pull request to the merge queue Sep 7, 2026
Merged via the queue into amd:main with commit dd98c0f Sep 7, 2026
48 checks passed
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Sep 7, 2026
...ss (amd#3358)
## 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 amd#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
- [x] `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 five
`replace_function` defect tests.
- [x] `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 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.
- [x] `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 `E1101`s 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.
- [x] **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 abe87ed) ----- ----- 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 (abe87ed), reverted 180's paths FAIL 2.35
upstream/main (abe87ed), 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 amd#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 amd#3364 amd#3359 this PR
data_vs_recall_disambiguation FAIL 6.40 PASS 9.05 FAIL 6.50 (flaky — also red on amd#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 (amd#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 (amd#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.
---------
Co-authored-by: Ovtcharov <kovtchar@amd.com>
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 devops DevOps/infrastructure changes documentation Documentation changes performance Performance-critical changes rag RAG system changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

PowerShell command injection: a filename and a notification message reach -Command as code

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