Skip to content
Open
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
1 change: 1 addition & 0 deletions src/backfill-container-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function backfillContainerConfigs(): void {
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
cli_scope: 'group',
security_json: null,
updated_at: new Date().toISOString(),
};

Expand Down
12 changes: 12 additions & 0 deletions src/container-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export interface AdditionalMountConfig {
readonly?: boolean;
}

/** Per-group container hardening overrides. Absent fields fall back to hardcoded defaults. */
export interface SecurityConfig {
capDrop?: string[];
capAdd?: string[];
// Tri-state: undefined → use hardcoded default; null → omit the flag (no limit); value → apply it.
pidsLimit?: number | null;
memory?: string | null;
noNewPrivileges?: boolean;
}

/** Shape of the materialized `container.json` file read by the container runner. */
export interface ContainerConfig {
mcpServers: Record<string, McpServerConfig>;
Expand All @@ -43,6 +53,7 @@ export interface ContainerConfig {
maxMessagesPerPrompt?: number;
model?: string;
effort?: string;
security?: SecurityConfig;
}

/** Build a `ContainerConfig` from a DB row + agent group identity. */
Expand All @@ -63,6 +74,7 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
security: row.security_json ? (JSON.parse(row.security_json) as SecurityConfig) : undefined,
};
}

Expand Down
43 changes: 42 additions & 1 deletion src/container-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { resolveProviderName } from './container-runner.js';
import { resolveProviderName, securityArgs } from './container-runner.js';

describe('resolveProviderName', () => {
it('prefers session over container config', () => {
Expand All @@ -25,3 +25,44 @@ describe('resolveProviderName', () => {
expect(resolveProviderName(null, '')).toBe('claude');
});
});

describe('securityArgs', () => {
it('emits safe defaults when no override given', () => {
const args = securityArgs(undefined);
expect(args).toContain('--security-opt');
expect(args).toContain('no-new-privileges:true');
expect(args).toContain('--cap-drop');
expect(args).toContain('ALL');
expect(args.join(' ')).toContain('--pids-limit 2048');
expect(args.join(' ')).not.toContain('--cap-add');
expect(args.join(' ')).not.toContain('--memory');
});

it('honors capAdd override', () => {
const args = securityArgs({ capAdd: ['SYS_ADMIN'] });
expect(args.join(' ')).toContain('--cap-add SYS_ADMIN');
});

it('omits pids-limit when explicitly null', () => {
const args = securityArgs({ pidsLimit: null });
expect(args.join(' ')).not.toContain('--pids-limit');
});

it('adds a memory cap when set', () => {
const args = securityArgs({ memory: '4g' });
expect(args.join(' ')).toContain('--memory 4g');
});

it('drops no-new-privileges when disabled', () => {
const args = securityArgs({ noNewPrivileges: false });
expect(args.join(' ')).not.toContain('no-new-privileges');
});

it('omits pids-limit when set to 0 (avoids cgroups v2 spawn crash)', () => {
expect(securityArgs({ pidsLimit: 0 }).join(' ')).not.toContain('--pids-limit');
});

it('omits memory when explicitly null', () => {
expect(securityArgs({ memory: null }).join(' ')).not.toContain('--memory');
});
});
32 changes: 32 additions & 0 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,35 @@ export function resolveProviderName(
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
}

const DEFAULT_PIDS_LIMIT = 2048;

/**
* Build container hardening flags. Hardcoded safe defaults
* (no-new-privileges, cap-drop=ALL, pids-limit=2048, no memory cap),
* overridable per-group via container.json `security`. Pure so the
* defaults/override precedence is unit-testable without spawning.
*/
export function securityArgs(security?: import('./container-config.js').SecurityConfig): string[] {
const args: string[] = [];

if (security?.noNewPrivileges ?? true) {
args.push('--security-opt', 'no-new-privileges:true');
}

const capDrop = security?.capDrop ?? ['ALL'];
for (const cap of capDrop) args.push('--cap-drop', cap);

const capAdd = security?.capAdd ?? [];
for (const cap of capAdd) args.push('--cap-add', cap);

const pids = security?.pidsLimit === undefined ? DEFAULT_PIDS_LIMIT : security.pidsLimit;
if (pids != null && pids > 0) args.push('--pids-limit', String(pids));

if (security?.memory) args.push('--memory', security.memory);

return args;
}

function resolveProviderContribution(
session: Session,
agentGroup: AgentGroup,
Expand Down Expand Up @@ -407,6 +436,9 @@ async function buildContainerArgs(
): Promise<string[]> {
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];

// Container hardening — privilege flags + generous safety caps.
args.push(...securityArgs(containerConfig.security));

// Environment — only vars read by code we don't own.
// Everything NanoClaw-specific is in container.json (read by runner at startup).
args.push('-e', `TZ=${TIMEZONE}`);
Expand Down
15 changes: 11 additions & 4 deletions src/db/container-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ const SCALAR_COLUMNS = new Set([
'max_messages_per_prompt',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
const JSON_COLUMNS = new Set([
'skills',
'mcp_servers',
'packages_apt',
'packages_npm',
'additional_mounts',
'security_json',
]);

export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
Expand All @@ -29,11 +36,11 @@ export function createContainerConfig(config: ContainerConfigRow): void {
`INSERT INTO container_configs (
agent_group_id, provider, model, effort, image_tag, assistant_name,
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
additional_mounts, updated_at
additional_mounts, cli_scope, security_json, updated_at
) VALUES (
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
@additional_mounts, @updated_at
@additional_mounts, @cli_scope, @security_json, @updated_at
)`,
)
.run(config);
Expand Down Expand Up @@ -82,7 +89,7 @@ export function updateContainerConfigScalars(
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
export function updateContainerConfigJson(
agentGroupId: string,
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts' | 'security_json',
value: unknown,
): void {
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
Expand Down
41 changes: 41 additions & 0 deletions src/db/db-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
createPendingQuestion,
getPendingQuestion,
deletePendingQuestion,
ensureContainerConfig,
getContainerConfig,
createContainerConfig,
} from './index.js';

function now() {
Expand Down Expand Up @@ -428,3 +431,41 @@ describe('pending questions', () => {
expect(getPendingQuestion('q-1')).toBeUndefined();
});
});

// ── Container Configs ──

describe('container configs', () => {
it('container_configs has nullable security_json column defaulting to null', () => {
createAgentGroup({ id: 'ag-sec', name: 'Sec', folder: 'sec', agent_provider: null, created_at: now() });
ensureContainerConfig('ag-sec');
const row = getContainerConfig('ag-sec');
expect(row).toBeDefined();
expect(row!.security_json).toBeNull();
});

it('createContainerConfig persists cli_scope and security_json', () => {
createAgentGroup({ id: 'ag-full', name: 'Full', folder: 'full', agent_provider: null, created_at: now() });
const secJson = JSON.stringify({ capDrop: ['ALL'], pidsLimit: 256 });
createContainerConfig({
agent_group_id: 'ag-full',
provider: null,
model: null,
effort: null,
image_tag: null,
assistant_name: null,
max_messages_per_prompt: null,
skills: '["all"]',
mcp_servers: '{}',
packages_apt: '[]',
packages_npm: '[]',
additional_mounts: '[]',
cli_scope: 'global',
security_json: secJson,
updated_at: now(),
});
const row = getContainerConfig('ag-full');
expect(row).toBeDefined();
expect(row!.cli_scope).toBe('global');
expect(row!.security_json).toBe(secJson);
});
});
11 changes: 11 additions & 0 deletions src/db/migrations/016-container-security-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';

export const migration016: Migration = {
version: 16,
name: 'container-security-config',
up(db: Database.Database) {
// Nullable: existing rows get NULL → hardcoded safe defaults apply at read time.
db.prepare('ALTER TABLE container_configs ADD COLUMN security_json TEXT').run();
},
};
2 changes: 2 additions & 0 deletions src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { migration012 } from './012-channel-registration.js';
import { migration013 } from './013-approval-render-metadata.js';
import { migration014 } from './014-container-configs.js';
import { migration015 } from './015-cli-scope.js';
import { migration016 } from './016-container-security-config.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';

Expand All @@ -35,6 +36,7 @@ const migrations: Migration[] = [
migration013,
migration014,
migration015,
migration016,
];

export function runMigrations(db: Database.Database): void {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ContainerConfigRow {
packages_npm: string; // JSON: string[]
additional_mounts: string; // JSON: AdditionalMountConfig[]
cli_scope: string; // 'disabled' | 'group' | 'global'
security_json: string | null; // JSON: SecurityConfig | null
updated_at: string;
}

Expand Down
Loading