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
15 changes: 15 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ The configurable stages are `issue_review`, `implement`, `verify`, `review`, `se

`issue_review.mode: auto` (the default) makes a new `run_cycle.py`, `cc-orchestrator`, or `cc-orca-orchestrator` cycle review its work item with `cc-issue-review` before implementing it, except a task declared trivial and not security-sensitive. `off` keeps the original flow. `--issue-review`, or `issue_review=auto|off` in an orchestrator invocation, overrides it for one run. Only a confirmed `READY` reaches implementation. See [Review lifecycle → Issue review](review-cycle.md#issue-review-before-implementation).

Issue review's GitHub network grant does not grant stored credentials. Codex
denies `~/.config/gh` in this non-publishing role. Claude ordinary issue review
keeps its normal tooling; if registry domains or a Docker grant enable its Bash
sandbox, that sandbox also denies the gh store. For authenticated issue reads
on private repositories from either sandbox, set
`code_cycle.user_choices.credential_reads: ["~/.config/gh"]` in the trusted
project configuration. Without that exception, stored-credential authentication
is unavailable to sandboxed gh commands; a resulting access failure does not
mean the work item needs refinement. The exception does not authorize publication.
See [credential-read exceptions](dependency-preparation.md#credential-read-exceptions).

### CI policy

`ci.policy` decides how a cycle treats the code host's checks. `strict`, the default, is the original serial flow: the implementation and each resolution wait for their pushed head's checks, and a review approves only once they passed. `parallel` starts the review while the checks run, sends a review that requests changes to resolution at once, and approves a clean review only after the runtime's one CI gate saw every required check of that same SHA pass. Parallel mode never disables CI; it removes the time spent waiting for it before any useful work can start.
Expand Down Expand Up @@ -394,6 +405,10 @@ On Windows, the `~/.config/code-cycle-toolkit` directory is `%APPDATA%\code-cycl
See [dependency preparation](dependency-preparation.md) for automatic frozen
Node installs, contained lifecycle-build recovery, persistent setup evidence,
and the `code_cycle.user_choices` configuration shared by dispatched stages.
Its `credential_reads` list accepts explicit credential-store exceptions, such
as `["~/.ssh"]`; the default is no exceptions. See
[credential-read exceptions](dependency-preparation.md#credential-read-exceptions)
for the accepted stores, evidence and sandbox scope.

---

Expand Down
49 changes: 42 additions & 7 deletions docs/dependency-preparation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ silently installing with another manager. Workspace package manifests determine
the package-local dependency directories, including `apps/web/node_modules`.
Independent nested lock roots are prepared separately. PnP outputs are supported.

Preparation runs through `codex sandbox` for Codex, and the installed `srt`
(Anthropic sandbox runtime) for Claude. Neither path falls back to a host install.
On Linux/WSL2 preparation runs through the installed `srt` (Anthropic sandbox
runtime) for both executors. There is no Linux Codex fallback. Until the `srt`
path is validated on macOS and Windows, Codex retains `codex sandbox` there,
with the same credential denials. Neither path falls back to a host install.
The install runs in a scratch copy of the lock root's tracked files. The sandbox
can write only that scratch directory and the download cache; the lockfile and
manifests stay read-only there too. The checkout and its Git metadata are never
Expand All @@ -38,8 +40,8 @@ failure, not a successful install or a model failure.
The host supplies the native executor and package manager. Before native stage
dispatch, Linux/WSL2 recovery reuses working `srt` and `rg` binaries. Missing or
unusable tools are installed in immutable bundles below
`~/.local/lib/code-cycle-tools/`; `srt` recovery runs only for Claude stages.
The stage, host isolation probe and Claude preparation process receive their
`~/.local/lib/code-cycle-tools/`; both executors require `srt` for Linux preparation.
The stage, host isolation probe and preparation process receive their
managed executable directories prepended to their own `PATH`, preserving the
original host directories in their existing order. Tools already selected from
the host `PATH` do not promote their directories. This does not change the runtime's
Expand Down Expand Up @@ -172,11 +174,44 @@ Claude receives both the allowed-tool patterns and the command hook policy.
Codex retains normal tools/MCP access and receives the matching explicit network
profile. Default code-host domains are granted to publishing stages. Per the
owner's #279 decision, Codex issue review also receives `github.com` and
`api.github.com`, with the same existing gh credentials, while its workspace and
publication contract remain read-only. This is a credential trust decision:
the domain grant itself does not make an HTTP credential read-only. Other
`api.github.com`. Under #294 its sandboxed commands need the explicit
`credential_reads: ["~/.config/gh"]` exception to use stored gh credentials;
its workspace and publication contract remain read-only. The domain grant itself
does not make an HTTP credential read-only. Other
non-publishing roles receive only registry access, without a publication profile.

### Credential-read exceptions

Every Codex stage sandbox, existing Claude Bash sandbox and preparation sandbox
denies reads of `~/.ssh`, `~/.aws`,
`~/.codex/auth.json`, `~/.claude/.credentials.json` and `~/.claude.json`.
`~/.config/gh` is readable by publishing stages only; preparation always denies
it. These rules restrict child commands, so the agent can still authenticate
outside its command sandbox. Rules name existing resolved targets, including
symlink targets; absent paths are skipped to avoid sandbox mount failures and
persistent placeholder files. A sandbox startup failure never retries without
the denials. Claude's sandbox scope is unchanged: active GitHub policies with
confined review verification, registry domains, publication commands or a Docker
grant. Inactive policies and ordinary Claude issue review keep their earlier
tooling. Static-only recovery disables execution and does not prove denial.

A project whose tests need a particular store, or whose remote is SSH-only,
must opt out explicitly in the trusted source checkout:

```yaml
code_cycle:
user_choices:
credential_reads: ["~/.ssh"]
```

Only the six store names above are accepted, without wildcards or arbitrary
paths. Each exception removes only that store's denial in stages and preparation;
it does not grant network access or make raw SSH work through the HTTPS proxy.
The stage artifacts, preparation report and stage prompt record the exceptions.
Never put credentials in this configuration. Review clones cannot supply their
own exceptions. See [host permissions](host-permissions.md) for the remaining
trust boundary around publishing credentials.

When runtime preparation detects Node dependencies, the worker inherits the
same npm/Yarn registry domains as preparation, plus explicitly selected registry
domains. This also applies after incomplete setup: package-manager launchers and
Expand Down
23 changes: 20 additions & 3 deletions docs/host-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,30 @@ an install with lifecycle scripts disabled, a separate contained lifecycle build
only when one is required, persistent evidence and bounded retries; it never
inherits another revision's `node_modules`. Non-publishing Codex stages receive
no default code-host network route, because readable credentials would permit
writes as well as reads. The exception is Codex issue review, which by the
owner's #279 decision receives `github.com` and `api.github.com` with the
existing `gh` credentials (see
writes as well as reads. Codex issue review, by the owner's #279 decision,
receives `github.com` and `api.github.com`; #294 denies its stored gh
credentials unless the project explicitly opts out (see
[dependency preparation](dependency-preparation.md#explicit-user-choices)); its
command table still denies publication. Explicitly selected registry domains do
not add code-host domains.

Every Codex stage and preparation sandbox denies credential-store reads:
`~/.ssh`, `~/.aws`, `~/.codex/auth.json`, `~/.claude/.credentials.json`, and
`~/.claude.json`. Only publishing stages retain `~/.config/gh` access by
default. Codex uses `none` filesystem rules; Claude and preparation use
`denyRead`. Claude adds these denials where its Bash sandbox already applies:
an active GitHub policy with confined review verification, registry domains,
publication commands or a Docker grant. Inactive policies retain their normal
tooling; adding credential denials does not enable a sandbox for them. The same
condition selects sandbox configuration checks and the Linux functional probe.
Unavailable isolation selects static-only recovery with verification disabled;
this does not establish credential confinement. Existing paths resolve to their
real targets before rules are
emitted; missing paths are omitted. An explicit per-project
`code_cycle.user_choices.credential_reads` list selects exceptions and is
reported in dispatch and preparation evidence. See
[credential-read exceptions](dependency-preparation.md#credential-read-exceptions).

## When a command is denied

The stage report lists each denied command (`blocked command: …`), read from
Expand Down
29 changes: 29 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ Herdr is optional when running the suite: with `HERDR_ENV` unset, worktree
selection uses Git directly; dedicated Herdr tests opt into the flag and mock
the command.

### Credential-read confinement (E7)

The unit suite checks every native stage profile's credential rules, explicit
exceptions, canonical symlink targets, and each missing-denial failure in
`probe_review_confinement`. The installed Codex confinement probe and `srt`
prerequisite probe also test synthetic credential stores without reading host
secrets. Claude settings with inherited `allowRead` are refused because that
setting could cancel a denial.

On a host that supports native sandbox execution, run the opt-in E7 matrix:

```bash
CODE_CYCLE_LIVE_CREDENTIAL_PROBE=1 python3 -m unittest discover -s tests -p test_credential_reads.py
```

This requires installed `codex` and `srt`. It exercises read/write, publishing,
registry and Docker-grant profile combinations on both executors, prints only
`READABLE`/`denied` states for synthetic stores, and fails on any unexpected
read. Publishing profiles must allow synthetic gh credentials and deny the five
other stores. No host credential contents are accessed. Preserve the output
and platform/version information as boundary evidence. macOS/Windows still need
platform validation before removing their Codex preparation fallback.

Do not opt in inside a native stage whose outer sandbox cannot support nested
Codex or `srt`. The test skips there; record that boundary as `not_verified`,
and keep unit results separate from real sandbox evidence. Static-only recovery
when the sandbox is unavailable does not establish credential confinement;
verification remains disabled under the existing recovery policy.

### Codex Git metadata sandbox

Writing Codex dispatches from linked worktrees need some of the worktree's Git
Expand Down
29 changes: 21 additions & 8 deletions scripts/dependency_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
import signal
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any

import native_prerequisites
import stage_permissions as sp

REGISTRIES = ("registry.npmjs.org", "registry.yarnpkg.com")
LOCKS = {"pnpm-lock.yaml": "pnpm", "package-lock.json": "npm",
Expand Down Expand Up @@ -99,8 +102,12 @@ def _manifest(path: Path) -> dict:

def sandbox_command(executor: str, binary: str, command: list[str], *,
writable: list[Path], protected: list[Path], domains: tuple[str, ...],
settings: Path, path: str | None = None) -> list[str]:
if executor == "codex":
settings: Path, path: str | None = None,
credential_reads: tuple[str, ...] = ()) -> list[str]:
denied = sp.credential_read_denials(credential_reads=credential_reads)
# Linux/WSL2 is validated for srt on both executors. Keep the aligned
# Codex fallback only on platforms whose srt path is not yet validated.
if executor == "codex" and not sys.platform.startswith("linux"):
if native_prerequisites.nested_codex():
raise OSError("known nested Codex limitation (#268): app-server socket directory must be "
"a user-owned directory with mode 0700; check not_verified without probing")
Expand All @@ -113,6 +120,7 @@ def sandbox_command(executor: str, binary: str, command: list[str], *,
rules.update({str(path): "read" for path in protected
if any(path.is_relative_to(output) or output.is_relative_to(path)
for output in writable)})
rules.update({path: "none" for path in denied})
filesystem = "{" + ",".join(json.dumps(key) + "=" + json.dumps(value)
for key, value in rules.items()) + "}"
network = "{" + ",".join(json.dumps(host) + '=\"allow\"' for host in domains) + "}"
Expand All @@ -126,13 +134,13 @@ def sandbox_command(executor: str, binary: str, command: list[str], *,
# bootstrap or unsandboxed fallback when the host has not installed it.
srt = native_prerequisites.executable("srt", path=path)
if not srt:
raise OSError("Claude dependency preparation needs the installed srt sandbox runtime")
raise OSError("dependency preparation needs the installed srt sandbox runtime")
settings.write_text(json.dumps({
"network": {"allowedDomains": list(domains), "deniedDomains": [],
"allowLocalBinding": False},
"filesystem": {"allowWrite": [str(path) for path in writable],
"denyWrite": [str(path) for path in protected],
"denyRead": ["~/.ssh", "~/.aws", "~/.config/gh"]},
"denyRead": list(denied)},
}), encoding="utf-8")
# srt shell-quotes each positional argument itself, so a pre-joined string
# would run as one word. "--" keeps srt from parsing npm's own flags, such
Expand Down Expand Up @@ -313,7 +321,8 @@ def _run(argv, *, timeout, **options):
def prepare(cwd: str, executor: str, binary: str, *,
domains: tuple[str, ...] = (), runner=_run,
evidence_root: Path | None = None, sleep=time.sleep,
prerequisite_path: str | None = None) -> dict:
prerequisite_path: str | None = None,
credential_reads: tuple[str, ...] = ()) -> dict:
"""Return honest setup evidence even on failure; no verdict or CI claim.

Logs outlive the disposable clone. Failure is given to the stage so it can
Expand All @@ -327,7 +336,10 @@ def prepare(cwd: str, executor: str, binary: str, *,
base = evidence_root or Path.home() / ".local/state/code-cycle-toolkit/preparation"
base.mkdir(parents=True, exist_ok=True)
evidence = Path(tempfile.mkdtemp(prefix="setup-", dir=base))
report = {"status": "prepared", "items": [], "evidence": str(evidence / "report.json")}
report: dict[str, Any] = {"status": "prepared", "items": [], "evidence": str(evidence / "report.json"),
"credential_read_policy": {"exceptions": list(credential_reads),
"denied_paths": list(sp.credential_read_denials(
credential_reads=credential_reads))}}
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root,
capture_output=True, text=True, timeout=30, check=True).stdout.strip()
tracked = subprocess.run(["git", "ls-files", "-z"], cwd=root,
Expand Down Expand Up @@ -357,7 +369,7 @@ def prepare(cwd: str, executor: str, binary: str, *,
if key in {"PATH", "SYSTEMROOT", "LANG", "LC_ALL",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"http_proxy", "https_proxy", "no_proxy"}}
if executor == "claude":
if executor == "claude" or sys.platform.startswith("linux"):
env["PATH"] = (native_prerequisites.tool_path(env.get("PATH", ""))
if prerequisite_path is None else prerequisite_path)
env.update(HOME=str(scratch), TMPDIR=str(scratch), CI="true",
Expand All @@ -376,7 +388,8 @@ def run(command, label, timeout, *, cwd=staged, environment=env,
argv = sandbox_command(executor, binary, command, writable=allowed,
protected=protected, domains=tuple(dict.fromkeys(
(*REGISTRIES, *domains))), settings=scratch / "sandbox.json",
path=environment.get("PATH") if executor == "claude" else None)
path=environment.get("PATH"),
credential_reads=credential_reads)
record["sandbox_command"] = argv
result = runner(argv, cwd=cwd, env=environment, capture_output=True,
text=True, timeout=timeout, stdin=subprocess.DEVNULL)
Expand Down
Loading
Loading