Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Semantic Versioning where the repository publishes a release.

### Changed

- Route sandboxed verification commands through the bounded subprocess layer,
reject copied-tree symlinks that leave the sandbox, and publish distinct
output-limit, unsupported-platform, missing/non-executable command, and
path-boundary evidence without exposing host paths or uncaught tracebacks.

- Emit completed repository pull-list requests as they finish in the five-minute
agent-mention sweep, while retaining the four-worker ceiling, rotation, and
exact-name dispatch ledger, so one slow repository cannot hide ready sibling
Expand Down
20 changes: 15 additions & 5 deletions docs/doctoring/sandboxed-output-resource-bounds.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,21 @@ A truncation marker is included inside, not in addition to, the declared retaine

## Deferred consumer integration

This first stack layer does not change `sandboxed_verify.py` or
`sandboxed_web_e2e.py`. The next layers adopt the library for short-lived
verification commands and long-running service evidence respectively. Keeping
those integrations separate prevents a shared process primitive, workspace
symlink policy, and E2E result schema from becoming one monolithic review.
The second stack layer adopts the library in `sandboxed_verify.py` for
short-lived verification commands. Long-running `sandboxed_web_e2e.py` service
evidence remains a separate layer. Keeping those integrations separate prevents
a shared process primitive, workspace symlink policy, and E2E result schema from
becoming one monolithic review.

The verification consumer maps an executable lookup failure to exit code `127`,
publishes its normal machine-readable failed result, and tells the operator to
install the executable or correct `PATH`. Provider and host path details do not
escape through an uncaught traceback.

A path that exists but is a directory or lacks execute permission is distinct:
the consumer returns exit code `126` and tells the operator to select an
executable file or correct its permissions. The stable failed result remains
available without exposing the operating-system exception traceback.

## Security and availability properties

Expand Down
61 changes: 61 additions & 0 deletions docs/doctoring/sandboxed-verification-symlink-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Sandboxed verification symlink boundary

## Incident

The review verifier copied an untrusted checkout with `shutil.copytree(...,
symlinks=True)`. That preserves symbolic links rather than copying their
targets. A pull request could therefore add an absolute link, or a relative
link containing enough parent traversal, that a verification command followed
outside the temporary repository. Environment scrubbing did not close that
filesystem boundary.

## Decision

After applying the copy ignore policy, and before running the untrusted command,
the verifier walks the exact copied tree without following directory links and
validates every symbolic link. An absolute target is rejected because the copied
link would still point at a host path. A relative target is accepted only when
its fully resolved path remains beneath the copied repository. Safe internal
relative links remain links so project semantics are preserved. Links under
ignored paths such as `node_modules` never enter the copy and are not evaluated.

The validation happens before the untrusted command starts. Rejection is
fail-closed with stable exit code `122`, `path_boundary_rejected=true` in the
machine-readable result, and a generic diagnostic that does not disclose the
resolved host target. It produces no verification success evidence. This is
filesystem containment, not an operating-system sandbox claim; the existing
network-mode field remains evidence metadata rather than enforcement.

The walk is intentionally a pre-execution copy validation, not a continuous
kernel-enforced filesystem sandbox. A command may create a new symlink after
validation. The wrapper therefore does not claim to contain a hostile process
that can mutate its copied workspace during execution; that stronger boundary
belongs to the surrounding runner or container. The control closes exposure
introduced by attacker-supplied links already present in the copied checkout.
Repository-internal symlink cycles remain inside the boundary but can make a
later verification tool that follows links recurse. Projects must remove such
cycles or configure the verification tool not to follow them; containment does
not imply that every internal graph is operationally valid.

## Test-first evidence

`tests/test_sandboxed_verify_symlink_boundary.py` first reproduced the defect:
an escaping repository link copied successfully instead of raising. The
accepted tests require rejection of both relative traversal and absolute links,
including an absolute link back into the original checkout, while retaining a
safe repository-internal relative link.

## Failure, recovery, and rollback

Repositories that intentionally contain absolute or escaping links must replace
them with bounded relative links before review verification. A rollback is safe
only if an independently reviewed replacement proves that no path available to
the copied command can resolve outside the copy. Dereferencing untrusted links
during the copy is not an acceptable fallback because it can read the external
target while constructing the sandbox.

## APA 7th reference

Python Software Foundation. (2026). *shutil—High-level file operations*
(Python 3.14.6 documentation). Retrieved August 24, 2026, from
https://docs.python.org/3.14/library/shutil.html#shutil.copytree
163 changes: 150 additions & 13 deletions scripts/ci/sandboxed_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
from collections.abc import Sequence
from pathlib import Path

if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from scripts.ci import bounded_subprocess


DEFAULT_IGNORE = (
".git",
Expand Down Expand Up @@ -56,9 +61,20 @@
"PYTHONPATH",
)
RESULT_MARKER = "SANDBOXED_VERIFY_RESULT"
PATH_BOUNDARY_EXIT_CODE = 122
COMMAND_NOT_EXECUTABLE_EXIT_CODE = 126
COMMAND_NOT_FOUND_EXIT_CODE = 127
ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


class RepositoryPathBoundaryError(ValueError):
"""Report a copied repository link that escapes its sandbox boundary."""


class RepositoryRootError(ValueError):
"""Report that the requested repository root cannot be copied."""


def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Parse CLI arguments for the sandboxed verification wrapper."""
parser = argparse.ArgumentParser(
Expand All @@ -69,6 +85,12 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
)
parser.add_argument("--repo-root", default=".", help="Repository root to copy into the sandbox.")
parser.add_argument("--timeout", type=int, default=300, help="Command timeout in seconds.")
parser.add_argument(
"--output-limit-bytes",
type=int,
default=bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES,
help="Maximum retained stdout and stderr bytes per stream.",
)
parser.add_argument(
"--keep-sandbox",
action="store_true",
Expand Down Expand Up @@ -106,6 +128,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.error("provide a verification command after --")
if args.timeout <= 0:
parser.error("--timeout must be positive")
try:
args.output_limit_bytes = bounded_subprocess.validate_output_limit(
args.output_limit_bytes,
"--output-limit-bytes",
)
except ValueError as error:
parser.error(str(error))
for name in args.allow_env:
if not ENV_NAME_RE.match(name):
parser.error(f"--allow-env must be an environment variable name: {name}")
Expand Down Expand Up @@ -138,29 +167,62 @@ def scrubbed_env(sandbox_root: Path, allow_env: Sequence[str] = ()) -> dict[str,
return env


def validate_repository_symlinks(source: Path) -> None:
"""Reject symlinks that could escape the copied repository sandbox.

Relative links are retained only when their resolved target stays beneath
``source``. Absolute links are rejected even when they currently name a
path beneath ``source`` because preserving them would point the sandboxed
command back at the original checkout instead of the isolated copy.
"""
source_root = source.resolve(strict=True)
for current_root, directory_names, file_names in os.walk(source_root, followlinks=False):
current = Path(current_root)
for name in (*directory_names, *file_names):
candidate = current / name
if not candidate.is_symlink():
continue
target = Path(os.readlink(candidate))
if target.is_absolute():
raise RepositoryPathBoundaryError(
f"symlink escapes repository verification sandbox via absolute target: "
f"{candidate} -> {target}"
)
resolved_target = (candidate.parent / target).resolve(strict=False)
try:
resolved_target.relative_to(source_root)
except ValueError as exc:
raise RepositoryPathBoundaryError(
f"symlink escapes repository verification sandbox: {candidate} -> {target}"
) from exc
Comment thread
seonghobae marked this conversation as resolved.


def copy_workspace(repo_root: Path, sandbox_root: Path, extra_ignores: Sequence[str]) -> Path:
"""Copy the repository into the sandbox and return the copied root."""
source = repo_root.resolve()
if not source.is_dir():
raise ValueError(f"repo root is not a directory: {source}")
raise RepositoryRootError(f"repo root is not a directory: {source}")
destination = sandbox_root / "repo"
ignore = shutil.ignore_patterns(*(DEFAULT_IGNORE + tuple(extra_ignores)))
shutil.copytree(source, destination, ignore=ignore, symlinks=True)
validate_repository_symlinks(destination)
return destination


def run_command(command: Sequence[str], cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]:
"""Run the verification command and capture output for review evidence."""
return subprocess.run(
list(command),
def run_command(
command: Sequence[str],
cwd: Path,
env: dict[str, str],
timeout: int,
output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES,
) -> bounded_subprocess.BoundedCompletedProcess:
"""Run one verification command with continuously drained bounded output."""
return bounded_subprocess.run_bounded_command(
command,
cwd=cwd,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
shell=False,
evidence_limit_bytes=output_limit_bytes,
)


Expand All @@ -184,6 +246,10 @@ def emit_result(
allowed_env: Sequence[str],
network: str,
evidence_note: str,
output_limit_bytes: int = bounded_subprocess.DEFAULT_COMMAND_OUTPUT_LIMIT_BYTES,
output_limited: bool = False,
output_limit_unsupported: bool = False,
path_boundary_rejected: bool = False,
) -> None:
"""Print a machine-readable execution evidence summary."""
payload = {
Expand All @@ -194,9 +260,14 @@ def emit_result(
"evidence_note": evidence_note,
"exit_code": exit_code,
"network": network,
"output_limit_bytes": output_limit_bytes,
"output_limited": output_limited,
"output_limit_unsupported": output_limit_unsupported,
"path_boundary_rejected": path_boundary_rejected,
"sandbox": str(sandbox_root) if kept else "(removed)",
"sandboxed": True,
}
print()
print(f"{RESULT_MARKER} {json.dumps(payload, sort_keys=True)}")


Expand All @@ -206,9 +277,30 @@ def main(argv: Sequence[str] | None = None) -> int:
sandbox = Path(tempfile.mkdtemp(prefix="sandboxed-verify-"))
start = time.monotonic()
exit_code = 1
output_limited = False
output_limit_unsupported = False
path_boundary_rejected = False
copied_repo = sandbox / "repo"
try:
copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore)
try:
copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore)
except RepositoryPathBoundaryError:
path_boundary_rejected = True
copied_repo = Path("(not-created)")
print(
"sandboxed-verify: repository path boundary rejected",
file=sys.stderr,
)
exit_code = PATH_BOUNDARY_EXIT_CODE
return exit_code
except RepositoryRootError:
copied_repo = Path("(not-created)")
print(
"sandboxed-verify: repository root is not a directory",
file=sys.stderr,
)
exit_code = 1
return exit_code
env = scrubbed_env(sandbox, args.allow_env)
print(f"sandboxed-verify: cwd={copied_repo}")
print(f"sandboxed-verify: command={' '.join(args.command)}")
Expand All @@ -217,19 +309,60 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.network != "default":
print(f"sandboxed-verify: network={args.network}")
try:
completed = run_command(args.command, copied_repo, env, args.timeout)
completed = run_command(
args.command,
copied_repo,
env,
args.timeout,
args.output_limit_bytes,
)
if completed.stdout:
print(completed.stdout, end="")
if completed.stderr:
print(completed.stderr, end="", file=sys.stderr)
exit_code = completed.returncode
output_limited = completed.output_limited
if output_limited:
print(
"sandboxed-verify: command output exceeded "
f"{args.output_limit_bytes} bytes",
file=sys.stderr,
)
exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE
else:
exit_code = completed.returncode
Comment thread
seonghobae marked this conversation as resolved.
except FileNotFoundError:
print(
"sandboxed-verify: install the executable or correct command PATH",
file=sys.stderr,
)
exit_code = COMMAND_NOT_FOUND_EXIT_CODE
Comment thread
seonghobae marked this conversation as resolved.
except (PermissionError, IsADirectoryError):
print(
"sandboxed-verify: select an executable file or correct its permissions",
file=sys.stderr,
)
exit_code = COMMAND_NOT_EXECUTABLE_EXIT_CODE
Comment thread
seonghobae marked this conversation as resolved.
except bounded_subprocess.OutputLimitUnsupportedError:
output_limit_unsupported = True
print(
"sandboxed-verify: bounded child output is unavailable on this platform",
file=sys.stderr,
)
exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE
except RuntimeError:
print(
"sandboxed-verify: bounded output capture failed",
file=sys.stderr,
)
exit_code = bounded_subprocess.OUTPUT_LIMIT_EXIT_CODE
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
except subprocess.TimeoutExpired as exc:
stdout = timeout_output_text(exc.stdout)
stderr = timeout_output_text(exc.stderr)
if stdout:
print(stdout, end="" if stdout.endswith("\n") else "\n")
if stderr:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
output_limited = bool(getattr(exc, "output_limited", False))
print(f"sandboxed-verify: command timed out after {args.timeout}s", file=sys.stderr)
exit_code = 124
Comment thread
seonghobae marked this conversation as resolved.
return exit_code
Expand All @@ -245,6 +378,10 @@ def main(argv: Sequence[str] | None = None) -> int:
allowed_env=args.allow_env,
network=args.network,
evidence_note=args.evidence_note,
output_limit_bytes=args.output_limit_bytes,
output_limited=output_limited,
output_limit_unsupported=output_limit_unsupported,
path_boundary_rejected=path_boundary_rejected,
)
if not args.keep_sandbox:
shutil.rmtree(sandbox, ignore_errors=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ def test_sandboxed_verify_timeout_with_no_streams_is_bounded(
repo.mkdir()

def timeout_runner(
command: list[str], _cwd: Path, _env: dict[str, str], timeout: int
command: list[str],
_cwd: Path,
_env: dict[str, str],
timeout: int,
_output_limit_bytes: int,
) -> subprocess.CompletedProcess[str]:
raise subprocess.TimeoutExpired(command, timeout, output=None, stderr=None)

Expand Down
Loading
Loading