Skip to content

security: give each default screenshot its own private temp file - #297

Open
Mr-Neutr0n wants to merge 2 commits into
andrewyng:mainfrom
Mr-Neutr0n:security/screenshot-temp-file-is-predictable
Open

security: give each default screenshot its own private temp file#297
Mr-Neutr0n wants to merge 2 commits into
andrewyng:mainfrom
Mr-Neutr0n:security/screenshot-temp-file-is-predictable

Conversation

@Mr-Neutr0n

Copy link
Copy Markdown
Contributor

Stacked on #289 — it edits the same function, so please take that one first. The diff here is the last commit only.

browser_screenshot's default location is a hardcoded name in the shared temp dir:

Path(tempfile.gettempdir()) / "coworker-browser-screenshot.png"

Two problems on a multi-user host. (Platform-dependent: Linux /tmp is shared; macOS gives each uid its own temp dir, so this is a Linux/server concern.)

  1. The path is predictable. Another local user can pre-create it as a symlink and the screenshot write follows it.
  2. The image is created at the umask default. A full-page screenshot of whatever the agent is driving may show a logged-in inbox, a Slack channel, or a bank page — left group/world readable.

tempfile.mkstemp gives a random name created 0600 with O_EXCL, so neither holds. Callers passing an explicit path are unaffected; that path is root-confined by #289.

On the evidence

The predictable-name half is self-evident from the line above — it's a literal constant. I want to be straight about what the test does and doesn't show: it pins the new invariant (two calls produce two distinct owner-only files), and it does fail on the current code, but for a different reason — today the file is only created inside the Playwright callback, so without Playwright installed nothing is created at all. It isn't a clean before/after demonstration of the mode, and I'd rather say so than let the red test imply more than it proves.

Tests

Extends tests/test_browser_path_confinement.py: two successive default-location calls get two distinct files matching the expected prefix, and both are 0600.

Full suite unchanged from #289: 927 passed, with the same 14 pre-existing failures (test_fake_slack.py dead-port timeouts #252, test_bedrock_provider.py missing extra #284/#285).


Reviewed and tested locally; drafted with AI assistance.

…oots

The permission engine path-scopes only the built-in WRITE_TOOLS, and only by
reading arguments["path"] (PermissionEngine.evaluate). A connector tool that
touches the local filesystem is therefore invisible to it. Two of the browser
tools do, in both directions:

  browser_upload_file(target, path)
      Path(path).expanduser().resolve() with no containment, then hands the
      bytes to whatever page is loaded. Any readable file on the machine can be
      posted to a remote site: ~/.ssh/id_rsa, ~/.aws/credentials, or the
      OpenWorker secret store itself. This is issue andrewyng#189.

  browser_screenshot(path)
      the same unconfined resolve, then out.parent.mkdir(parents=True) and a
      write. That is an arbitrary file overwrite plus directory creation
      anywhere the process can reach. Not previously reported.

Both are approval-gated in Interactive mode, so this is not a silent
compromise there. It is still the wrong boundary: in Auto mode there is no
prompt at all, and even with a prompt the tools reach files the user never
granted the session, which is exactly what the roots list exists to prevent.

The fix follows the confinement email_tools already applies to outgoing
attachments (email_tools.py:640): resolve, then require the result to sit
inside a granted root. Reads accept any root; the screenshot write requires a
writable one. make_integration_tools already receives roots for precisely this
reason - browser tools simply were not passed them.

Containment is now decided before the browser layer is touched, so a refused
screenshot no longer creates its parent directories on the way to failing, and
a refused upload does not stat the path (the error cannot be used to test for
the existence of ungranted files).

Behaviour is unchanged for paths inside the session, and the default
screenshot location (the temp file) is untouched.

Tests: tests/test_browser_path_confinement.py - traversal, symlink escape,
read-only root rejected for writes, no-roots denies, and refusal ordering.
browser_screenshot's default location was a fixed name in the shared temp dir:

    Path(tempfile.gettempdir()) / "coworker-browser-screenshot.png"

Two problems on a multi-user host (Linux /tmp is shared; macOS gives each uid
its own, so this is platform-dependent):

- the path is predictable, so another local user can pre-create it as a
  symlink and the screenshot write follows it
- the image is created at the umask default. A full-page screenshot of
  whatever the agent is driving may show a logged-in inbox, a Slack channel or
  a bank page, left group/world readable

tempfile.mkstemp gives a random name created 0600 with O_EXCL, so neither
holds. Callers that pass an explicit path are unaffected (that path is
root-confined by the preceding commit).

Stacked on security/browser-path-confinement (andrewyng#289) because it edits the same
function.

Tests: extends tests/test_browser_path_confinement.py - two calls get two
distinct files, both owner-only.
@Mr-Neutr0n
Mr-Neutr0n force-pushed the security/screenshot-temp-file-is-predictable branch from c1759e8 to 08b0d11 Compare July 28, 2026 20:52
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