Skip to content

Commit 46567ea

Browse files
mchwangclaude
andauthored
Keep no Docker daemon log of agent containers (#165) (#190)
* Keep no Docker daemon log of agent containers (#165) The supervisor reads an agent's output through `docker start --attach` and bounds it there. Agent containers now use `--log-driver=none`, so the daemon keeps no copy that a fast writer could grow before the supervisor stops the container, and validation refuses a container with any other log setting before it starts. Closes #165 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Accept the daemon's cache log options; test the no-log rule directly (#165) Review round 1: a daemon copies its own cache-* log options into every container, so an exact empty-options check would refuse every agent on such a host. The rule is now keepsNoDaemonLog, with unit cases for the options only the API can set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check the created container with the no-log rule; scope and wording (#165) Review round 2: the inspect test accepts the daemon's cache-* options as the validator does; the rule's unit test runs in the parallel project (agent-daemon-log); docs record that only agent containers are covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the refusal test for what it shows; exact log wording (#165) Review round 3 nits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List every container that keeps the default log (#165) Review round 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aced300 commit 46567ea

7 files changed

Lines changed: 64 additions & 4 deletions

File tree

‎agents/container/profile.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ export async function createContainerProfile(options: ProfileOptions): Promise<C
325325
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
326326
'--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
327327
'--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private',
328+
// The supervisor reads the agent's output through `docker start --attach` and bounds it there. The daemon keeps no
329+
// copy (#165): with the daemon's default driver (json-file, unbounded unless the daemon is set otherwise) it could grow
330+
// until the supervisor stops the container.
331+
'--log-driver=none',
328332
`--network=${options.network.name}`, '--dns=127.0.0.1', '--env', 'HOME=/home/codeboost',
329333
'--env', `CODEBOOST_PHASE=${invocation.phase}`,
330334
'--label', `io.codeboost.invocation=${ownershipId}`, ...ownerLabelArgs(containerOwner),

‎agents/container/run.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ type Inspect = {
190190
CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null;
191191
StorageOpt?: Record<string, string> | null; CgroupParent: string;
192192
RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Runtime: string;
193+
LogConfig?: DaemonLogConfig;
193194
Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record<string, string> | null;
194195
Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean;
195196
TmpfsOptions?: { SizeBytes?: number; Mode?: number; Options?: unknown };
@@ -200,6 +201,16 @@ type Inspect = {
200201
NetworkSettings: { Networks: Record<string, unknown>; Ports: Record<string, unknown> };
201202
};
202203

204+
type DaemonLogConfig = { Type?: string; Config?: Record<string, string> | null } | null | undefined;
205+
/**
206+
* Whether an inspected `HostConfig.LogConfig` keeps no daemon log (#165): driver `none`, and only options whose key
207+
* starts with `cache-`. A daemon copies its own `cache-*` log options into every container, whatever its driver (moby
208+
* v27.4.0, `logcache.MergeDefaultLogConfig`), and with `none` no logger or cache starts, so they do nothing. The CLI
209+
* refuses any option with `none`, but the API stores whatever it is given.
210+
*/
211+
export const keepsNoDaemonLog = (config: DaemonLogConfig): boolean => config?.Type === 'none'
212+
&& Object.keys(config.Config ?? {}).every(key => key.startsWith('cache-'));
213+
203214
/** Validate daemon-resolved configuration before starting an agent. */
204215
export async function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000,
205216
signal?: AbortSignal, processLifecycle?: ProcessGroupLifecycle): Promise<void> {
@@ -244,7 +255,8 @@ export async function validateContainer(container: string, profile: ContainerPro
244255
|| host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0
245256
|| host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== ''
246257
|| !['', 'no'].includes(host.RestartPolicy?.Name ?? '') || (host.RestartPolicy?.MaximumRetryCount ?? 0) !== 0
247-
|| host.Runtime !== 'runc')
258+
|| host.Runtime !== 'runc'
259+
|| !keepsNoDaemonLog(host.LogConfig))
248260
throw new Error('Container daemon configuration is missing required lockdown.');
249261
if (JSON.stringify(host.Dns) !== JSON.stringify(['127.0.0.1']))
250262
throw new Error('Container DNS configuration changed.');

‎docs/designs/codeboost-plan-indexed-review.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ Nothing else from your computer is inside. So `~/.ssh`, `~/.config/gh`, `~/.npmr
239239
| Runs as a non-root user; all Linux capabilities dropped (`--cap-drop=ALL`); `--security-opt=no-new-privileges` | The agent cannot become root or use privileged system calls. |
240240
| No `--privileged`, no `--device`, no host network, no Docker socket, and no host mounts other than the bounded worktree, separate read-only Git metadata, runner-prepared read-only schema input, and read-only sign-in file | Nothing on the host is reachable through the container. |
241241
| Limits on processes, memory, and CPU (`--pids-limit`, `--memory`, `--cpus`) | Bound the agent's consumption of these resources; they do not guarantee zero host performance impact. |
242+
| No Docker daemon log (`--log-driver=none`) | The supervisor reads the agent's output as it is written (`docker start --attach`) and bounds it there. The daemon keeps no copy that a fast writer could grow before the supervisor stops the container. |
242243
| `/work` lives on a dedicated size-limited task filesystem with hard byte and inode limits, allocated before the invocation; never an unbounded host-directory bind | Large output and many tiny files fail inside the task filesystem instead of growing without limit on the host. |
243244

244245
**Output and time enforcement.** A trusted supervisor outside the invocation container incrementally captures every agent/check command with hard limits of 16 MiB stdout, 4 MiB stderr, and 20 MiB combined, including protocol envelopes. Host buffers, persisted logs, and UI forwarding obey the same per-invocation limits; task-log retention has a configured total byte cap and never accumulates unbounded output across retries. Before launch, require a finite wall deadline (default 10 minutes, capped by the remaining task budget; configured overrides must also be finite). On overflow/deadline or capture failure, stop consuming into retained buffers, terminate the entire container including descendants, escalate to forced container kill if graceful termination fails, and record a bounded failure diagnostic. Never wait indefinitely for EOF from an orphan child, silently treat truncated output as success, or run tests/commit after this failure. Container tests must exercise infinite stdout, infinite stderr, mixed streams, and a child ignoring SIGTERM, while measuring bounded host capture and completed termination. For structured vendor output, the transport cap includes envelope headroom; separately enforce the 1 MiB plan-document limit after extraction.

‎docs/implementation/agent-isolation.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa
3737
| Planning and questions cannot run a process | `agent-policy`: tool sets exclude the command tool, and command dispatch refuses these phases |
3838
| Codex runs in no phase, because it cannot read code without its shell (see [Codex is refused in every phase](#codex-is-refused-in-every-phase)) | `agent-policy`: the Codex command refuses all five phases; `agent-adapter`: the Codex start call refuses them and allocates nothing; `runner-execution`: an execute task with Codex is refused before task storage exists |
3939
| A planning answer is checked against the mounted schema | `agent-policy`: only the Claude planning command passes `--json-schema`; `agent-container`: the profile refuses a command whose schema differs from the mounted file; `agent-adapter`: the answer is read from `structured_output` |
40+
| The Docker daemon keeps no log of an agent container (#165): the supervisor's bounded capture through `docker start --attach` is the only copy of its output. This covers the agent container only. The runner's other containers keep the daemon's default log: the task-storage script containers (`docker run --rm`, `agents/container/storage.ts`), whose log is removed with the container while the client bounds what it reads, and the storage keeper and egress proxy, which write no output | `agent-container`: the created container's `LogConfig` is driver `none` with no option but `cache-*` ones; the validator refuses a container with a `json-file` log or a bounded `local` log; every probe that returns output shows the attach still delivers it. `agent-daemon-log`: the rule (`keepsNoDaemonLog`) also refuses driver `none` with a log option, which only the API can set, and accepts the `cache-*` options a daemon copies into every container |
4041
| Task and scratch byte and inode limits hold | `agent-container`: task capacity; scratch capacity for Codex and Claude (`/tmp`, `HOME`, `CODEX_HOME`, output directory) |
4142
| Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after |
4243
| Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) |

‎docs/implementation/plan-library-alignment.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ Issue #6 tracked the v1 plan library contract and the runner obligations that ca
2020
| Hostile repository, issue, lesson, feedback, previous-plan and continuation-context text in every authoring prompt | **This change** (revised drafts, suggestions and the continuation context) | `test/planning-author.test.ts` |
2121
| Container mounts: Git metadata on its own filesystem, read-only, with no writable hard links or aliases into it | Done before this change | `agents/container/profile.ts`, probe `metadata-alias` in `agents/policy.ts`; `test/agent-container.test.ts` ("keeps Git metadata read-only, on another filesystem, and mounted against replacement", "keeps Git metadata unchanged under link, alias, truncation and replacement attempts during %s"), negative controls in `test/agent-gate.test.ts` (real Docker) |
2222
| Process termination: output overflow, deadline and cancellation stop the whole container, including a process that ignores SIGTERM | Done before this change | `agents/adapters/supervisor.ts`; `test/agent-supervisor.test.ts` ("terminates %s at bounded output limits", "enforces a finite wall deadline and force-settles the container", "preserves the first cancellation reason until an ignored SIGTERM fully settles") (real Docker) |
23-
| Output shown in the UI bounded: captured output comes only from the supervisor, which bounds stdout and stderr; when a planning run exits with an error, the provider's message quotes its output with whitespace collapsed, cut to at most 300 UTF-16 code units (`runner/planning-provider.ts`) | Done before this change; 300-unit quote tested in this change | `test/agent-supervisor.test.ts` output-limit cases (real Docker); `test/planning-provider.test.ts` ("quotes at most 300 code units of a failed run's output, with whitespace collapsed"). The Docker daemon's own log is #165 |
23+
| Output shown in the UI bounded: captured output comes only from the supervisor, which bounds stdout and stderr; when a planning run exits with an error, the provider's message quotes its output with whitespace collapsed, cut to at most 300 UTF-16 code units (`runner/planning-provider.ts`) | Done before this change; 300-unit quote tested in this change | `test/agent-supervisor.test.ts` output-limit cases (real Docker); `test/planning-provider.test.ts` ("quotes at most 300 code units of a failed run's output, with whitespace collapsed"). The Docker daemon's own log is #165 (*later:* the daemon keeps none) |
2424
| Output limits at the exact byte | **This change** (tests; the supervisor already enforced the limits) | Supervisor enforcement: `test/agent-supervisor.test.ts` (real Docker). Decoder behaviour only, not a limit: the Claude decoder handles an envelope of exactly the stdout ceiling, and the 1 MiB parser limit applies to the decoded answer at the exact byte: `test/agent-adapter.test.ts`. At #6 the decoder re-serialized the answer, so the limit applied to the re-encoded text. *Later:* #166 returns the exact text of `structured_output` in the CLI's envelope, so the limit applies to those bytes |
2525
| Prompt byte budget before spawn | Done before this change | `MAX_PROMPT_BYTES` in `core/planning-author.ts`; `test/planning-author.test.ts` ("accepts exactly the prompt byte limit and rejects the next byte without truncation") |
2626
| Model budget before spawn | Moved to #163 | — |
2727
| Planning profile probed at startup | Moved to #164 | — |
28-
| Docker daemon log limits | Moved to #165 | — |
28+
| Docker daemon log limits | Moved to #165. *Later:* fixed in #165 with `--log-driver=none`, checked before launch | `agents/container/profile.ts`, `agents/container/run.ts`; `test/agent-container.test.ts` ("creates the agent container with no Docker daemon log", "refuses an agent container with %s at validation") (real Docker), `test/agent-daemon-log.test.ts`. Agent containers only; the runner's other containers (task-storage scripts, the keeper, the egress proxy) keep the daemon's default log |
2929
| Claude structured output under the v1 parsing contract | Moved to #166 (found during this change). *Later:* fixed in #166 | `agents/adapters/claude.ts`; `test/agent-adapter.test.ts` ("lets draft validation refuse %s in a structured answer") |
3030

3131
## Schema loading

‎test/agent-container.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/c
1313
import { assertContainerProfile, createContainerProfile, disposeContainerProfile,
1414
containerProfileResources, isContainerProfileAuthentic } from '../agents/container/profile.ts';
1515
import { createValidatedContainer, disposeValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer,
16-
startValidatedContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts';
16+
startValidatedContainer, hasExactOptions, keepsNoDaemonLog, validateContainer } from '../agents/container/run.ts';
1717
import { createTaskClone } from '../git/clone.ts';
1818
import { hasOwnerLabels } from '../agents/labels.ts';
1919
import { exportTaskDiff, exportTaskPaths, importTaskPaths, isRecoveredTaskStorage, prepareTaskFilesystemsAsync, taskFilesystemOwner, EXPORT_SCRIPT,
@@ -224,6 +224,31 @@ describe('real Docker agent isolation', () => {
224224
expect(args).toContain('--cgroupns=private');
225225
}, 60_000);
226226

227+
// #165: the supervisor bounds the output it reads through `docker start --attach`; the daemon must keep no copy.
228+
it('creates the agent container with no Docker daemon log', async () => {
229+
const live = await profile(fixture(), 'planning', 'noop');
230+
await createValidatedContainer(live); containers.add(live.name);
231+
const inspected = JSON.parse(docker('container', 'inspect', live.name))[0] as
232+
{ HostConfig: { LogConfig: { Type: string; Config: Record<string, string> | null } } };
233+
// Driver `none`, with no option but the `cache-*` ones a daemon copies from its own log options, if it has any.
234+
expect(inspected.HostConfig.LogConfig.Type).toBe('none');
235+
expect(Object.keys(inspected.HostConfig.LogConfig.Config ?? {}).filter(key => !key.startsWith('cache-'))).toEqual([]);
236+
expect(keepsNoDaemonLog(inspected.HostConfig.LogConfig)).toBe(true);
237+
await disposeValidatedContainer(live); containers.delete(live.name);
238+
}, 60_000);
239+
240+
it.each([
241+
['a json-file daemon log', (args: readonly string[]) => args.map(arg => arg === '--log-driver=none' ? '--log-driver=json-file' : arg)],
242+
['a bounded local daemon log', (args: readonly string[]) => args.flatMap(arg => arg === '--log-driver=none'
243+
? ['--log-driver=local', '--log-opt', 'max-size=1m', '--log-opt', 'max-file=1'] : [arg])],
244+
] as const)('refuses an agent container with %s at validation', async (_label, change) => {
245+
const valid = await profile(fixture(), 'planning', 'must-not-run');
246+
expect(valid.args).toContain('--log-driver=none');
247+
docker(...change(valid.args)); containers.add(valid.name);
248+
await expect(validateContainer(valid.name, valid)).rejects.toThrow('lockdown');
249+
docker('rm', '--force', valid.name); containers.delete(valid.name);
250+
}, 60_000);
251+
227252
it.each(['planning', 'review', 'execute'] as const)(
228253
'keeps Git metadata unchanged under link, alias, truncation and replacement attempts during %s', async phase => {
229254
expect(await runContainer(await profile(fixture(), phase, 'metadata-alias'))).toBe('metadata-unchanged');

‎test/agent-daemon-log.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { keepsNoDaemonLog } from '../agents/container/run.ts';
3+
4+
// The validator's rule for an agent container's daemon log (#165), for configurations the CLI cannot make: it refuses
5+
// `--log-opt` with driver `none`, but the API stores any option, and a daemon copies its `cache-*` log options into
6+
// every container (moby v27.4.0). The created container itself is checked against real Docker in agent-container.
7+
describe('keepsNoDaemonLog', () => {
8+
it('accepts only driver none, with no option but cache ones', () => {
9+
expect(keepsNoDaemonLog({ Type: 'none', Config: {} })).toBe(true);
10+
expect(keepsNoDaemonLog({ Type: 'none', Config: null })).toBe(true);
11+
expect(keepsNoDaemonLog({ Type: 'none', Config: { 'cache-disabled': 'true', 'cache-max-size': '1m' } })).toBe(true);
12+
for (const config of [undefined, null, {}, { Type: 'json-file', Config: {} }, { Type: 'NONE', Config: {} },
13+
{ Type: 'local', Config: { 'max-size': '1m', 'max-file': '1' } }, { Type: 'none', Config: { mode: 'non-blocking' } },
14+
{ Type: 'none', Config: { 'max-size': '1m' } }, { Type: 'none', Config: { 'cache-disabled': 'true', mode: 'non-blocking' } },
15+
] as Parameters<typeof keepsNoDaemonLog>[0][]) expect(keepsNoDaemonLog(config)).toBe(false);
16+
});
17+
});

0 commit comments

Comments
 (0)