Skip to content

agent_sidecar: confine the workspace a request may name - #2

Open
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:fix/sidecar-confines-workspace
Open

agent_sidecar: confine the workspace a request may name#2
egeboy35 wants to merge 1 commit into
lkk688:mainfrom
egeboy35:fix/sidecar-confines-workspace

Conversation

@egeboy35

Copy link
Copy Markdown

The sidecar's README documents AGENT_WORKSPACE as "Project dir the agent reads/edits". The request decides otherwise — agent_sidecar.py:150:

root = os.path.abspath(body.get("root") or DEFAULT_ROOT)

No allowlist, no check that root sits under DEFAULT_ROOT. Whatever directory the body names becomes the agent's project root, and edge_agent.Tools then confines it faithfully to that. The service listens on 0.0.0.0 with no authentication, and edgeLLM/nextjs-nemotron-app/app/api/agent/route.js:75 forwards body.root verbatim from the browser.

Measured

Against the real app through httpx's ASGI transport — real routing, real body parsing, real edge_agent.Tools. The only substitution is _make_complete, because reaching the model needs a live endpoint and a key; the canned ReAct text it returns is what a prompt-injected model could return anyway.

POST /run  {"task": "...", "root": "<outside the workspace>"}
  -> HTTP 200
  [start]        root accepted: <outside the workspace>
  [observation]      1  PRIVATE-KEY-MATERIAL      <- read_file
  [observation]  wrote 18 bytes to PWNED.txt      <- write_file
  the file now exists outside the workspace: True

write_file and edit_file are in the same tool set, so this is write access to anything the sidecar's uid can reach.

The change

Adds AGENT_ALLOWED_ROOTS, os.pathsep-separated, defaulting to the workspace alone — which is the guarantee AGENT_WORKSPACE already implies. A request may name an allowed root or anything beneath it; anything else gets 403 with a message naming the variable to widen. Comparison is on realpath, so a link inside an allowed root cannot name a target outside it. /health reports the list.

The multi-project workflow the root field exists for is unchanged: point AGENT_ALLOWED_ROOTS at the parent of your checkouts and every one stays selectable.

What this deliberately does not change

The service still binds 0.0.0.0 and still has no auth. docs/curriculum/11b_nextjs_agent_lab.md:526 advertises http://<jetson>:8002/docs as a way in, so the bind looks deliberate and I did not want to break a documented path on my own judgement. Worth deciding separately — with the root confined, an unauthenticated caller can no longer choose the target, which is the part that made the exposure serious.

Tests

Adds agent_sidecar/tests/test_workspace_confinement.py — 13 tests, no hardware, no network, no model.

Against this branch: 13 passed. Against the file as it stands on main: 10 failed, 3 passed, including

FAILED test_post_run_refuses_an_outside_root_with_403
FAILED test_post_run_does_not_touch_a_file_outside_the_root

The 3 that pass either way are the default workspace still works, a subdirectory of it still works, and a missing task is still a 400.

pip install fastapi httpx pytest && pytest edgeLLM/nextjs-nemotron-app/agent_sidecar/tests

@egeboy35
egeboy35 force-pushed the fix/sidecar-confines-workspace branch from 6b463b6 to 4748ca9 Compare September 1, 2026 12:28
The sidecar's README documents AGENT_WORKSPACE as "Project dir the agent
reads/edits". The request decides otherwise:

    root = os.path.abspath(body.get("root") or DEFAULT_ROOT)   # :150

There is no allowlist and no check that `root` sits under DEFAULT_ROOT, so
whatever directory the body names becomes the agent's project root, and
`edge_agent.Tools` confines it faithfully to *that*. The service listens on
0.0.0.0 with no authentication, and `edgeLLM/nextjs-nemotron-app/app/api/
agent/route.js:75` forwards `body.root` verbatim from the browser.

Measured against the real app (httpx ASGI transport; only `_make_complete`
stubbed, because reaching the model needs a live endpoint and a key -- the
canned ReAct text it returns is what a prompt-injected model could return):

    POST /run  {"task": "...", "root": "<outside the workspace>"}
      -> HTTP 200
      [start]       root accepted: <outside the workspace>
      [observation]     1  PRIVATE-KEY-MATERIAL       <- read_file
      [observation] wrote 18 bytes to PWNED.txt       <- write_file
      the file now exists outside the workspace: True

So the read is not the whole of it: write_file and edit_file are in the same
tool set, which makes this write access to anything the sidecar's uid can
reach.

This adds AGENT_ALLOWED_ROOTS, os.pathsep-separated, defaulting to the
workspace alone -- which is the guarantee AGENT_WORKSPACE already implies. A
request may name an allowed root or anything beneath it; anything else gets
403 with a message naming the variable to widen. Comparison is on realpath,
so a link inside an allowed root cannot name a target outside it, and
/health reports the list.

The multi-project workflow the `root` field exists for is unchanged: point
AGENT_ALLOWED_ROOTS at the parent of your checkouts and every one of them
stays selectable.

What this does NOT change: the service still binds 0.0.0.0 and still has no
auth. `docs/curriculum/11b_nextjs_agent_lab.md:526` advertises
`http://<jetson>:8002/docs` as a way in, so the bind looks deliberate and I
did not want to break a documented path on my own judgement. Worth deciding
separately -- with the root confined, an unauthenticated caller can no longer
pick the target, which is the part that made the exposure serious.

Adds agent_sidecar/tests/test_workspace_confinement.py -- 13 tests, no
hardware, no network, no model. Against this branch: 13 passed. Against the
file as it stands on main: 10 failed, 3 passed, including

    FAILED test_post_run_refuses_an_outside_root_with_403
    FAILED test_post_run_does_not_touch_a_file_outside_the_root

The 3 that pass either way are "the default workspace still works", "a
subdirectory of it still works" and "a missing task is still a 400".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@egeboy35
egeboy35 force-pushed the fix/sidecar-confines-workspace branch from 4748ca9 to 2a70281 Compare September 1, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant