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
3 changes: 3 additions & 0 deletions .github/workflows/lint-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,15 @@ jobs:
node-version: [24.x]
package:
[
'@metamask/kernel-cli',
'@metamask/kernel-node-runtime',
'@ocap/extension',
'@ocap/omnium-gatherum',
'@ocap/evm-wallet-experiment',
]
include:
- package: '@metamask/kernel-cli'
directory: kernel-cli
- package: '@metamask/kernel-node-runtime'
directory: kernel-node-runtime
- package: '@ocap/extension'
Expand Down
4 changes: 4 additions & 0 deletions packages/kernel-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** `executeDBQuery`, `clearState`, and `terminateAllVats` are no longer served on the daemon control socket unless the daemon is started with `OCAP_DEV_MODE=true` ([#1034](https://github.com/MetaMask/ocap-kernel/pull/1034))
- `executeDBQuery` runs caller-supplied SQL against kernel state; it has no place in a deployed configuration. In default mode its handler is not registered at all, rather than being refused by name.
- The refusal names the flag, so a caller who hits it is told which daemon to restart and how.
- `$OCAP_HOME` is created `0700` and the daemon socket `0600`, instead of inheriting the ambient umask. The directory mode is reapplied on every start, so an `$OCAP_HOME` created before this change is brought forward rather than keeping its old permissions ([#1034](https://github.com/MetaMask/ocap-kernel/pull/1034))
- The daemon log filters entries below a minimum severity, defaulting to `info`, so high-volume `debug` output (refcount churn and similar) no longer dominates `daemon.log`; set `$OCAP_DAEMON_LOG_LEVEL` to `debug` to record everything again ([#1008](https://github.com/MetaMask/ocap-kernel/pull/1008))
- Relay state files (`relay.pid`, `relay.addr`) now live in their own directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) instead of under `$OCAP_HOME`, so one libp2p relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))

Expand Down
59 changes: 53 additions & 6 deletions packages/kernel-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,63 @@ Stop the daemon and delete all state.

Send an RPC method call to the daemon. Defaults to `getStatus` when `method` is omitted.

## Trust Model of the Control Socket

**Anyone who can open the daemon's Unix socket controls the kernel.** The socket
has no authentication, and none is planned: `launchSubcluster` runs vat code from
a caller-supplied `bundleSpec` in a worker thread of the daemon process, and
`queueMessage` invokes any method on any object the kernel holds — krefs are
sequentially numbered, so a caller can simply enumerate them. There is no useful
subset of the RPC surface that a partially-trusted caller could safely be given.

Authorization is therefore entirely filesystem permissions, and the daemon sets
them explicitly rather than inheriting the ambient umask:

- `$OCAP_HOME` (default `~/.ocap`) is `0700`, applied on every start so a
directory created by an older version is brought forward too.
- `daemon.sock` is `0600`.

Two consequences worth being deliberate about:

- Anything that can read `$OCAP_HOME` can also read `kernel.sqlite` directly.
The socket mode is not the only thing protecting kernel state.
- Pointing `$OCAP_SOCKET_PATH` at a shared directory such as `/tmp` moves the
socket out from behind the `0700` directory. The `0600` mode still holds, but
the socket is then only as private as its own mode.

These modes are POSIX-only. Windows is not a supported platform for the daemon.

### Dev-only methods

`executeDBQuery`, `clearState`, and `terminateAllVats` are withheld unless the
daemon is started with `OCAP_DEV_MODE=true`:

```sh
OCAP_DEV_MODE=true ocap daemon start
```

`executeDBQuery` is the one that motivates the flag — it runs caller-supplied SQL
against kernel state, which is indispensable while debugging and has no place in
a deployed configuration. In default mode its handler is not registered at all,
so nothing reachable from the socket can call it.

`clearState` joins it as the whole-kernel `reset()`, and `terminateAllVats` for
symmetry. Per-vat operations (`terminateVat`, `terminateSubcluster`, `revoke`)
are part of normal operation and stay reachable, so this narrows the surface
without being a security boundary — see the trust model above.

The flag is read once, when the daemon starts. If `ocap daemon exec` reports a
method as dev-only despite `OCAP_DEV_MODE=true`, an already-running daemon is
serving the request; `ocap daemon stop` and start again.

## Known Limitations

The daemon is a prototype. The following limitations apply:

1. **`executeDBQuery` accepts arbitrary SQL** — any CLI user can execute unrestricted SQL against the kernel database. For production, this should be removed or restricted to read-only queries.
2. **No socket permission enforcement** — the Unix socket is created with default permissions. Any local user can connect and issue commands. For production, socket permissions should be restricted to `0600`.
3. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this.
4. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory.
5. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation.
6. **PID file is vulnerable to PID reuse** — if the daemon crashes without cleaning up `daemon.pid` and the OS reassigns that PID to an unrelated process, `stopDaemon` may signal the wrong process. A lockfile (`flock`) mechanism would eliminate this risk (and also solve limitation #3).
1. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this.
2. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory.
3. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation.
4. **PID file is vulnerable to PID reuse** — if the daemon crashes without cleaning up `daemon.pid` and the OS reassigns that PID to an unrelated process, `stopDaemon` may signal the wrong process. A lockfile (`flock`) mechanism would eliminate this risk (and also solve limitation #1).

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion packages/kernel-cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ const yargsInstance = yargs(hideBin(process.argv))
)
.example(
'$0 daemon exec executeDBQuery \'{"sql":"SELECT * FROM kv LIMIT 5"}\'',
'Run a SQL query',
'Run a SQL query (requires a daemon started with OCAP_DEV_MODE=true)',
)
.option('timeout', {
describe: 'Read timeout in seconds (default: no timeout)',
Expand Down
26 changes: 23 additions & 3 deletions packages/kernel-cli/src/commands/daemon-entry.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import '@metamask/kernel-shims/endoify-node';
import { makeKernel } from '@metamask/kernel-node-runtime';
import { startDaemon } from '@metamask/kernel-node-runtime/daemon';
import {
DEV_ONLY_METHODS,
startDaemon,
} from '@metamask/kernel-node-runtime/daemon';
import type { DaemonHandle } from '@metamask/kernel-node-runtime/daemon';
import { stringify } from '@metamask/kernel-utils';
import type { LogEntry } from '@metamask/logger';
import { Logger } from '@metamask/logger';
import { appendFileSync, rmSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import {
cleanUpFailedStartup,
logBestEffort,
makeDaemonRunLoopWiring,
} from './run-loop-failure.ts';
import { resolveDevMode } from '../dev-mode.ts';
import { getOcapHome } from '../ocap-home.ts';
import { isProcessAlive } from '../utils.ts';

Expand Down Expand Up @@ -91,7 +95,22 @@ main().catch((error) => {
* Main daemon entry point. Starts the daemon process and keeps it running.
*/
async function main(): Promise<void> {
await mkdir(ocapDir, { recursive: true });
// 0o700 so no other local user can enter the directory and reach the
// socket or the database inside it. `mode` applies only to directories
// mkdir creates, so the chmod is what brings an $OCAP_HOME from before
// this was enforced up to the same footing.
await mkdir(ocapDir, { recursive: true, mode: 0o700 });
await chmod(ocapDir, 0o700);

const devMode = resolveDevMode({
env: process.env,
warn: (message) => logger.warn(message),
});
if (devMode) {
logger.warn(
`Dev mode enabled (OCAP_DEV_MODE=true): ${DEV_ONLY_METHODS.join(', ')} are served on the control socket.`,
);
}

const socketPath =
process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock');
Expand Down Expand Up @@ -150,6 +169,7 @@ async function main(): Promise<void> {
kernel,
kernelDatabase,
onShutdown: async () => shutdown('RPC shutdown'),
devMode,
});
} catch (error) {
await cleanUpFailedStartup({
Expand Down
43 changes: 43 additions & 0 deletions packages/kernel-cli/src/dev-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest';

import { resolveDevMode } from './dev-mode.ts';

describe('resolveDevMode', () => {
it.each([
{ value: undefined, devMode: false, warned: false },
{ value: 'true', devMode: true, warned: false },
// Everything below must stay off. `1` and `TRUE` are the values someone
// reaches for by habit, and treating either as truthy would serve
// arbitrary SQL on a daemon whose operator believed the flag was unset.
{ value: '1', devMode: false, warned: true },
{ value: 'TRUE', devMode: false, warned: true },
{ value: 'True', devMode: false, warned: true },
{ value: 'yes', devMode: false, warned: true },
{ value: 'false', devMode: false, warned: true },
{ value: '', devMode: false, warned: true },
{ value: ' true', devMode: false, warned: true },
{ value: 'true ', devMode: false, warned: true },
])('resolves $value to devMode=$devMode', ({ value, devMode, warned }) => {
const warn = vi.fn();
const env = (
value === undefined ? {} : { OCAP_DEV_MODE: value }
) as NodeJS.ProcessEnv;

const result = resolveDevMode({ env, warn });

expect({ result, warnCount: warn.mock.calls.length }).toStrictEqual({
result: devMode,
warnCount: warned ? 1 : 0,
});
});

it('names the offending value in the warning', () => {
const warn = vi.fn();

resolveDevMode({ env: { OCAP_DEV_MODE: '1' }, warn });

expect(warn).toHaveBeenCalledWith(
"OCAP_DEV_MODE is set to '1', which is not 'true'; dev-only methods stay disabled.",
);
});
});
35 changes: 35 additions & 0 deletions packages/kernel-cli/src/dev-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Resolve whether to serve the dev-only RPC methods.
*
* Deliberately exact-match: a daemon that served arbitrary SQL because
* someone wrote `OCAP_DEV_MODE=1` would be a nasty surprise. A set-but-
* unrecognized value is warned about rather than ignored, since silently
* treating it as "off" is the other way to surprise someone.
*
* Takes `env` and `warn` as parameters rather than reaching for
* `process.env` and a module-scope logger, so the value table above can be
* tested — `daemon-entry` calls `main()` at module load and cannot be
* imported.
*
* @param options - Resolution options.
* @param options.env - The environment to read `OCAP_DEV_MODE` from.
* @param options.warn - Called with a message when the variable is set to
* something other than `'true'`.
* @returns Whether dev mode is enabled.
*/
export function resolveDevMode({
env,
warn,
}: {
env: NodeJS.ProcessEnv;
warn: (message: string) => void;
}): boolean {
const raw = env.OCAP_DEV_MODE;
if (raw === undefined || raw === 'true') {
return raw === 'true';
}
warn(
`OCAP_DEV_MODE is set to '${raw}', which is not 'true'; dev-only methods stay disabled.`,
);
return false;
}
61 changes: 57 additions & 4 deletions packages/kernel-cli/test/e2e/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable n/no-sync -- existsSync is fine in tests */
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

Expand All @@ -11,6 +11,18 @@ import { sendCommand, pingDaemon } from '../../src/commands/daemon-client.ts';
// NOTE: `redeem-url` is not tested here because it requires remote comms
// infrastructure (relay + peer). See unit tests in src/commands/daemon.test.ts.

/**
* Read a path's permission bits.
*
* @param path - The path to stat.
* @returns The permission bits, maskable against octal literals.
*/
async function permissionsOf(path: string): Promise<number> {
const { mode } = await stat(path);
// eslint-disable-next-line no-bitwise -- the only way to read mode bits
return mode & 0o777;
}

describe('Daemon CLI e2e', { timeout: 60_000 }, () => {
describe('start / exec / queueMessage', () => {
let daemon: TestDaemon;
Expand Down Expand Up @@ -51,15 +63,19 @@ describe('Daemon CLI e2e', { timeout: 60_000 }, () => {
expect((response.error as { code: number }).code).toBe(-32601);
});

it('executes DB query with SQL param', async () => {
it('refuses executeDBQuery without OCAP_DEV_MODE', async () => {
const response = await sendCommand({
socketPath: daemon.socketPath,
method: 'executeDBQuery',
params: { sql: 'SELECT key, value FROM kv LIMIT 5' },
});

expect(response.error).toBeUndefined();
expect(Array.isArray(response.result)).toBe(true);
expect(response.result).toBeUndefined();
expect(response.error).toStrictEqual({
code: -32601,
message:
"Method not found: 'executeDBQuery' is served only when the daemon runs with OCAP_DEV_MODE=true",
});
});

it('returns error for queueMessage with invalid kref', async () => {
Expand All @@ -76,6 +92,43 @@ describe('Daemon CLI e2e', { timeout: 60_000 }, () => {
expect(existsSync(join(daemon.ocapHome, 'kernel.sqlite'))).toBe(true);
expect(existsSync(join(daemon.ocapHome, 'daemon.log'))).toBe(true);
});

it('restricts OCAP_HOME and the socket to the owning user', async () => {
// The helper leaves OCAP_HOME at 0755, so this covers the upgrade
// path: a home directory created before the daemon enforced a mode.
const [homeMode, socketMode] = await Promise.all([
permissionsOf(daemon.ocapHome),
permissionsOf(daemon.socketPath),
]);

expect({ homeMode, socketMode }).toStrictEqual({
homeMode: 0o700,
socketMode: 0o600,
});
});
});

describe('dev mode', () => {
let daemon: TestDaemon;

beforeAll(async () => {
daemon = await spawnTestDaemon({ devMode: true });
});

afterAll(async () => {
await daemon.cleanup();
});

it('executes DB query with SQL param', async () => {
const response = await sendCommand({
socketPath: daemon.socketPath,
method: 'executeDBQuery',
params: { sql: 'SELECT key, value FROM kv LIMIT 5' },
});

expect(response.error).toBeUndefined();
expect(Array.isArray(response.result)).toBe(true);
});
});

describe('stop / purge', () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/kernel-cli/test/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { chmod, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -51,12 +51,22 @@ export type TestDaemon = {
/**
* Spawn a real daemon process in a temporary directory.
*
* @param options - Spawn options.
* @param options.devMode - Start the daemon with `OCAP_DEV_MODE=true`, so it
* serves the dev-only RPC methods.
* @returns The OCAP home dir, socket path, and cleanup function.
*/
export async function spawnTestDaemon(): Promise<TestDaemon> {
export async function spawnTestDaemon({
devMode = false,
}: { devMode?: boolean } = {}): Promise<TestDaemon> {
const ocapHome = await mkdtemp(join(tmpdir(), 'ocap-e2e-'));
const socketPath = join(ocapHome, 'daemon.sock');

// `mkdtemp` already returns 0700; loosen it so the daemon's chmod has
// something to actually tighten, which is the case a fresh directory
// can't exercise.
await chmod(ocapHome, 0o755);

const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const entryPath = join(packageRoot, 'dist/commands/daemon-entry.mjs');

Expand All @@ -67,6 +77,9 @@ export async function spawnTestDaemon(): Promise<TestDaemon> {
...process.env,
OCAP_HOME: ocapHome,
OCAP_SOCKET_PATH: socketPath,
// Pinned off rather than omitted, or an ambient `OCAP_DEV_MODE=true`
// would make the refusal assertions vacuous.
OCAP_DEV_MODE: devMode ? 'true' : undefined,
},
});
const { pid } = child;
Expand Down
4 changes: 4 additions & 0 deletions packages/kernel-node-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** `startRpcSocketServer` and `startDaemon` no longer serve `executeDBQuery`, `clearState`, or `terminateAllVats` by default; pass `devMode: true` to restore them ([#1034](https://github.com/MetaMask/ocap-kernel/pull/1034))
- In default mode the handlers are withheld rather than merely refused by name, so the `executeDBQuery` hook is never constructed and no handler can reach `kernelDatabase.executeQuery`. The exported `DEV_ONLY_METHODS` names the withheld set.
- This is not a security boundary on its own: `launchSubcluster` and `queueMessage` remain reachable and either suffices to drive the kernel arbitrarily. Anyone able to open the socket controls the kernel — see the trust model in `@metamask/kernel-cli`'s README.
- The RPC socket is created `0600`. The bind runs under a `0o177` umask, except when the server is started off the main thread (`process.umask` throws on a worker thread), so the socket is not briefly reachable by other local users between bind and `chmod`; a `chmod` that fails closes the server rather than leaving it listening on a socket whose mode is unknown ([#1034](https://github.com/MetaMask/ocap-kernel/pull/1034))
- **BREAKING:** `makeIOChannelFactory` is now `makeIOListenerFactory`, and `makeSocketIOChannel` is now `makeSocketIOListener`. The Unix-socket server hands each connection to `accept()` as its own `IOChannel`, whose receive buffer, decoder, line queue, and reader queue are local to that connection, so any number of peers can be served concurrently. Connections arriving before `accept()` is called are queued rather than dropped. Gone with the single-client design: the shared `currentSocket`, the session-boundary latch, the merged line queue, and the `socket.destroy()` that rejected every second connection ([#1007](https://github.com/MetaMask/ocap-kernel/pull/1007))
- **BREAKING:** Drop `platformOptions.fetch` from `makeNodeJsVatSupervisor` ([#942](https://github.com/MetaMask/ocap-kernel/pull/942))
- `fetch` is now a vat endowment; stub `globalThis.fetch` directly if needed
Expand Down
2 changes: 1 addition & 1 deletion packages/kernel-node-runtime/src/daemon/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { startDaemon } from './start-daemon.ts';
export type { StartDaemonOptions, DaemonHandle } from './start-daemon.ts';
export { startRpcSocketServer } from './rpc-socket-server.ts';
export { startRpcSocketServer, DEV_ONLY_METHODS } from './rpc-socket-server.ts';
export type { RpcSocketServerHandle } from './rpc-socket-server.ts';
export { deleteDaemonState } from './delete-daemon-state.ts';
export type { DeleteDaemonStateOptions } from './delete-daemon-state.ts';
Expand Down
Loading
Loading