Skip to content

agent env: /tmp/opencode advertised as writable scratch but owned by another user #44696

Open

Description

@jc0d35

Summary

The agent environment banner advertises /tmp/opencode as "pre-created and approved for external access" and directs agents to prefer it over generic system temp dirs. On this host the directory exists but is owned by a different user than the session (uid 1000), so the agent cannot write to it:

$ printf 'probe' > /tmp/opencode/probe1.txt
/bin/bash: line 1: /tmp/opencode/probe1.txt: Permission denied

$ id
uid=1000(cunabu) ...
$ ls -ld /tmp/opencode
drwxrwxr-x 2 iconoclasta iconoclasta ...

(Verified 2026-08-24.)

Environment

  • opencode version: opencode2 v0.0.0-beta-18050 (beta)
  • OS: Linux 6.8.0-111-generic x86_64
  • Shell: /bin/bash; Terminal: tmux

Expected Behavior

Either /tmp/opencode is owned/mode'd so the session user can write to it, or the banner stops claiming it is approved for access when it is not.

Actual Behavior

A sanctioned scratch location is unusable; agents are steered toward a path that fails, then must fall back to un-advertised locations.

Additional Context

Low priority and mostly host-provisioning, but it is an instruction-vs-reality mismatch in shipped surface text. If /tmp/opencode is created by harness/host tooling rather than by the user, it should be created with the session user as owner; if it is expected to be pre-existing, docs should say so and note the permission requirement.

Activity

  1. github-actions commented on Aug 24, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • Agent writes tmp files to /tmp instead of /tmp/opencode #44644: Also concerns agents and the /tmp/opencode scratch directory — that issue reports agents writing to /tmp instead of /tmp/opencode, while this issue reports /tmp/opencode being owned by a different user. They are related and may benefit from a shared fix (e.g. ensuring the directory is created with the correct ownership/permissions by the session user or host tooling).
  2. Shra1V32 commented on Oct 13, 2026

    @Shra1V32

    Reproduced on 2.0.28 (Linux x86_64, multi-user host where /tmp/opencode is drwxrwxr-x 2 ubuntu ubuntu and a second user runs as uid=2571).

    Beyond the EACCES failure for the second user, there are a few multi-tenant security implications with how Global.Path.tmp and os.tmpdir() are currently handled on shared POSIX hosts:

    1. Global.Path.tmp (/tmp/opencode) has no 0o700 mode or UID/symlink verification (packages/util/src/global.ts:68-74):

      • fs.promises.mkdir(service.tmp, { recursive: true }) uses the default umask (0755 / 0775), so any files written into /tmp/opencode by the owning user (agent scratch files, web/desktop attachment uploads at ${info.paths.tmp}/uploads/... in packages/app/src/composer/attachments/destination.ts, etc.) are world-readable (0644) to other local users.
      • Because mkdir(..., { recursive: true }) succeeds if the directory already exists without checking lstat ownership (uid === process.getuid()) or permissions, another local user can pre-create /tmp/opencode with 0777 (or as a symlink that fs.promises.realpath(service.tmp) resolves and injects into the system prompt via packages/core/src/instructions/builtins.ts:43), allowing cross-user reading/tampering of scratch files.
      • Note for fix(util): use a per-user tmp directory #52973 (opencode-<uid>): even with opencode-<uid> in os.tmpdir(), acquire() in packages/util/src/global.ts should create service.tmp with mode: 0o700 and verify via lstat that the directory is not a symlink and is owned by process.getuid(), since /tmp/opencode-<uid> is still a predictable name in world-writable /tmp.
    2. Other direct os.tmpdir() writes bypass Global.Path.tmp without 0o600:

      • packages/tui/src/editor.ts:17-18: writes the user's external $EDITOR prompt buffer to path.join(os.tmpdir(), "${Date.now()}.md") with default umask (0644, world-readable) and a predictable millisecond filename without flag: "wx".
      • packages/core/src/plugin/vcs/git.ts:562-569: writes ${os.tmpdir()}/opencode-untracked-${randomUUID()}.{list,index} with default umask (0644) in world-listable /tmp.
  3. lyckantropen commented on Oct 25, 2026

    @lyckantropen

    One more shape of this problem beyond the cross-user permission failure: /tmp/opencode\ is also a single flat namespace shared by all of one user's concurrent sessions. Even single-user, two sessions writing scratch or harness-created directories (the /tmp/opencode/t\ pattern) collide in the same way — there is no per-session namespace under the temp root.

    Workarounds that avoid /tmp\ entirely: set \worktree.directory\ (already in the config schema) to a home-relative parent so new worktrees never land in temp, and have agents keep per-session scratch under a home-level directory (e.g. ~/.opencode/tmp/). The multi-user permission failure itself can only be fixed by moving off shared /tmp, which is what this issue tracks.

  4. sudoer777 commented on Oct 28, 2026

    @sudoer777

    Considering how this affects more than just OpenCode, I ended up writing a systemd service and shell hooks to set TMPDIR to /tmp/user/<uid> and OpenCode seems to be following that (similar to pam_tmpdir but doesn't need a SUID helper). I'm not sure why distros don't do this by default.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the report. I reproduced this on @opencode/cli 2.2.0 (the beta build you reported isn't published on npm) and on the latest dev (a882cf9).

    Steps, with two ordinary users on one Linux host:

    1. alice runs OpenCode once (opencode debug paths is enough). That creates /tmp/opencode as drwxrwxr-x alice alice.
    2. bob starts a session. His system prompt still says /tmp/opencode is "pre-created and approved for external access", but the agent's printf probe > /tmp/opencode/probe1.txt fails with Permission denied.

    OpenCode places its temp directory at os.tmpdir()/opencode, a single path shared by every user on the host. It never checks who owns that directory, or whether the current user can write to it, before advertising it. The recording and screenshot below show the run, and the script below reproduces it.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces #44696: on a multi-user host, /tmp/opencode belongs to whichever user
    // ran OpenCode first, yet every other user's agent is told it is writable scratch.
    // Setup (two ordinary users, alice and bob):
    //   rm -rf /tmp/opencode
    //   su alice -c "opencode debug paths"   # alice now owns /tmp/opencode (drwxrwxr-x)
    //   mkdir -p /tmp/opencode-drive && chmod 1777 /tmp/opencode-drive
    //   su bob -c "cd /workspace/repro && opencode-drive run drive.ts"
    // Expected per the banner: the probe write succeeds. Actual: Permission denied.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    let banner = "(banner not found)"
    let probed = false
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "README.md": "probe\n" } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((request) => {
            const text = JSON.stringify(request)
            const m = text.match(/Prefer [^"\\]*? over generic system temporary directories[^"\\]*/)
            if (m) banner = m[0]
            if (!m) return Stream.make(Llm.text("Probe"))
            if (!probed && (probed = true))
              return Stream.make(
                Llm.toolCall({
                  index: 0,
                  id: "call_probe",
                  name: "shell",
                  input: { command: "id -un; ls -ld /tmp/opencode; printf probe > /tmp/opencode/probe1.txt", description: "Write probe to advertised tmp" },
                }),
                Llm.finish("tool-calls"),
              )
            return Stream.make(Llm.text(`BANNER SEEN: ${banner}`))
          })
          yield* ui.submit("Write a probe file into the advertised scratch directory")
          yield* Effect.sleep("8 seconds")
          yield* ui.waitFor("Permission denied", { timeout: 20000 })
          yield* ui.waitFor("BANNER SEEN", { timeout: 20000 })
          yield* ui.screenshot("permission-denied")
          console.log("BANNER:", banner)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  6. denizzzka commented on Nov 4, 2026

    @denizzzka

    Maybe it’s better to place it to /var/tmp/opencode or ~/.cache/opencode/?

  7. xenoterracide commented on Nov 7, 2026

    @xenoterracide

    Additional data point on 2.2.6, plus a concrete mktemp fix.

    The session runs as uid 1000. Another local user (uid 1001) owns /tmp/opencode:

    $ stat -c '%u %a' /tmp/opencode
    1001 755
    $ mktemp /tmp/opencode/commit.XXXXXXXX
    mktemp: failed to create file via template '/tmp/opencode/commit.XXXXXXXX': Permission denied

    0755 blocks writes from every other user. If the owner pre-creates the path as 0777 or as a symlink, scratch files become readable and replaceable by that user. A predictable name in a world-writable directory cannot be treated as safe.

    Suggested fix:

    • Stop pre-creating and advertising a fixed path under os.tmpdir().
    • Use a per-user base. On Linux, $XDG_RUNTIME_DIR is already private (0700) and typically cleared at logout. On macOS, $TMPDIR is already per-user. Fall back to os.tmpdir() only when neither exists.
    • Create one directory per server instance with mktemp -d. It picks a random name and creates the directory atomically with mode 0700:
    base="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
    tmpdir="$(mktemp -d "${base%/}/opencode-$(id -u)-${port}-XXXXXXXX")" || exit 1
    trap 'rm -rf "$tmpdir"' EXIT
    • Use the server port as the per-instance key. opencode serve already listens on an ephemeral loopback port, so the server can pass its own. Keep the random suffix because ports get reused.
    • If a stable path is required for external access, use $XDG_RUNTIME_DIR/opencode/<port>, create it with install -d -m 700, then lstat it on reuse. Reject symlinks and require uid === process.getuid() and (mode & 0o077) === 0.
    • On Windows, %TEMP% is already per-user. Use the platform equivalent of mktemp -d.

    The opencode-<uid> proposal above still uses a predictable name. mktemp -d removes the prediction.

    Do not use ~/.cache/opencode or ~/.opencode/tmp as the runtime scratch root. They persist across logout. ~/.opencode/tmp is fine for durable, user-owned files, but it still needs per-session subdirectories with 0700.

    Verified mode here:

    $ d=$(mktemp -d "${XDG_RUNTIME_DIR}/opencode-$(id -u)-${port}-XXXXXXXX")
    $ stat -c '%u %a' "$d"
    1000 700

    This fixes both the cross-user failure and the cross-session collision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions