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
6 changes: 6 additions & 0 deletions .changeset/node-ssh-capability-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@internal/server": minor
"@internal/node": minor
---

Per-node SSH capability gate: a new ssh_enabled flag (migration 0047) defaults off on every machine including the control-plane host, owners flip it at PUT /api/nodes/:id/ssh-enabled (admins on the server row) audited as node.ssh_enabled.update, the node caches the value in a mirror file it reads only to refuse, and the node protocol bumps to 16: update the server before the nodes, because the link gate is exact-match and a lagging agent is held (offline for every purpose but update) until it crosses the bump on the update command. The ported foundations (OpenSSH config discovery, ssh -G snapshot resolution, the isolated sshd e2e fixture) land with it; nothing launches SSH yet.
2 changes: 1 addition & 1 deletion .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Per-app mechanics (command lists and shapes, assistant screens, the reset path,

Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row.

Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.
Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|logging.update|update|update.unknown|shares_set|local_share_changed|service` · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.

## Deliberate non-validation & accepted risks

Expand Down
290 changes: 290 additions & 0 deletions apps/node/agent/src/__tests__/ssh-enabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol";
import type { CommandContext } from "../commands/context.js";
import { dispatchCommand } from "../commands/index.js";
import { maybeReportSshEnabled, seedSshEnabledMemo } from "../commands/report.js";
import type { NodeConfig } from "../config.js";
import { readSshEnabled, reportableSshEnabled, sshAllowed, sshEnabledPath, writeSshEnabled } from "../ssh-enabled.js";
import { SubshellMetaStore } from "../subshell-meta.js";
import { captureLogs } from "./helpers/capture-logs.js";

/**
* The SSH-capability mirror (spec 2026-10-07 §4.3): persistence, the
* file-mode doctrine borrowed from `maintenance.ts`, and the FAIL-CLOSED read
* — but with the default INVERTED. Maintenance refuses when it reads `on`;
* this refuses unless it reads `on`, because the plane is the only writer and
* "never told" is not "allowed" for a widening of egress and keys.
*
* The wiring half goes through `dispatchCommand`, so the frame parser and the
* switch are exercised too (the `commands-maintenance.test.ts` pattern).
*/

const STAMP = "2026-10-07T10:00:00.000Z";
const made: string[] = [];

function freshDataDir(): string {
// realpath'd like the commands-maintenance harness: macOS hides /private
// behind the temp dir, and an assertion about the file path should test the
// path the daemon itself would write.
const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-ssh-on-")));
made.push(dir);
return dir;
}

afterEach(() => {
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true });
});

/** The mirror's own mtime, as the unreadable branch stamps it. */
function mtimeOf(dataDir: string): string {
return statSync(sshEnabledPath(dataDir)).mtime.toISOString();
}

/** A context whose tmux THROWS on anything unstubbed (the maintenance harness). */
function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext {
const config: NodeConfig = {
serverUrl: "http://localhost:1",
nodeId: "node-1",
nodeKey: "k",
controlPublicKey: "{}",
dataDir,
name: "test-node",
};
const tmux = new Proxy(
{},
{
get: () => () => {
throw new Error("tmux must not be reached");
},
},
);
return {
config,
tmux: tmux as unknown as CommandContext["tmux"],
meta: new SubshellMetaStore(dataDir),
nowMs: () => 1_700_000_000_000,
ws: { send: (ev) => events.push(ev) },
watchers: new Map(),
tails: new Map(),
uploads: new Map(),
runtime: null,
requestRestart: () => {},
};
}

describe("persistence", () => {
it("an empty dir reads `absent`: nothing has said yes, so SSH is refused", () => {
expect(readSshEnabled(freshDataDir())).toEqual({ kind: "absent" });
});

it("round-trips the exact stamp it was given", () => {
const dataDir = freshDataDir();
expect(writeSshEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP });
expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP });
});

it("stores the whole wire verbatim — `ready.sshEnabled` is buildable from what was written", () => {
const dataDir = freshDataDir();
writeSshEnabled(dataDir, { on: true, changedAt: STAMP });
expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP });
});

it("stores `on: false` as a value too, but the mirror's ANSWER for it is refusal", () => {
// The plane stamps the file when it turns SSH off, and the bytes stay on
// disk; what the reader answers is the gate's answer, and an off file and
// no file give the same one. There is no node-side writer, so the off
// stamp is not something anyone later reconciles against — the plane's
// own row is the record, and it agrees.
const dataDir = freshDataDir();
const state = { on: false, changedAt: "2026-10-07T11:00:00.000Z" };
writeSshEnabled(dataDir, state);
expect(JSON.parse(readFileSync(sshEnabledPath(dataDir), "utf8"))).toEqual(state);
expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" });
});

it("writes the file 0600, and creates a missing data dir rather than throwing", () => {
const dataDir = freshDataDir();
writeSshEnabled(dataDir, { on: true, changedAt: STAMP });
expect(statSync(sshEnabledPath(dataDir)).mode & 0o777).toBe(0o600);
const nested = join(freshDataDir(), "nested", "data");
writeSshEnabled(nested, { on: true, changedAt: STAMP });
expect(exists(sshEnabledPath(nested))).toBe(true);
});

it("leaves no temp file behind, and the file it leaves is whole", () => {
// Atomic because a half-written file parses as corrupt, and corrupt means
// REFUSED here — a torn write would cut SSH for the duration of the write.
const dataDir = freshDataDir();
writeSshEnabled(dataDir, { on: true, changedAt: STAMP });
writeSshEnabled(dataDir, { on: false, changedAt: "2026-10-07T12:00:00.000Z" });
expect(readdirSync(dataDir)).toEqual(["ssh-enabled.json"]);
});

function exists(file: string): boolean {
try {
statSync(file);
return true;
} catch {
return false;
}
}
});

describe("reading is fail-CLOSED", () => {
it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => {
const dataDir = freshDataDir();
writeFileSync(sshEnabledPath(dataDir), "{not json");
const cap = captureLogs();
try {
expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) });
} finally {
cap.restore();
}
expect(cap.lines.filter((l) => l.includes("ssh-enabled"))).toHaveLength(1);
});

it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => {
// Same classification maintenance gives garbage, and the same reason: both
// halves or nothing. The brief's unreadable fixtures are content fixtures
// (a corrupt file) rather than chmod-000 — an EACCES read fails into the
// same branch, so the three spellings of "cannot read" must not answer
// differently.
const dataDir = freshDataDir();
writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: true }));
const cap = captureLogs();
try {
expect(readSshEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) });
writeFileSync(sshEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP }));
expect(readSshEnabled(dataDir).kind).toBe("unreadable");
} finally {
cap.restore();
}
});
});

describe("the gate classifier", () => {
it("allows ONLY a parsed on:true mirror; absent and unreadable both refuse", () => {
// The whole point of the flag: a node that cannot read its own setting
// refuses — the opposite of the directory allowlist's deliberate fail-open
// (spec 2026-10-07 §4.3).
expect(sshAllowed({ kind: "on", changedAt: STAMP })).toBe(true);
expect(sshAllowed({ kind: "absent" })).toBe(false);
expect(sshAllowed({ kind: "unreadable", changedAt: STAMP })).toBe(false);
expect(sshAllowed({ kind: "unreadable" })).toBe(false);
});
});

describe("what a read is worth announcing", () => {
it("announces `on` verbatim; an unreadable mirror as the REFUSAL it produces", () => {
// Maintenance reports an unreadable mirror as `on` because that is what
// it refuses with; here the refusal is spelled `on: false`. Either way
// the plane hears the answer the machine is actually acting on, and a row
// that disagrees earns the push that rewrites the broken file.
expect(reportableSshEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP });
expect(reportableSshEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP });
});

it("sends nothing for absent, and nothing an unstamped unreadable", () => {
expect(reportableSshEnabled({ kind: "absent" })).toBeUndefined();
expect(reportableSshEnabled({ kind: "unreadable" })).toBeUndefined();
});
});

describe("set_ssh_enabled (the plane's half)", () => {
it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => {
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
const ctx = makeCtx(dataDir, events);
const cmd = { type: "set_ssh_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody;
expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true });
expect(readSshEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP });
expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP });
expect(events).toEqual([]); // the command answers with a result; it announces nothing
});

it("an off flip also lands verbatim, and the machine says nothing about it", async () => {
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
const ctx = makeCtx(dataDir, events);
await dispatchCommand(ctx, { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T13:00:00.000Z" });
maybeReportSshEnabled(ctx);
expect(events).toEqual([]);
});
});

describe("the heartbeat belt", () => {
it("reports a mirror that moved outside the plane's own push, once", () => {
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
const ctx = makeCtx(dataDir, events);
writeSshEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved

maybeReportSshEnabled(ctx);
maybeReportSshEnabled(ctx);

expect(events).toEqual([{ type: "ssh_enabled", on: true, changedAt: STAMP }]);
expect(ctx.lastReportedSshEnabled).toEqual({ on: true, changedAt: STAMP });
});

it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => {
// The repair is the plane's push (its row wins); a node overwriting the
// corrupt file would destroy the bytes an operator may want to look at,
// and inventing an `off` here would mask the disagreement the report
// exists to raise.
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
writeFileSync(sshEnabledPath(dataDir), "{not json");
const stamp = mtimeOf(dataDir);
const ctx = makeCtx(dataDir, events);
const cap = captureLogs();
try {
maybeReportSshEnabled(ctx);
maybeReportSshEnabled(ctx);
} finally {
cap.restore();
}
expect(events).toEqual([{ type: "ssh_enabled", on: false, changedAt: stamp }]);
expect(readSshEnabled(dataDir).kind).toBe("unreadable");
});

it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => {
// Maintenance re-writes a vanished mirror from its memo because a CLI at
// the keyboard can delete it legitimately. There is no such writer here:
// hand-deleting this file is not an interface, and the next `ready` makes
// the plane push the row value back.
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
const ctx = makeCtx(dataDir, events);
writeSshEnabled(dataDir, { on: true, changedAt: STAMP });
seedSshEnabledMemo(ctx);
unlinkSync(sshEnabledPath(dataDir));

maybeReportSshEnabled(ctx);

expect(events).toEqual([]);
expect(readSshEnabled(dataDir)).toEqual({ kind: "absent" });
});

it("seed returns the value ready carries and silences the first heartbeat for it", () => {
const dataDir = freshDataDir();
const events: NodeEvent[] = [];
const ctx = makeCtx(dataDir, events);
writeSshEnabled(dataDir, { on: true, changedAt: STAMP });
expect(seedSshEnabledMemo(ctx)).toEqual({ on: true, changedAt: STAMP });
maybeReportSshEnabled(ctx);
expect(events).toEqual([]);
expect(seedSshEnabledMemo(makeCtx(freshDataDir()))).toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions apps/node/agent/src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
NodeEvent,
NodeMaintenanceWire,
NodeRuntimeReport,
NodeSshEnabledWire,
} from "@internal/subshell-protocol";
import type { NodeConfig } from "../config.js";
import type { ServiceDeps } from "../service.js";
Expand Down Expand Up @@ -155,6 +156,21 @@ export interface CommandContext {
* from echoing the plane's own write back at it one tick later.
*/
lastReportedMaintenance?: NodeMaintenanceWire;
/**
* The `{ on, changedAt }` this connection last told the plane about its SSH
* capability, or undefined when it has said nothing (spec 2026-10-07 §4.3).
*
* The same echo-suppression the maintenance memo exists for — but with a
* simpler job here, because the plane is this flag's ONLY writer: the memo
* exists so the heartbeat belt reports a mirror that moved by accident
* (corruption, mostly) and never the plane's own push come back one tick
* later. Seeded at every connect from the same read that built `ready`.
*
* There is deliberately no `mirrorRestoreLogged` partner: SSH has no
* restore-from-memo path (no node-side writer whose deletion deserves
* repair), so there is no failing restore to say anything about.
*/
lastReportedSshEnabled?: NodeSshEnabledWire;
/**
* Whether this connection has already reported a mirror it could not
* rewrite (see `restoreMirrorFromMemo`).
Expand Down
7 changes: 6 additions & 1 deletion apps/node/agent/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { execService } from "./service.js";
import { execSetLogLevel } from "./set-log-level.js";
import { execSetMaintenance } from "./set-maintenance.js";
import { execSetServerUrl } from "./set-server-url.js";
import { execSetSshEnabled } from "./set-ssh-enabled.js";
import { execLogRead, execTailStart, execTailStop } from "./tail.js";
import { execTransferWrite } from "./transfer-write.js";
import { execTreeManifest } from "./tree-manifest.js";
Expand All @@ -47,7 +48,9 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con
* the one command whose wire shape is frozen, because the plane sends it to
* agents whose protocol it does not share), and the five archive-transfer
* commands `archive_create`, `file_read`, `transfer_write`, `archive_extract`
* and `tree_manifest` (spec 2026-10-01 §4, protocol 15). Any
* and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and
* `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane-
* to-machine write). Any
* unknown type still answers `unsupported` — the integration
* contract that lets the backend and agent tracks move independently.
*
Expand Down Expand Up @@ -122,6 +125,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody)
return execSetLogLevel(ctx, cmd);
case "set_maintenance":
return execSetMaintenance(ctx, cmd);
case "set_ssh_enabled":
return execSetSshEnabled(ctx, cmd);
case "set_server_url":
return await execSetServerUrl(ctx, cmd);
case "update":
Expand Down
Loading
Loading