Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/running-tend/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Step 1 anchor instead, so its spend covers the same band that step censuses.

## Session Log Paths

Artifact paths: `-tmp-tend-agent-workspace-*-checkout/<session-id>.jsonl`
Artifact paths: `-var-tmp-tend-agent-workspace-*-checkout/<session-id>.jsonl`

`review-reviewers` runs produce one session log per matrix repo in
`.github/workflows/review-reviewers.yaml`.
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ jobs:
# remains on 8899 — so a later step must expect that state. This job exercises
# the real boundary: shared paths cross, runner-home paths do not, same-name
# system fallbacks are blocked, and Tend preserves a shared uv. It then drives
# sandbox_setup and both harness adapters inside their real SRT lifecycle.
# sandbox_setup and both harness adapters inside their real SRT lifecycle, and
# ends on the dispose step that clears what the sandbox wrote to /tmp.
test-sandbox:
runs-on: ubuntu-24.04
steps:
Expand All @@ -120,6 +121,8 @@ jobs:
run: bash proxy/test-setup-sandbox.sh verify-srt
- name: Refuse unsafe sandbox configuration
run: bash proxy/test-setup-sandbox.sh verify-refusals
- name: Dispose the sandbox's scratch
run: bash proxy/test-setup-sandbox.sh verify-dispose
- name: Clean up sandbox fixtures
if: always()
run: bash proxy/test-setup-sandbox.sh cleanup
Expand Down
6 changes: 4 additions & 2 deletions claude/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,16 @@ runs:
# This trusted step restores before Claude starts, signs the baseline with a
# per-run key that never crosses into the sandbox env, then hands only the
# memory directory to the sandbox uid. Failure leaves Claude running without
# shared memory.
# shared memory. The directory goes in /var/tmp because it ends up
# sandbox-owned, and the dispose step — which runs before the save below —
# clears that uid's /tmp entries.
- name: Restore experimental memory Gist
id: auto_memory
if: inputs.memory_gist == 'true'
continue-on-error: true
shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0}
run: |
memory_dir=$(/usr/bin/mktemp -d /tmp/tend-auto-memory.XXXXXXXXXX)
memory_dir=$(/usr/bin/mktemp -d /var/tmp/tend-auto-memory.XXXXXXXXXX)
key_file=""
cleanup_restore() {
/usr/bin/sudo /usr/bin/rm -rf -- "$memory_dir"
Expand Down
18 changes: 14 additions & 4 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ The harness has four states and only four transitions:

Each transition is a bottleneck with one job:

- **Content ingress** creates a full remote clone inside a dedicated `/tmp`
- **Content ingress** creates a full remote clone inside a dedicated `/var/tmp`
container with no hardlinks or object-store alternates and with runner/system
Git config and attributes disabled. It selects the exact base, PR merge/head,
or open-PR head ref, pins startup configuration to the exact chosen base
Expand All @@ -316,7 +316,13 @@ Each transition is a bottleneck with one job:
`RUNNER_TEMP` remains unreadable except for the exact read-only event payload
named by `GITHUB_EVENT_PATH`. SRT, the Codex binaries, and the immutable agent
environment live in one dedicated runner-owned, sandbox-readable runtime
directory; the sandbox cannot write it.
directory; the sandbox cannot write it. Both containers sit under `/var/tmp`
rather than `/tmp`, which is ordinary writable scratch inside the sandbox
because tooling hard-codes paths there with no way to redirect them. What the
sandbox may write follows the policy rather than the directory: `/tmp`, the
agent's own checkout and home, and, where the memory experiment is on, the
one `/var/tmp` directory that experiment owns. `/tmp` is sticky, so the
sandbox can add entries beside the runner's but cannot unlink or rename one.
- **Launch and lifetime** invokes the consumer's `sandbox_setup:` and the whole
Claude or Codex turn as one command under the pinned Anthropic Sandbox
Runtime. Tend supplies absolute `node`, `bwrap`, `socat`, `rg`, and seccomp
Expand Down Expand Up @@ -398,8 +404,12 @@ non-sudo sandbox user in the same SRT process lifetime as the agent.

After SRT exits, the trusted supervisor kills and verifies the complete sandbox
UID process tree, then copies only size-bounded fixed outputs. The next fixed
action step deletes the dedicated `/tmp/tend-agent-workspace-*` container; no
post-sandbox step executes a file from that checkout.
action step deletes the dedicated `/var/tmp/tend-agent-workspace-*` container
and every top-level `/tmp` entry the sandbox UID owns, so no post-sandbox step
executes a file from that checkout and the agent's scratch is gone before a
later step or a `setup:` action's POST step could read it. A run whose reap
failed keeps both: the job has already failed, and the live writer is the
reason not to delete underneath it.

**Credential isolation.** Both harness actions run the agent as a separate
non-sudo `tend-sandbox` user, sharing the GitHub proxy machinery under the
Expand Down
2 changes: 1 addition & 1 deletion docs/tend.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ bot_name: my-project-bot
# `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_AUTH_JSON`, `CODEX_HOME`
# - the disposable checkout identity — `GITHUB_WORKSPACE`
# - prompt suppression — `CLAUDE_CODE_REMOTE`
# - the sandbox's own locations — `HOME`, `PATH`, `TMPDIR`, `TMPPREFIX`,
# - the sandbox's own locations — `HOME`, `PATH`, `TMPDIR`,
# `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`
#
# sandbox_env:
Expand Down
1 change: 0 additions & 1 deletion generator/src/tend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@
"CODEX_AUTH_JSON",
"CODEX_HOME",
"TMPDIR",
"TMPPREFIX",
}


Expand Down
23 changes: 0 additions & 23 deletions generator/tests/test_repo_pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,29 +736,6 @@ def test_codex_refresher_keeps_the_secret_writer_pat_out_of_the_model_step() ->
assert publish["env"]["CODEX_OUTCOME"] == "${{ steps.codex.outcome }}"


def test_bundled_runner_instructions_have_no_unscoped_tmp_paths() -> None:
"""`/tmp` is not writable in the sandbox; `$TMPDIR` is.

A bare `/tmp` anywhere the runner reads — instructions, script, helper — sends
the session to a path that fails on write, so the ban is repo-wide rather
than a rule any one file states. A line that says `/tmp` is read-only is
that rule, not an instance of the failure, so it is exempt.
"""
runner = REPO_ROOT / "plugins" / "tend-ci-runner"
unscoped_tmp = re.compile(r"(?<![\w-])/tmp(?:/|\b)")
offenders = sorted(
{
path.relative_to(REPO_ROOT)
for path in runner.rglob("*")
if path.suffix in {".md", ".py", ".sh"}
for line in path.read_text().splitlines()
if unscoped_tmp.search(line) and "read-only" not in line
}
)

assert offenders == []


def test_bundled_runner_instructions_never_return_with_cd_dash() -> None:
"""`cd -` cannot bring a session back to where a recipe started.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ bot_name: my-project-bot
# `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_AUTH_JSON`, `CODEX_HOME`
# - the disposable checkout identity — `GITHUB_WORKSPACE`
# - prompt suppression — `CLAUDE_CODE_REMOTE`
# - the sandbox's own locations — `HOME`, `PATH`, `TMPDIR`, `TMPPREFIX`,
# - the sandbox's own locations — `HOME`, `PATH`, `TMPDIR`,
# `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`
#
# sandbox_env:
Expand Down
2 changes: 1 addition & 1 deletion plugins/tend-ci-runner/skills/run-tend/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Nearly every task takes some of these, so work out which apply before you start:

## Temporary Files

Tend sets `$TMPDIR` to `/home/tend-sandbox/tmp`, the writable scratch directory; writes land only there and in the checkout, which sits under a `/tmp` container that is itself read-only, so a scratch path written by hand under `/tmp` fails with `Read-only file system` even though the mode reads `drwxrwxrwt`. A compound command without `set -e` runs on past that failure. Shell commands expand `$TMPDIR`; file-writing tools need the absolute path.
Write scratch files under `$TMPDIR`, which Tend sets to `/home/tend-sandbox/tmp`. Shell commands expand `$TMPDIR`; file-writing tools need the absolute path.

## Conduct

Expand Down
5 changes: 0 additions & 5 deletions proxy/setup_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
"CODEX_AUTH_JSON",
"CODEX_HOME",
"TMPDIR",
"TMPPREFIX",
}
BLOCKED_COMMAND = """#!/bin/sh
printf "tend: %s came from the runner home and is unavailable; install it into ~/.local/bin with sandbox_setup, or point sandbox_path at a copy outside the runner home\n" "${0##*/}" >&2
Expand Down Expand Up @@ -312,10 +311,6 @@ def base_agent_env(
"GITHUB_WORKSPACE": str(workspace),
"CLAUDE_CODE_REMOTE": "1",
"TMPDIR": str(AGENT_TMP_DIR),
# zsh names here-document temp files from TMPPREFIX, not TMPDIR, and
# its default sits in the read-only root /tmp. A prefix needs only its
# directory to exist, which AGENT_TMP_DIR already does.
"TMPPREFIX": str(AGENT_TMP_DIR / "zsh"),
}
if anthropic_dummy:
values[anthropic_dummy[0]] = anthropic_dummy[1]
Expand Down
37 changes: 33 additions & 4 deletions proxy/test-setup-sandbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ plant() {
bin="$HOME/.cargo-install/tend-probe/bin"
seeded="$HOME/.tend-seeded/bin"
shared="/opt/tend-sandbox-test-$GITHUB_RUN_ID/bin"
TEND_AGENT_CONTAINER=$(mktemp -d /tmp/tend-agent-workspace-test.XXXXXX)
# /var/tmp, where prepare_agent_workspace puts the real container, so the
# assertions below read the same boundary the action builds.
TEND_AGENT_CONTAINER=$(mktemp -d /var/tmp/tend-agent-workspace-test.XXXXXX)
TEND_AGENT_WORKSPACE="$TEND_AGENT_CONTAINER/checkout"
TEND_RUNNER_WORKSPACE="$GITHUB_WORKSPACE"
TEND_TEST_ACTION_PATH="$TEND_AGENT_CONTAINER/action"
Expand Down Expand Up @@ -231,7 +233,7 @@ verify_refusals() {
verify_srt() {
local claude_argv claude_env claude_stub codex_argv codex_env codex_stub dummy_token
local github_output private_action probe_info probe_pid probe_port rc runner_summary
local setup_commands setup_proxy stream_json tool_root
local runner_owned setup_commands setup_proxy stream_json tool_root
github_output="$RUNNER_TEMP/srt-github-output"
runner_summary="$RUNNER_TEMP/srt-step-summary"
probe_info="$RUNNER_TEMP/srt-network-probe"
Expand Down Expand Up @@ -329,6 +331,14 @@ PY

dummy_token=$(sed -n 's/^GITHUB_TOKEN=//p' "$AGENT_ENV_FILE")
test -n "$dummy_token"
# What the runner keeps in the shared /tmp while the agent runs, protected by
# nothing but the sticky bit.
runner_owned="/tmp/tend-runner-owned-$GITHUB_RUN_ID"
touch "$runner_owned"
# The three /tmp and /var/tmp probes below all aim at 1777 directories, where
# only SRT's bind and the sticky bit can deny them. Aimed at the 755 runtime
# or workspace container instead, the write probe would be refused on file
# permissions alone and would hold whether or not the boundary was there.
setup_commands=$(printf '%s\n' \
'touch "$TEND_RUNNER_WORKSPACE/.tend-srt-wrote-here" 2>/dev/null || true' \
'printf "%s\n" "$HTTP_PROXY" > .tend-setup-proxy' \
Expand All @@ -337,7 +347,9 @@ PY
'chmod +x ~/.local/bin/tend-probe' \
'tend-probe > .tend-setup-tool' \
'test -z "${GITHUB_ENV:-}"' \
'if touch /tmp/tend-unscoped 2>/dev/null; then exit 91; fi' \
'touch /tmp/tend-sandbox-scratch' \
Comment thread
tend-agent marked this conversation as resolved.
Resolved
'if touch /var/tmp/tend-unscoped 2>/dev/null; then exit 91; fi' \
"if rm -f '$runner_owned' 2>/dev/null; then exit 92; fi" \
'touch "$TMPDIR/tend-scratch-probe"' \
"test \"\$GITHUB_TOKEN\" = \"$dummy_token\"")

Expand Down Expand Up @@ -431,6 +443,21 @@ PY
echo "[test-setup-sandbox] complete Claude and Codex SRT lifecycles verified"
}

# The real dispose step, against the real filesystem: /tmp is the sandbox's to
# write and the runner's to keep, so ownership is all that separates what goes
# from what stays. The runner-owned file the sandbox failed to unlink in
# verify_srt is the one that has to survive this too.
verify_dispose() {
test "$(stat -c %U /tmp/tend-sandbox-scratch)" = "$SANDBOX"
PATH=/usr/sbin:/usr/bin:/sbin:/bin /usr/bin/python3 -E -s \
shared/steps/dispose_sandbox_resources.py
test ! -e /tmp/tend-sandbox-scratch
test ! -e "$TEND_AGENT_CONTAINER"
test ! -e "$TEND_RUNTIME_ROOT"
test -f "/tmp/tend-runner-owned-$GITHUB_RUN_ID"
echo "[test-setup-sandbox] sandbox scratch disposed, runner entries kept"
}

cleanup() {
local shared
shared="/opt/tend-sandbox-test-$GITHUB_RUN_ID/bin"
Expand All @@ -441,6 +468,7 @@ cleanup() {
if [ -n "${TEND_AGENT_CONTAINER:-}" ]; then
/usr/bin/sudo rmdir -- "$TEND_AGENT_CONTAINER" 2>/dev/null || true
fi
/usr/bin/sudo rm -f "/tmp/tend-runner-owned-$GITHUB_RUN_ID"
/usr/bin/sudo rm -f /usr/local/bin/tend-probe "$shared/tend-shared" "$shared/uv"
/usr/bin/sudo rmdir "$shared" "${shared%/bin}" 2>/dev/null || true
/usr/bin/sudo rm -f /etc/skel/.tend-seeded/bin/tend-seeded
Expand All @@ -455,9 +483,10 @@ case "${1:-}" in
verify) verify ;;
verify-refusals) verify_refusals ;;
verify-srt) verify_srt ;;
verify-dispose) verify_dispose ;;
cleanup) cleanup ;;
*)
echo "usage: $0 {plant|setup|install-agent-uv|verify|verify-refusals|verify-srt|cleanup}" >&2
echo "usage: $0 {plant|setup|install-agent-uv|verify|verify-refusals|verify-srt|verify-dispose|cleanup}" >&2
exit 2
;;
esac
14 changes: 0 additions & 14 deletions proxy/test_setup_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,6 @@ def test_every_fixed_agent_assignment_is_reserved() -> None:
assert f"TMPDIR={setup_sandbox.AGENT_TMP_DIR}" in assignments


def test_agent_environment_keeps_zsh_heredoc_scratch_writable() -> None:
"""zsh names here-document temp files from TMPPREFIX, which ignores TMPDIR.

Left at its `/tmp/zsh` default every heredoc fails against the sandbox's
read-only root `/tmp` — after the shell has already truncated the
redirection target to zero bytes.
"""
assignments = setup_sandbox.base_agent_env(
"/usr/bin", None, workspace=Path("/agent")
)

assert f"TMPPREFIX={setup_sandbox.AGENT_TMP_DIR / 'zsh'}" in assignments


def test_github_only_agent_environment_has_no_model_credential() -> None:
assignments = setup_sandbox.base_agent_env(
"/usr/bin", None, workspace=Path("/agent")
Expand Down
44 changes: 36 additions & 8 deletions shared/steps/dispose_sandbox_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Delete the event checkout and per-run runtime after the sandbox is reaped."""
"""Delete what the sandbox ran from and what it left behind, after the reap.

The event checkout and the per-run runtime are named containers under
``/var/tmp``; ``/tmp`` is scratch the sandbox shares with the runner, so what
the sandbox uid owns at the top level of ``/tmp`` goes too, before a later
runner step or a ``setup:`` action's POST step reads it.
"""

from __future__ import annotations

import os
import pwd
import re
import subprocess
from pathlib import Path

CONTAINER_PARENT = Path("/var/tmp")
SANDBOX_SCRATCH = Path("/tmp")
WORKSPACE_CONTAINER = re.compile(r"tend-agent-workspace-[A-Za-z0-9._-]+\Z")
RUNTIME_CONTAINER = re.compile(r"tend-runtime\.[A-Za-z0-9]+\Z")

Expand All @@ -21,26 +30,44 @@ def fail(message: str) -> int:


def workspace_container(workspace: Path) -> Path:
"""Return the dedicated /tmp container or reject a broader target."""
"""Return the dedicated /var/tmp container or reject a broader target."""
if workspace.name != "checkout":
raise ValueError("TEND_AGENT_WORKSPACE must end in /checkout")
container = workspace.parent
if container.parent != Path("/tmp") or not WORKSPACE_CONTAINER.fullmatch(
if container.parent != CONTAINER_PARENT or not WORKSPACE_CONTAINER.fullmatch(
container.name
):
raise ValueError(
"TEND_AGENT_WORKSPACE must be inside a tend-agent-workspace-* /tmp container"
"TEND_AGENT_WORKSPACE must be inside a "
"tend-agent-workspace-* /var/tmp container"
)
return container


def runtime_container(runtime: Path) -> Path:
"""Accept only the mktemp shape used by install-sandbox-runtime.sh."""
if runtime.parent != Path("/tmp") or not RUNTIME_CONTAINER.fullmatch(runtime.name):
raise ValueError("TEND_RUNTIME_ROOT must be a tend-runtime.* /tmp directory")
if runtime.parent != CONTAINER_PARENT or not RUNTIME_CONTAINER.fullmatch(
runtime.name
):
raise ValueError(
"TEND_RUNTIME_ROOT must be a tend-runtime.* /var/tmp directory"
)
return runtime


def scratch_entries(sandbox: str) -> list[Path]:
"""Return the top-level /tmp entries the sandbox uid owns.

Ownership is the whole test: /tmp is sticky, so an entry the sandbox uid
owns is one the sandbox created, and a runner-owned entry beside it — a
`setup:` action's state, whatever the runner image keeps here — stays.
"""
uid = pwd.getpwnam(sandbox).pw_uid
return sorted(
entry for entry in SANDBOX_SCRATCH.iterdir() if entry.lstat().st_uid == uid
)
Comment thread
tend-agent marked this conversation as resolved.
Resolved


def targets() -> list[Path]:
"""Resolve every resource that this run got far enough to create."""
resources: list[Path] = []
Expand All @@ -56,8 +83,6 @@ def targets() -> list[Path]:
def main() -> int:
try:
resources = targets()
if not resources:
return 0
sandbox = os.environ.get("SANDBOX", "")
if sandbox:
live = subprocess.run(
Expand All @@ -72,6 +97,9 @@ def main() -> int:
)
if live.returncode != 1:
return fail("could not verify that sandbox processes were reaped")
resources.extend(scratch_entries(sandbox))
if not resources:
return 0
subprocess.run(
[
"/usr/bin/sudo",
Expand Down
4 changes: 3 additions & 1 deletion shared/steps/install-sandbox-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ if [ "${#stale[@]}" -gt 0 ]; then
/usr/bin/sudo /usr/bin/apt-get "${apt_options[@]}" install -y --allow-downgrades "${stale[@]}"
fi

runtime_root=$(/usr/bin/mktemp -d /tmp/tend-runtime.XXXXXX)
# /var/tmp rather than /tmp: both are 1777, but the sandbox may write /tmp, and
# the runtime it executes from has to stay read-only to it.
runtime_root=$(/usr/bin/mktemp -d /var/tmp/tend-runtime.XXXXXX)
# Publish the cleanup target before any fallible install work. The later
# always() cleanup can then remove a partial runtime too.
echo "TEND_RUNTIME_ROOT=$runtime_root" >> "$GITHUB_ENV"
Expand Down
Loading
Loading