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
10 changes: 8 additions & 2 deletions .github/workflows/sandbox-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ jobs:
executed=$(grep -oE 'executed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+')
passed=$(grep -oE 'passed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+')
echo "executed=${executed:-0} passed=${passed:-0}"
if [ "${executed:-0}" -lt 96 ]; then
echo "::error::Expected >=96 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR."
if [ "${executed:-0}" -lt 97 ]; then
echo "::error::Expected >=97 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control + a repository clean filter the capture runs with its egress severed), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR."
exit 1
fi

Expand Down Expand Up @@ -284,6 +284,12 @@ jobs:
assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass'
print('All 4 goal-channel E2E arms ran and passed.')

# The capture cannot switch off a repository's filter.<driver>.clean, so the only proof its run is harmless is this
# case: a KernelFact that skips without bubblewrap, and a skip is no evidence. Require it ran once and passed.
clean_filter = [r for r in results if 'GitWorkspaceIsolationE2ETests.A_repo_clean_filter_runs_during_capture_with_its_egress_severed' in r.get('testName', '')]
assert len(clean_filter) == 1 and clean_filter[0].get('outcome') == 'Passed', 'the clean-filter capture case must run once and pass'
print('The clean-filter capture case ran and passed.')

# The sealed-egress E2E returns early on a host that cannot confine, which reads as Passed; require each arm's marker.
for arm in ('durable', 'non-durable', 'relay-ipv6', 'restart', 'relay-refused', 'relay-policy-route'):
assert f'[sealed-egress-e2e] ran {arm}' in text, f'sealed-egress E2E arm "{arm}" did not run — this lane is root with bwrap and the relay helper, so it must relay'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using CodeSpace.Messages.Agents;

namespace CodeSpace.Core.Services.Agents.Workspace;

/// <summary>
/// Builds every <c>git</c> spec the platform runs over an agent's clone after the agent's turn: the capture
/// (<c>add -A</c> and the <c>diff --cached</c> trio), the re-attach capture, and the branch steps (<c>checkout -B</c>,
/// <c>add -A</c>, <c>commit</c>, <c>diff --quiet</c>, <c>rev-parse HEAD</c>). The agent can write its own <c>.git</c>
/// during the run, so by then its config, hooks and attributes are the agent's. Each spec built here makes sure a
/// tampered <c>.git</c> cannot run code with anything the agent does not already have:
/// <list type="bullet">
/// <item><c>-c core.hooksPath=/dev/null</c> — a command-line value beats the repo's own, so neither a planted
/// <c>.git/hooks/*</c> nor an agent-set hooks directory runs, and a vetoing pre-commit can no longer lose the
/// produced branch. git looks hooks up as <c>/dev/null/&lt;name&gt;</c>, which can never exist. Not an empty value:
/// git resolves <c>core.hooksPath=</c> to the filesystem root.</item>
/// <item><c>-c core.fsmonitor=</c> — the empty value turns off a repo <c>core.fsmonitor</c> program on every git
/// version. Not <c>false</c>: before 2.36 git reads the key only as a program path and runs <c>false</c> from PATH on
/// every index refresh.</item>
/// <item>a diff gets <c>--no-ext-diff --no-textconv</c>, so <c>diff.external</c>, <c>diff.&lt;driver&gt;.command</c>
/// and <c>diff.&lt;driver&gt;.textconv</c> (selected through <c>.gitattributes</c>) never run.</item>
/// <item>no network and no credential: <see cref="SandboxSpec.AllowNetwork"/> is false and the environment is empty.
/// A repo <c>filter.&lt;driver&gt;.clean</c> still runs on <c>add -A</c> — git has no command-line switch for it —
/// but only at the agent's own uid, with no secret and, under a confining runner, no egress.</item>
/// </list>
/// The <c>-c</c> values reach child git processes too (a commit's <c>gc --auto</c>), through
/// <c>GIT_CONFIG_PARAMETERS</c>. Global and system config are left alone: the worker image registers git-lfs with
/// <c>git lfs install --system</c>, and operators may set a CA bundle or proxy globally. Commands that reach the remote
/// (clone, fetch, ls-remote, the authenticated push) are not built here.
/// </summary>
internal static class AgentCloneGitCommand
{
/// <summary>The config every command gets ahead of its own arguments.</summary>
internal static readonly IReadOnlyList<string> HardeningConfig = new[] { "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=" };

/// <summary>The flags a <c>diff</c> gets right after the subcommand.</summary>
private static readonly IReadOnlyList<string> DiffDriverSuppression = new[] { "--no-ext-diff", "--no-textconv" };

/// <summary>The hardened spec for one git command in an agent clone; <paramref name="args"/> is the command as written without hardening, e.g. <c>["add","-A"]</c> or a <c>commit</c> with its inline <c>-c</c> identity flags.</summary>
internal static SandboxSpec Build(IReadOnlyList<string> args, string workingDirectory, IReadOnlyList<string> readOnlyPaths, int timeoutSeconds) => new()
{
Command = "git",
Args = HardenedArgs(args),
WorkingDirectory = workingDirectory,
ReadOnlyPaths = readOnlyPaths,
TimeoutSeconds = timeoutSeconds,
AllowNetwork = false,
};

/// <summary><see cref="HardeningConfig"/> ahead of the command, plus <see cref="DiffDriverSuppression"/> right after a leading <c>diff</c>.</summary>
internal static IReadOnlyList<string> HardenedArgs(IReadOnlyList<string> args) =>
IsDiff(args) ? [.. HardeningConfig, args[0], .. DiffDriverSuppression, .. args.Skip(1)] : [.. HardeningConfig, .. args];

private static bool IsDiff(IReadOnlyList<string> args) => args.Count > 0 && args[0] == "diff";
}
Loading
Loading