-
Notifications
You must be signed in to change notification settings - Fork 162
fix(security): stop PowerShell sinks from executing untrusted text - #3359
fix(security): stop PowerShell sinks from executing untrusted text #3359kovtcharov-amd merged 3 commits into
Conversation
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
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:104installs only-e ".[api]"and runspytest tests/unit/(line 124) → whole file skipped..github/workflows/test_chat_agent.yml:74installs the chat package but runs onlyhub/agents/chat/python/tests/andtests/unit/agents/test_registry.py..github/workflows/test_gaia_agent.yml:74installs it but runs four unrelatedtests/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_stemis 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 npmCHANGELOG.mdall move six→seven together; I checkedSPEC.mdandREADME.mdand neither enumerates the set, so there's no drift left behind. -NoProfileadded 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
kovtcharov
commented
Sep 4, 2026
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.yaml — success_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-only → blocked_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
kovtcharov
commented
Sep 4, 2026
|
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 I am still not quoting a scorecard, because the run I have does not measure this branch. On what the eval can and cannot show here: Both of your blocking items remain fixed as of 🔍 Technical detailsRouting evidence
Correction carried into
So under the harness the gated call costs a ~60s stall before its denial. Baseline caveat for whenever a valid run lands: the committed |
...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>
Uh oh!
There was an error while loading. Please reload this page.
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
.pptxthrough 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 innotify_desktop, whose PowerShell fallback is the only path on every Windows install becauseplyeris in no dependency list; prompt-injected content steering that tool'smessagegot 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
convert_pptx_to_pdfandnotify_desktopeach send a fixed module-level script and hand their values to the child through its environment, where they are values and never code._sanitize_stemalso 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_desktopjoins 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.plyeris 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.yamlfollows 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=1exercises the tool body instead.importorskipraisedSkippedduring 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 totest_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.test_unit.ymlshape,-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 (thetest_gaia_agent.ymlshape): 10 passed.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, andgaia eval agent --audit-onlyreportsblocked_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-onlyosmembers (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):...and the same for the notification message:
After, both commands are constants that PowerShell's own parser accepts, and the payload arrives as data:
Launching the real
notify_desktopcommand with the payload as its title shows it rendered as text, not executed: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_KEYin the parent checkout's.envreaches the eval process becauseload_dotenv()walks up the directory tree, which flipsrunner.py:949into passing--bare, and--barerestricts auth to that key and never reads the working OAuth session. Unsetting the shell variable does not help; only an explicit empty value does, becauseload_dotenv()will not override a variable that is already present:ANTHROPIC_API_KEY= gaia eval agent --category tool_selectionWith 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 --backendonly reaches the prompt text (runner.py:501); the MCP server that carries every request is launched fromeval/mcp-config.jsonwith--stdioand no--backend, so it uses its hardcodedhttp://localhost:4200default. 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 reportednotify_desktopreturning 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 15profilespec_characterizationrows before and after the change gives no diff, because the confirmation set is read only by_execute_tooland 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.pyparametrizes over the whole base set and passes withnotify_desktopin it (the tool body never runs when the surface cannot approve), andeval/scenarios/web_system/desktop_notification.yamlhas 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_notificationis categoryweb_system, nottool_selection—grep -rl notify_desktop eval/scenarios/tool_selection/returns nothing. So atool_selectionpass shows the gate causes no collateral regression; it does not exercise the gate. Only theweb_systemscenario does, and it is run separately. A cleantool_selectionscore 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 atTOOL_CONFIRM_TIMEOUT_SECONDS(60s) and the call is denied. The outright-refusal path belongs togaia/api/sse_handler.py, the sidecar's/query. An earlier revision of this body conflated the two.documents.pyis touched only in_sanitize_stem, whose behaviour is covered by direct unit tests (the router's own suite needs a live embedder).Checklist
Closes #3357).python util/lint.py --all,pytest tests/unit/).docs/guides/gaia.mdxand the flagship'sSKILL.mdboth said "six gated tools" and now say seven; the flagship'sCHANGELOG.mdcarries the security entry.