Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a09903d
feat(sandbox): expand default allow list for node package managers
Apr 23, 2026
3bb49bb
docs(changelog): add PR link for CYPACK-1128
Apr 23, 2026
506ff5d
fix(sandbox): use BUN_TMPDIR and allow ~/.config/git in sandbox
Apr 23, 2026
9f02d98
fix(sandbox): add worktree path to sandbox read allowlist
Apr 23, 2026
f46a70b
refactor(sandbox): drop "." from allowRead and document why
Apr 23, 2026
4d4da96
feat(sandbox): exclude node package managers + add ssh/gh config reads
Apr 23, 2026
7a7eb03
fix(sandbox): include secondary-repo worktree metadata for multi-repo
Apr 23, 2026
83000b5
refactor(sandbox): move git-metadata paths out of allowedDirectories
Apr 23, 2026
a4187f6
Merge remote-tracking branch 'origin/main' into cypack-1128
Apr 28, 2026
7cab30d
docs(claude-runner): cross-reference sandbox allow/deny lists
cyrusagent[bot] Apr 30, 2026
d30f92d
feat(edge-worker): sandbox hardening and FD-3 bash-wedge detection
cyrusagent[bot] Apr 30, 2026
7855e1a
refactor(edge-worker): consolidate proxy header-build, add TLS+update…
cyrusagent[bot] Apr 30, 2026
374c579
refactor(edge-worker): extract buildEgressCaEnv + cert trust learning…
cyrusagent[bot] Apr 30, 2026
c80b05d
feat: default GitHub credential brokering for the egress proxy
cyrusagent[bot] Apr 30, 2026
0b97c6d
chore(deps): bump @anthropic-ai/claude-agent-sdk 0.2.117 → 0.2.123
cyrusagent[bot] Apr 30, 2026
c11f66c
Merge remote-tracking branch 'origin/main' into cypack-1128
cyrusagent Apr 30, 2026
cab4a03
Revert "feat(edge-worker): sandbox hardening and FD-3 bash-wedge dete…
cyrusagent Apr 30, 2026
ebfd950
feat(edge-worker): use gh auth token in GitHub broker fallback chain
Apr 30, 2026
24b9f3b
fix(sandbox): remove ~/.config/gh/hosts.yml from read allowlist
May 1, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Security
- **Removed `~/.config/gh/hosts.yml` from the sandbox read allowlist** — that file stores `gh` OAuth tokens, which sandboxed sessions must never be able to read. Credential brokering (the egress proxy injecting `Authorization` headers at request time) is the supported path for GitHub auth from inside the sandbox; the agent never needs the raw token. ([CYPACK-1128](https://linear.app/ceedar/issue/CYPACK-1128))

### Changed
- **GitHub credential brokering now picks up `gh auth token` automatically** — When sandbox credential brokering is enabled and no GitHub App is configured, Cyrus now falls through to `gh auth token` (cached for 60s) before resorting to the `GITHUB_TOKEN` env var. Self-hosted users who already authed with `gh auth login` no longer need to also export `GITHUB_TOKEN` for the egress proxy to inject credentials into sandboxed sessions. ([CYPACK-1128](https://linear.app/ceedar/issue/CYPACK-1128))

## [0.2.51] - 2026-04-30

### Changed
Expand Down Expand Up @@ -85,6 +91,7 @@ All notable changes to this project will be documented in this file.
- **Eliminated spurious blank lines in the Linear activity log** — Empty/whitespace-only assistant turns no longer produce blank "thought" activities, which previously appeared as an extra empty line between the "Using model: ..." notification and the first real tool call. ([CYPACK-1112](https://linear.app/ceedar/issue/CYPACK-1112), [#1134](https://github.com/ceedaragents/cyrus/pull/1134))

### Security
- **Expanded sandbox defaults for node-based package managers** — The sandbox now grants read access to `~/.gitconfig`, `~/.config/git`, `~/.config/gh/hosts.yml`, `~/.config/gh/config.yml`, `~/.ssh/known_hosts`, and read+write access to the standard caches/stores used by npm, yarn, pnpm, bun, deno, and node-gyp (both macOS and Linux layouts). `BUN_TMPDIR` is also pointed at a Cyrus-managed directory (`<cyrusHome>/tmp`) that is always inside the write allowlist, so `bun install` has a reliable writable tmp dir for its atomic install path (the Claude Code binary forcibly overrides `TMPDIR` inside the sandbox, so Bun's higher-priority `BUN_TMPDIR` is the one variable we can reliably set from the outside). Multi-repo sessions now also add every secondary repo's sub-worktree and `.git`/`.git/worktrees/<name>` metadata dirs to both the sandbox read **and** write allowlists — previously only the primary repo's linked-worktree metadata was enumerated, which caused `Operation not permitted` errors when running git against a secondary repo (e.g. creating `index.lock` on commit). Node package manager commands (`bun *`, `npm *`, `pnpm *`, `yarn *`) are also now excluded from the sandbox via `excludedCommands`, so their lifecycle scripts, native addon builds, and long tail of touched paths no longer require enumerating every filesystem entry — the egress proxy still intercepts their network traffic. The session's worktree path is also now added to the sandbox read allowlist explicitly — previously it was only in the write allowlist, which made the working tree write-only and caused `git status`, `ls`, and `bun install` lifecycle scripts to fail with `Operation not permitted` inside the sandbox. ([CYPACK-1128](https://linear.app/ceedar/issue/CYPACK-1128), [#1155](https://github.com/cyrusagents/cyrus/pull/1155))
- **Tightened sandbox and tool permission defaults** — Claude sessions now run with stricter out-of-the-box restrictions: the OS-level sandbox enforces `denyRead: ["~/"]` + `allowRead: ["."]` (home directory blocked, worktree allowed) and `allowWrite` scoped to the session worktree only. On the tool permission side, `Read`, `Edit`, and `Write` are now narrowed to `Read(**)`, `Edit(**)`, and `Write(**)` to prevent unintended matches. Home directory files (SSH keys, credentials, etc.) are explicitly enumerated and added to `disallowedTools` at session start, working around the fact that `Read(~/**)` does not match in Claude Code's permission layer. ([#1123](https://github.com/ceedaragents/cyrus/pull/1123))
- **Addressed open security advisories** — Refreshed `pnpm-lock.yaml` so vulnerable transitive dependencies resolve to their patched versions (`protobufjs`, `path-to-regexp`, `picomatch`, `flatted`, `brace-expansion`, `yaml`, `follow-redirects`, `vite`, `hono`, `@hono/node-server`) through their existing direct-dep paths, without introducing new `pnpm.overrides` entries. ([CYPACK-1101](https://linear.app/ceedar/issue/CYPACK-1101), [#1128](https://github.com/ceedaragents/cyrus/pull/1128))

Expand Down
35 changes: 34 additions & 1 deletion packages/claude-runner/src/ClaudeRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,32 @@ function flattenSanitizedQueryOptions(
return out;
}

/**
* Return a shallow copy of `env` with `keysToStrip` removed.
*
* Pure function, exported for tests. Used at the env-merge step to filter
* `repositoryEnv` (loaded from the worktree's `.env` file) before it reaches
* the child process — the strip path that makes GitHub credential brokering
* actually deliver isolation. See `buildGitHubBrokeredEnv` and
* `GITHUB_BROKERED_STRIP_ENV_KEYS` in cyrus-edge-worker for the wiring.
*
* Empty / undefined `keysToStrip` returns the input unchanged so callers
* can spread `stripKeys(env, undefined)` unconditionally without paying
* for a filter when stripping isn't enabled.
*/
export function stripKeys(
env: Record<string, string>,
keysToStrip: readonly string[] | undefined,
): Record<string, string> {
if (!keysToStrip || keysToStrip.length === 0) return env;
const stripSet = new Set(keysToStrip);
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (!stripSet.has(k)) out[k] = v;
}
return out;
}

export declare interface ClaudeRunner {
on<K extends keyof ClaudeRunnerEvents>(
event: K,
Expand Down Expand Up @@ -648,7 +674,14 @@ export class ClaudeRunner extends EventEmitter implements IAgentRunner {
// investigated. The sandbox requirements precheck is still run
// above so the diagnostics remain available when we re-enable.
// See: CYPACK-1108.
...this.repositoryEnv,
//
// `stripEnvKeys` filters `repositoryEnv` only (the .env-loaded
// inheritance) — not `additionalEnv`, which is intentional
// session-scoped state from RunnerConfigBuilder (e.g. brokered
// `GH_TOKEN` sentinel). Stripping at this level is what makes
// GitHub credential brokering actually deliver isolation: a real
// `GITHUB_TOKEN` in the worktree's .env never reaches the child.
...stripKeys(this.repositoryEnv, this.config.stripEnvKeys),
...this.config.additionalEnv,
// When logging at DEBUG level, enable the SDK's own debug output so
// --debug-to-stderr and DEBUG=1 propagate to the Claude subprocess.
Expand Down
30 changes: 30 additions & 0 deletions packages/claude-runner/src/home-directory-restrictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,36 @@ import { join, relative, resolve } from "node:path";
*
* Claude Code requires an extra leading / for absolute paths in tool patterns.
* See: https://docs.anthropic.com/en/docs/claude-code/settings#read-edit
*
* IMPORTANT — relationship to `buildPackageManagerHomeAllowances()`:
* This denylist and the OS-level sandbox `allowRead` list built in
* `packages/edge-worker/src/RunnerConfigBuilder.ts` are INTENTIONALLY
* INDEPENDENT. They serve different consumers:
* - This list feeds the SDK's `disallowedTools` (Claude's tool-permission
* layer). It controls what Claude's own `Read`/`Edit` tool calls may
* touch.
* - `buildPackageManagerHomeAllowances()` feeds `sandbox.filesystem.allowRead`
* (OS-level). It controls what unsandboxed children like `npm`, `git`,
* and `gh` may touch when they execute on Claude's behalf.
* The same path can legitimately appear in both: e.g. `~/.gitconfig` is in
* the OS allow-list (so `git` can read it) AND denied here (so Claude's
* `Read` tool cannot). That is the point — defense-in-depth.
* If you change one, do not assume the other tracks it. See CLAUDE.md § 6.
*
* KNOWN SDK LIMITATION — Glob/Grep enumeration leak:
* The SDK only honors `Read(...)` patterns in `disallowedTools`; it does NOT
* honor `Glob(...)` or `Grep(...)` patterns. The SDK does check Glob/Grep's
* input `path` arg against `Read(...)` denies, but it does NOT apply those
* denies to the entries Glob returns from its recursive walk. So when
* `additionalAllowedPaths` includes a path whose parent has denied siblings
* (e.g. allowing `~/cyrus-app/cyrus` makes `~/cyrus-app` a "passthrough"
* parent that this function leaves un-denied — see the algorithm below),
* `Glob(path="~/cyrus-app", pattern="**")` will return entries from
* `~/cyrus-app/cyrus-hosted/`, `~/cyrus-app/documentation/`, etc. The
* downstream `Read` of any leaked path is still denied — but file/directory
* NAMES leak. Adding `Glob(...)`/`Grep(...)` patterns here is a no-op; the
* realistic mitigation is workspace layout (avoid passthrough parents with
* denied siblings).
*/
export function buildHomeDirectoryDisallowedTools(
cwd: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type {
StopHookInput,
WarmQuery,
} from "@anthropic-ai/claude-agent-sdk";
export { AbortError, ClaudeRunner } from "./ClaudeRunner.js";
export { AbortError, ClaudeRunner, stripKeys } from "./ClaudeRunner.js";
export {
availableTools,
getAllTools,
Expand Down
12 changes: 12 additions & 0 deletions packages/claude-runner/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export interface ClaudeRunnerConfig {
sandbox?: SandboxSettings; // Sandbox settings (enabled, network proxy ports, etc.)
/** Additional environment variables to pass to the Claude child process (merged after process.env) */
additionalEnv?: Record<string, string>;
/**
* Env var names to strip from the inherited `repositoryEnv` (loaded from
* the worktree's .env file) before merging into the child process env.
*
* Used by the GitHub credential brokering path to ensure real
* `GITHUB_TOKEN` / `GH_TOKEN` values present in a `.env` file never
* propagate to the sandboxed agent — the proxy is the authoritative
* source of GitHub credentials when brokering is on. The stripped keys
* can still be set via `additionalEnv` (e.g. the brokered `GH_TOKEN`
* sentinel), since the strip applies to `repositoryEnv` only.
*/
stripEnvKeys?: readonly string[];
pathToClaudeCodeExecutable?: string; // Explicit path to Claude Code CLI executable (auto-resolved if not set)
extraArgs?: Record<string, string | null>; // Additional CLI arguments to pass to Claude Code (e.g., { chrome: null } for --chrome flag)
/**
Expand Down
86 changes: 86 additions & 0 deletions packages/claude-runner/test/strip-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Learning tests for `stripKeys` — the env filter applied at the merge step
// in ClaudeRunner. Used by the GitHub credential brokering path to remove
// real `GITHUB_TOKEN` / `GH_TOKEN` values from `repositoryEnv` (loaded from
// the worktree's .env) before they reach the child process.

import { describe, expect, it } from "vitest";
import { stripKeys } from "../src/ClaudeRunner.js";

describe("stripKeys: identity behavior when nothing to strip", () => {
it("returns the input unchanged when keysToStrip is undefined", () => {
const env = { A: "1", B: "2" };
expect(stripKeys(env, undefined)).toBe(env);
});

it("returns the input unchanged when keysToStrip is empty", () => {
// Identity (===) is intentional — the function avoids the cost of
// building a Set + new object when there's nothing to filter.
// Callers spread `stripKeys(env, undefined)` unconditionally;
// pinning identity here means that costs nothing in the common case.
const env = { A: "1", B: "2" };
expect(stripKeys(env, [])).toBe(env);
});
});

describe("stripKeys: removes named keys", () => {
it("removes a single named key", () => {
expect(
stripKeys(
{ GITHUB_TOKEN: "real_token", PATH: "/usr/bin", HOME: "/home/u" },
["GITHUB_TOKEN"],
),
).toEqual({ PATH: "/usr/bin", HOME: "/home/u" });
});

it("removes multiple named keys", () => {
expect(
stripKeys(
{
GITHUB_TOKEN: "x",
GH_TOKEN: "y",
GH_ENTERPRISE_TOKEN: "z",
KEEP_ME: "ok",
},
["GITHUB_TOKEN", "GH_TOKEN", "GH_ENTERPRISE_TOKEN"],
),
).toEqual({ KEEP_ME: "ok" });
});

it("ignores keys-to-strip that aren't present in env", () => {
const env = { PATH: "/usr/bin" };
expect(stripKeys(env, ["GITHUB_TOKEN", "DOES_NOT_EXIST"])).toEqual({
PATH: "/usr/bin",
});
});

it("preserves the input — does not mutate", () => {
// Caller may keep a reference to the env for diagnostic logging.
// Strip must produce a fresh object when filtering.
const env = { GITHUB_TOKEN: "x", PATH: "/usr/bin" };
const before = JSON.stringify(env);
const result = stripKeys(env, ["GITHUB_TOKEN"]);
expect(JSON.stringify(env)).toBe(before);
// Result is a separate object.
expect(result).not.toBe(env);
});

it("is case-sensitive (matches env-var conventions on Linux)", () => {
// Linux env vars are case-sensitive. Pinning so a future "be helpful
// and lowercase-match" change can't slip through and accidentally
// strip more than the caller asked for.
const env = { github_token: "lowercase-not-real", PATH: "/usr/bin" };
expect(stripKeys(env, ["GITHUB_TOKEN"])).toEqual(env);
});

it("preserves prototype-pollution-style keys", () => {
// Ensure stripKeys uses Object.entries (own properties only) — no
// prototype walk that could leak inherited properties or get fooled
// by a poisoned prototype.
const env = { __proto__: "weird", PATH: "/usr/bin" };
const out = stripKeys(env, ["GITHUB_TOKEN"]);
expect(out.PATH).toBe("/usr/bin");
// Don't enforce specific behavior for __proto__ — Object.entries
// handles its own-property-ness correctly. The only invariant we
// care about is that PATH survives.
});
});
3 changes: 3 additions & 0 deletions packages/core/schemas/EdgeConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,9 @@
},
"logRequests": {
"type": "boolean"
},
"brokerGitHubCredentials": {
"type": "boolean"
}
},
"required": ["httpProxyPort", "socksProxyPort"],
Expand Down
3 changes: 3 additions & 0 deletions packages/core/schemas/EdgeConfigPayload.json
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,9 @@
},
"logRequests": {
"type": "boolean"
},
"brokerGitHubCredentials": {
"type": "boolean"
}
},
"required": ["httpProxyPort", "socksProxyPort"],
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/agent-runner-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,18 @@ export interface AgentRunnerConfig {
onAskUserQuestion?: OnAskUserQuestion;
/** Logger instance for the runner */
logger?: ILogger;
/**
* Env var names to strip from the inherited `repositoryEnv` (loaded from
* the worktree's `.env` file) before merging into the child process env.
*
* Used by the GitHub credential brokering path to ensure real
* `GITHUB_TOKEN` / `GH_TOKEN` values in a `.env` file never propagate to
* the sandboxed agent — the proxy is the authoritative source of GitHub
* credentials when brokering is on. Stripped keys CAN still be set via
* the runner's `additionalEnv` (e.g. the brokered `GH_TOKEN` sentinel)
* since the strip applies to `repositoryEnv` only.
*/
stripEnvKeys?: readonly string[];
/** Callback for each message received */
onMessage?: (message: AgentMessage) => void | Promise<void>;
/** Callback for errors */
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/config-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,34 @@ export const SandboxConfigSchema = z.object({
* @default true
*/
logRequests: z.boolean().optional(),

/**
* Default-on credential brokering for GitHub when the sandbox is enabled.
*
* When true (default), EdgeWorker resolves the workspace's GitHub token
* (App-installation or PAT) once on proxy start, layers `Authorization`
* header transforms onto the network policy for `api.github.com` (Bearer)
* and `github.com` (Basic with `x-access-token` username), and refreshes
* the policy periodically. The session env gets a sentinel `GH_TOKEN`
* + a git credential helper that returns the same sentinel — the proxy
* overwrites the real `Authorization` header at request time, so
* sandboxed `gh`/`git` see no real credentials. This matches the
* Cloudflare "Outbound Workers TLS auth" credential-injection model.
*
* Set to false to opt out (e.g., during migration, or if you want to
* pass real tokens via .env). When false, `GITHUB_TOKEN`/`GH_TOKEN` flow
* through to sessions as before — no policy injection, no env stripping.
*
* Has no effect when `enabled` is false.
*
* @default true (when sandbox.enabled is true)
*
* Treated as `true` when undefined; the runtime check is `!== false` so
* existing config literals don't need to set this field. (Schema-level
* `.default(true)` would make the post-parse type required and break
* literal `EdgeWorkerConfig` consumers like apps/f1.)
*/
brokerGitHubCredentials: z.boolean().optional(),
});

/**
Expand Down
Loading
Loading