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
4 changes: 3 additions & 1 deletion docs/internal/flows/startup.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ Entry: `src/daemon/main.ts` `startDaemon` → `DaemonServer.start` (`src/daemon/

## Both modes, before the branch

1. `loadConfig` reads `config.json`; its warnings are logged once the logger exists.
1. `prepareDataDirectory` creates the data directory `0700` when missing; an existing one keeps
its mode, and one group or others can reach is a `warn` line once the logger exists.
`loadConfig` reads `config.json`; its warnings are logged once the logger exists.
2. `Logger` → `daemon.log`. At `log.level: debug` the `ProcessRunner` is wrapped.
3. Host info read once (`sw_vers` on macOS); bounded, never throws.
4. `EventBus` → the event history is created (`createEventHistory`). It touches no file yet.
Expand Down
5 changes: 3 additions & 2 deletions e2e/daemon-folders.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { readFile, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
Expand Down Expand Up @@ -79,12 +79,13 @@ describe("daemon folders (ADR 0034)", () => {
expect(result.stdout).toContain("Usage: simlock");
});

it("--gateway config set makes a gateway present, after which --gateway status starts it", async () => {
it("--gateway config set makes a gateway present in an owner-only folder, after which --gateway status starts it", async () => {
const env = await withDaemon({ mode: "auto" });

try {
await makeGatewayPresent(env);
expect(existsSync(join(gatewayFolder(env), "config.json"))).toBe(true);
expect((await stat(gatewayFolder(env))).mode & 0o777).toBe(0o700);

const status = await env.cli(["--gateway", "status", "--json"]);

Expand Down
4 changes: 2 additions & 2 deletions src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ Surface: [cli.mdx](../../website/src/content/docs/docs/reference/cli.mdx) — ev
6a. **`simlock stats`** — `usageWindow` (contract) turns `--since`, `--from`, `--to` into a window; each refusal is a usage error (exit 2) worded here. Asks `usage.get` and prints `formatUsage` (`stats.ts`) or the answer as `--json`; computes no figure. `--csv` asks with `includeLedger` and writes `ledgerCsv` (`csv.ts`, RFC 4180, CRLF, a leading `'` before `=`, `+`, `-`, `@`); `--csv` with `--json` is a usage error; a `ledgerTruncated` answer adds a stderr notice.
7. **`simlock daemon logs --follow`** — `followLog` polls every 250 ms from a byte offset.
A new identity on `daemon.log.1` is a rotation: print its rest, restart at offset 0.
8. **`simlock config set`** — validates the merged file with `loadConfig`, writes
`config.json` atomically, mode `0600`.
8. **`simlock config set`** — validates the merged file with `loadConfig`, creates the
data directory `0700` when missing, writes `config.json` atomically, mode `0600`.
9. **`simlock mcp`** — imports `src/mcp/main.ts` and runs it, passing the global flag as `{ daemonFlag }`.
10. **`simlock instructions [--json]`** — prints the static rules text from `src/instructions`, or
one JSON object `{ instructions }`; never connects to a daemon.
Expand Down
3 changes: 2 additions & 1 deletion src/cli/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ export function buildCliEnvironment(
import("./passthrough.js").then(({ spawnPassthrough }) => spawnPassthrough(command))),
readStdin: ports.readStdin ?? readPipedStdin,
writeConfigFile: async (contents) => {
await filesystem.mkdirp(dataDirectory);
const { DATA_DIRECTORY_MODE } = await import("../ports/index.js");
await filesystem.mkdirp(dataDirectory, { mode: DATA_DIRECTORY_MODE });
// why: Owner-only on every write: the file can hold `gateway.token`, and the atomic rename
// replaces the old file, so a mode the user set by hand would not survive otherwise.
await filesystem.writeFileAtomic(configPath, `${JSON.stringify(contents, null, 2)}\n`, {
Expand Down
9 changes: 6 additions & 3 deletions src/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Flows: [startup](../../docs/internal/flows/startup.md), [device.exec](../../docs

## Composition (`main.ts`)

Both modes: config (`config.json`) → `Logger` → host info → `EventBus` → event history (`createEventHistory`, not yet started) → `openStateDatabase` (the role's `state.db`; a gateway's holds the `tokens` table only; closed last in `dispose`, or by `startDaemon` when the start fails after the open).
Both modes: the data directory (`prepareDataDirectory`: created `0700` when missing; an existing one keeps its mode, and one group or others can reach gets one `warn` line once the logger exists) → config (`config.json`) → `Logger` → host info → `EventBus` → event history (`createEventHistory`, not yet started) → `openStateDatabase` (the role's `state.db`; a gateway's holds the `tokens` table only; closed last in `dispose`, or by `startDaemon` when the start fails after the open).
The role (`StartDaemonOptions.role`; the entry reads it from `--gateway` through `parseDaemonArgv`) then picks one branch; a gateway builds none of the worker's parts. The data directory is `daemonDataDirectory(resolveSimlockHome(), role)`: `<root>` for a worker, `<root>/gateway` for a gateway. The daemon never reads the other role's folder.

- **Worker** — `openStateDatabase` (`state.db`, before the token checks) → `Registry` → instance id (`instance.json`) →
Expand All @@ -55,7 +55,7 @@ Its stdout lines move the state; a failed line, a non-zero exit or a failed laun

## Connection (`server.ts`)

1. **Claim** — `DaemonEndpointHost.start`: a live peer → `DaemonAlreadyRunningError`; a dead socket file is removed and claimed. Default `<data dir>/daemon.sock`.
1. **Claim** — `DaemonEndpointHost.start`: creates the socket's directory `0700` when missing (an existing one keeps its mode); a live peer → `DaemonAlreadyRunningError`; a dead socket file is removed and claimed. Default `<data dir>/daemon.sock`.
2. **Hello** — first frame must be `hello`, else `HANDSHAKE_REQUIRED` and close.
Role: the uplink connection is `admin` by acceptance; otherwise the resolver. Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life.
3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE`
Expand Down Expand Up @@ -107,7 +107,10 @@ Admin secret: fresh each start, hash kept in memory. Written to `admin.token` (0

Security model: cooperative identity, not a hostile-process boundary.

- Every socket peer is the same OS user; file modes are the real boundary.
- Every socket peer is the same OS user; file modes are the real boundary: the data
directory is created `0700` (`DATA_DIRECTORY_MODE`) by whoever makes it first (the
daemon, `NodeDaemonLauncher`, `config set`, service install), and its files are `0600`.
An existing directory is never chmod'ed; the daemon warns when group or others can reach it.
- A `hello.principal` is a claim. Ownership checks stop accidents, not attacks.
- Admin comes only from a credential at `hello`, never from the socket itself.
- A worker that joins a fleet grants its gateway `admin` over itself.
Expand Down
28 changes: 28 additions & 0 deletions src/daemon/connection-host.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

import {
Expand All @@ -7,6 +11,7 @@ import {
MemoryFilesystem,
MemoryIpcTransport,
MemoryLogSink,
NodeFilesystem,
type IpcConnection,
type IpcConnector,
type IpcListenerFactory,
Expand All @@ -30,6 +35,29 @@ describe("DaemonEndpointHost", () => {
await expect(ipc.connect(endpoint)).rejects.toMatchObject({ code: "endpoint-not-found" });
});

it("creates the directory its endpoint lives in owner-only", async () => {
// why: under the umask every real host starts from, a directory made without a mode comes
// out 0755, so only a mode the host asks for ends at 0700.
const previousUmask = process.umask(0o022);
const base = await mkdtemp(join(tmpdir(), "simlock-endpoint-"));
const ipc = new MemoryIpcTransport();
const host = new DaemonEndpointHost({
connector: ipc,
endpoint: join(base, "home", "daemon.sock"),
filesystem: new NodeFilesystem(),
listenerFactory: ipc,
});
try {
await host.start(() => undefined);

expect((await stat(join(base, "home"))).mode & 0o777).toBe(0o700);
} finally {
process.umask(previousUmask);
await host.stop();
await rm(base, { force: true, recursive: true });
}
});

it("rejects a live endpoint before binding", async () => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp("/simlock");
Expand Down
20 changes: 18 additions & 2 deletions src/daemon/connection-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
IpcListenerFactory,
Logger,
} from "../ports/index.js";
import { IpcError, NoopLogger } from "../ports/index.js";
import { DATA_DIRECTORY_MODE, IpcError, NoopLogger } from "../ports/index.js";

export interface ConnectionHost {
readonly endpoint: string;
Expand Down Expand Up @@ -40,6 +40,22 @@ export async function refuseWhenDaemonAnswers(options: {
throw new DaemonAlreadyRunningError(options.endpoint);
}

/**
* why: the daemon makes its data directory owner-only before anything writes into it (the log,
* `state.db`, the socket). One that already exists is never chmod'ed, since the user chose its
* mode: this only returns the warning the daemon logs when the group or others can reach it.
*/
export async function prepareDataDirectory(
filesystem: Filesystem,
path: string,
): Promise<string | undefined> {
await filesystem.mkdirp(path, { mode: DATA_DIRECTORY_MODE });
const mode = ((await filesystem.stat(path)).mode ?? DATA_DIRECTORY_MODE) & 0o777;
if ((mode & 0o077) === 0) return undefined;
const octal = mode.toString(8).padStart(3, "0");
return `data directory ${path} is accessible to other users (mode ${octal}); run chmod 700 ${path}`;
}

export interface DaemonEndpointHostOptions {
readonly connector: IpcConnector;
readonly endpoint: string;
Expand All @@ -65,7 +81,7 @@ export class DaemonEndpointHost implements ConnectionHost {
async start(accept: (connection: IpcConnection) => void): Promise<void> {
if (this.#listener !== undefined || this.#stopped)
throw new Error("Daemon endpoint host has already been started");
await this.options.filesystem.mkdirp(dirname(this.endpoint));
await this.options.filesystem.mkdirp(dirname(this.endpoint), { mode: DATA_DIRECTORY_MODE });
if (await this.options.filesystem.exists(this.endpoint)) {
try {
const live = await this.options.connector.connect(this.endpoint);
Expand Down
72 changes: 71 additions & 1 deletion src/daemon/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer as createHttpServer } from "node:http";
Expand Down Expand Up @@ -53,6 +53,7 @@ import {
JsonLinesLogger,
MemoryFilesystem,
MemoryLogSink,
NodeFilesystem,
NodeSqliteOpener,
NoopLogger,
ScriptedProcessRunner,
Expand Down Expand Up @@ -300,6 +301,75 @@ const hasEvent =
(events: readonly EventEnvelope[]): boolean =>
events.some((event) => event.event === name);

describe("the data directory", () => {
it("creates a missing data directory owner-only, and for one other users can reach logs one warning at start and leaves its mode", async () => {
// why: under the umask every real host starts from, a directory made without a mode comes
// out 0755, so only the daemon asking for 0700 ends there.
const previousUmask = process.umask(0o022);
try {
const base = await mkdtemp(join(tmpdir(), "simlock-dd-"));
temporaryDirectories.push(base);
const cases = [
{ name: "new", existing: undefined, mode: 0o700, warned: undefined },
{ name: "700", existing: 0o700, mode: 0o700, warned: undefined },
{ name: "750", existing: 0o750, mode: 0o750, warned: "750" },
{ name: "755", existing: 0o755, mode: 0o755, warned: "755" },
];

for (const testCase of cases) {
const dataDirectory = join(base, testCase.name);
if (testCase.existing !== undefined) {
await mkdir(dataDirectory);
await chmod(dataDirectory, testCase.existing);
}

const { sink } = await start({ dataDirectory, filesystem: new NodeFilesystem() });

expect(
{
mode: (await stat(dataDirectory)).mode & 0o777,
warnings: sink.records
.filter((record) => record.message.startsWith("data directory"))
.map((record) => [record.level, record.message]),
},
testCase.name,
).toEqual({
mode: testCase.mode,
warnings:
testCase.warned === undefined
? []
: [
[
"warn",
`data directory ${dataDirectory} is accessible to other users (mode ${testCase.warned}); run chmod 700 ${dataDirectory}`,
],
],
});
}
} finally {
process.umask(previousUmask);
}
});
});

describe("the data directory", () => {
it("makes the parent of a gateway's data directory owner-only too when it creates it", async () => {
// why: a gateway started first creates `<root>`, which is the worker's data directory later.
const previousUmask = process.umask(0o022);
try {
const root = join(await mkdtemp(join(tmpdir(), "simlock-dd-")), "home");
temporaryDirectories.push(join(root, ".."));

await start({ dataDirectory: join(root, "gateway"), filesystem: new NodeFilesystem() });

expect((await stat(root)).mode & 0o777).toBe(0o700);
expect((await stat(join(root, "gateway"))).mode & 0o777).toBe(0o700);
} finally {
process.umask(previousUmask);
}
});
});

describe("the version a daemon reports", () => {
it("is the version in package.json when the caller names none", async () => {
const directory = await mkdtemp(join(tmpdir(), "simlock-main-"));
Expand Down
3 changes: 3 additions & 0 deletions src/daemon/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import { startVersionCheck, type VersionCheck } from "./version-check.js";
import {
DaemonAlreadyRunningError,
DaemonEndpointHost,
prepareDataDirectory,
refuseWhenDaemonAnswers,
} from "./connection-host.js";
import { describeLeaseRequestFailure } from "./error-code.js";
Expand Down Expand Up @@ -279,6 +280,7 @@ async function composeDaemon(
const tcpProbe = options.tcpProbe ?? new NodeTcpProbe();
const configPath = options.configPath ?? join(dataDirectory, "config.json");
const socketPath = options.socketPath ?? resolveDaemonSocketPath(dataDirectory);
const dataDirectoryWarning = await prepareDataDirectory(filesystem, dataDirectory);
const configWarnings: string[] = [];
const moduleDefinitions = options.optionalModules ?? optionalModules;
const { config, defaulted } = await loadConfigWithDefaults({
Expand Down Expand Up @@ -306,6 +308,7 @@ async function composeDaemon(
});
// why: the logger needs the config's log level, so what the config load found to warn about is told once the logger exists.
for (const message of configWarnings) logger.warn(message);
if (dataDirectoryWarning !== undefined) logger.warn(dataDirectoryWarning);
const processRunner = processRunnerFor(config.log.level, baseProcessRunner, logger, clock);
// ADR 0008 §6: the machine is read once, here. Bounded and never throwing, so a slow or
// missing `sw_vers` costs a fallback value, not the daemon.
Expand Down
Loading
Loading