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
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ rootcell is early and intentionally narrow. Today it targets:
- **Coding harness:** [Pi](https://pi.dev) inside the agent VM.

The agent and firewall environments are NixOS VMs, but the host-side lifecycle,
networking, Keychain integration, and VM lifecycle currently assume macOS.
networking, default Keychain-backed secrets provider, and VM lifecycle currently
assume macOS.

## Why This Exists

Expand All @@ -35,8 +36,8 @@ VM without receiving broad access to your Mac:
- A separate firewall VM with the only public internet route.
- DNS, HTTPS, and SSH allowlists you can review and hot-reload.
- A per-VM SSH key for Git pushes.
- Provider secrets read from macOS Keychain at runtime, not stored in the VM or
the Nix store.
- Provider secrets read from host-side secret providers at runtime, not stored
in the VM or the Nix store.

Use it when you want the agent to go wild inside the VM, while keeping
an explicit network boundary around the work.
Expand Down Expand Up @@ -73,7 +74,7 @@ The two VMs have different jobs:
Rootcell supports named instances. Plain `./rootcell` uses the `default`
instance and creates VMs named `agent` and `firewall`. `./rootcell --instance
dev` creates `agent-dev` and `firewall-dev`, with separate CA material,
allowlists, Keychain mappings, and a separate private VM link.
allowlists, secret mappings, and a separate private VM link.

HTTPS egress is transparent from inside the agent VM. A normal command like
`curl https://github.com` either works because the host is allowlisted, or fails
Expand Down Expand Up @@ -331,7 +332,7 @@ firewall-vm.nix firewall VM services and nftables rules
home.nix pi, Git, SSH, and developer tools for the agent VM
network.nix default inter-VM network settings
.env.defaults seed values for per-instance `.env`
secrets.env.defaults seed Keychain secret mappings for per-instance `secrets.env`
secrets.env.defaults seed provider-qualified secret mappings for per-instance `secrets.env`
instances/
per-instance state, allowlists, CA, SSH keys, and generated files
proxy/ allowlists and mitmproxy/dnsmasq firewall code
Expand All @@ -356,7 +357,7 @@ the private user-v2 address.
Use `./rootcell list` to show known VMs and their state. `./rootcell stop`
stops the selected instance's VMs, and `./rootcell remove` stops the selected
instance and deletes its Lima VM state. Instance-local configuration such as
allowlists, Keychain mappings, CA files, and subnet allocation remains in
allowlists, secret mappings, CA files, and subnet allocation remains in
the instance state directory so the next start keeps the same instance
settings.

Expand Down Expand Up @@ -390,18 +391,19 @@ NETWORK_PREFIX=24

`./rootcell` also seeds `<instance-dir>/secrets.env` from
`secrets.env.defaults` on first run. This file maps agent VM environment
variable names to macOS Keychain service names; it does not contain the secret
values themselves:
variable names to provider-qualified secret references; it does not contain the
secret values themselves. The provider id is required on each line, so different
secrets may come from different providers:

```sh
AWS_BEARER_TOKEN_BEDROCK=aws-bedrock-api-key
AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key
```

For example, to inject an additional `ANTHROPIC_API_KEY`:

```sh
security add-generic-password -a "$USER" -s anthropic-api-key -w "<your-key>"
echo 'ANTHROPIC_API_KEY=anthropic-api-key' >> "$INSTANCE_DIR/secrets.env"
echo 'ANTHROPIC_API_KEY=macos-keychain:anthropic-api-key' >> "$INSTANCE_DIR/secrets.env"
```

If you want to use Anthropic or OpenAI subscriptions, you can log in from
Expand Down Expand Up @@ -448,7 +450,7 @@ Named instances are isolated from each other:
./rootcell --instance review
```

Each instance gets its own VMs, state directory, CA, allowlists, Keychain mapping
Each instance gets its own VMs, state directory, CA, allowlists, secret mapping
file, control SSH key, private-link state, and `/24`.

The `default` instance still seeds from legacy repo-local `.env`, `secrets.env`,
Expand Down
4 changes: 2 additions & 2 deletions home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
#
# Pi reads provider keys from the env. DON'T put them in this file — the
# Nix store is world-readable. Configure secret entries in secrets.env; `rootcell`
# reads those macOS Keychain secrets on the host and exports them on
# guest sessions.
# reads those provider-backed secrets on the host and exports them on guest
# sessions.

let
net = import ./network.nix;
Expand Down
6 changes: 3 additions & 3 deletions secrets.env.defaults
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Defaults for Keychain secrets to inject into the agent VM. Copied to
# Defaults for provider-backed secrets to inject into the agent VM. Copied to
# secrets.env on first run if secrets.env is missing.
# Format: <ENV_VAR_NAME>=<macOS Keychain service name>
# Format: <ENV_VAR_NAME>=<provider-id>:<provider-specific reference>

AWS_BEARER_TOKEN_BEDROCK=aws-bedrock-api-key
AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key
32 changes: 27 additions & 5 deletions src/rootcell/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import { EnvironmentVariableNameSchema } from "./schema.ts";
import { SecretMappingSchema, type SecretMapping } from "./types.ts";
import {
SecretEnvMappingSchema,
SecretProviderIdSchema,
type SecretEnvMapping,
} from "./secrets/types.ts";

export function loadDotEnv(path: string, env: NodeJS.ProcessEnv): void {
if (!existsSync(path)) {
Expand All @@ -23,8 +27,8 @@ export function loadDotEnv(path: string, env: NodeJS.ProcessEnv): void {
}
}

export function parseSecretMappings(text: string): SecretMapping[] {
const mappings: SecretMapping[] = [];
export function parseSecretMappings(text: string): SecretEnvMapping[] {
const mappings: SecretEnvMapping[] = [];
for (const line of text.split(/\r?\n/)) {
if (line.length === 0 || line.startsWith("#")) {
continue;
Expand All @@ -39,9 +43,27 @@ export function parseSecretMappings(text: string): SecretMapping[] {
throw new Error(`invalid secret environment variable name in secrets.env: ${envName}`);
}
if (service.length === 0) {
throw new Error(`empty Keychain service name for ${envName}`);
throw new Error(`empty secret reference for ${envName}`);
}
mappings.push(SecretMappingSchema.parse({ envName, service }));
const separatorAt = service.indexOf(":");
if (separatorAt === -1) {
throw new Error(`secret reference for ${envName} must include a provider id, for example macos-keychain:${service}`);
}
const providerId = service.slice(0, separatorAt);
const reference = service.slice(separatorAt + 1);
if (providerId.length === 0) {
throw new Error(`empty secret provider id for ${envName}`);
}
if (!SecretProviderIdSchema.safeParse(providerId).success) {
throw new Error(`invalid secret provider id in secrets.env for ${envName}: ${providerId}`);
}
if (reference.length === 0) {
throw new Error(`empty secret reference for ${envName}`);
}
mappings.push(SecretEnvMappingSchema.parse({
envName,
secret: { providerId, reference },
}));
}
return mappings;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
} from "../../../providers/macos-lima-user-v2-network.ts";
import type { ProviderBundle } from "../../../providers/types.ts";
import { preflightMacOsLimaUserV2Integration } from "./preflight.ts";
import { MacOsKeychainSecretProvider } from "../../../secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "../../../secrets/registry.ts";

const JsonObjectSchema = z.record(z.string(), z.unknown());

Expand All @@ -39,6 +41,9 @@ export function createBundle(
return {
network: new MacOsLimaUserV2NetworkProvider(config, log),
vm: new LimaVmProvider(config, log),
secrets: new StaticSecretProviderRegistry([
new MacOsKeychainSecretProvider(),
]),
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/rootcell/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { RootcellConfig } from "../types.ts";
import type { ProviderBundle } from "./types.ts";
import { LimaVmProvider } from "./lima.ts";
import { MacOsLimaUserV2NetworkProvider, type LimaUserV2NetworkAttachment } from "./macos-lima-user-v2-network.ts";
import { MacOsKeychainSecretProvider } from "../secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "../secrets/registry.ts";

export function createProviderBundle(
config: RootcellConfig,
Expand All @@ -10,5 +12,8 @@ export function createProviderBundle(
return {
network: new MacOsLimaUserV2NetworkProvider(config, log),
vm: new LimaVmProvider(config, log),
secrets: new StaticSecretProviderRegistry([
new MacOsKeychainSecretProvider(),
]),
};
}
2 changes: 2 additions & 0 deletions src/rootcell/providers/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CommandResult, InheritedCommandResult } from "../types.ts";
import type { SecretProviderRegistry } from "../secrets/types.ts";

export type VmRole = "agent" | "firewall";

Expand Down Expand Up @@ -77,4 +78,5 @@ export interface VmProvider<TAttachment extends VmNetworkAttachment = VmNetworkA
export interface ProviderBundle<TAttachment extends VmNetworkAttachment = VmNetworkAttachment> {
readonly network: NetworkProvider<TAttachment>;
readonly vm: VmProvider<TAttachment>;
readonly secrets: SecretProviderRegistry;
}
124 changes: 117 additions & 7 deletions src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import {
ParsedRootcellRunArgsSchema,
RootcellConfigSchema,
RootcellInstanceSchema,
SecretMappingSchema,
type ParsedRootcellRunArgs,
type RootcellInstance,
} from "./types.ts";
import { MacOsKeychainSecretProvider } from "./secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "./secrets/registry.ts";
import { SecretEnvMappingSchema } from "./secrets/types.ts";

const EmptyStringArraySchema = z.array(z.string()).length(0);
const DefaultSpyOptionsSchema = z.object({
Expand Down Expand Up @@ -207,14 +209,37 @@ describe("environment parsing", () => {
});

test("validates secret mappings", () => {
const mappings = parseSecretMappings("AWS_BEARER_TOKEN_BEDROCK=aws-bedrock-api-key\n");
expect(mappings).toEqual(expect.schemaMatching(z.array(SecretMappingSchema)));
const mappings = parseSecretMappings([
"AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key",
"AWS_SECRET_ACCESS_KEY=aws-prod:arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key",
"ONEPASSWORD_TOKEN=1password:op://Private/token/password",
"",
].join("\n"));
expect(mappings).toEqual(expect.schemaMatching(z.array(SecretEnvMappingSchema)));
expect(mappings).toEqual([
{ envName: "AWS_BEARER_TOKEN_BEDROCK", service: "aws-bedrock-api-key" },
{
envName: "AWS_BEARER_TOKEN_BEDROCK",
secret: { providerId: "macos-keychain", reference: "aws-bedrock-api-key" },
},
{
envName: "AWS_SECRET_ACCESS_KEY",
secret: {
providerId: "aws-prod",
reference: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key",
},
},
{
envName: "ONEPASSWORD_TOKEN",
secret: { providerId: "1password", reference: "op://Private/token/password" },
},
]);
expect(() => parseSecretMappings("1BAD=service\n")).toThrow("invalid secret environment variable name");
expect(() => parseSecretMappings("1BAD=macos-keychain:service\n")).toThrow("invalid secret environment variable name");
expect(() => parseSecretMappings("BAD\n")).toThrow("invalid secret entry");
expect(() => parseSecretMappings("BAD=\n")).toThrow("empty Keychain service name");
expect(() => parseSecretMappings("BAD=\n")).toThrow("empty secret reference");
expect(() => parseSecretMappings("BAD=service\n")).toThrow("must include a provider id");
expect(() => parseSecretMappings("BAD=:service\n")).toThrow("empty secret provider id");
expect(() => parseSecretMappings("BAD=bad/id:service\n")).toThrow("invalid secret provider id");
expect(() => parseSecretMappings("BAD=macos-keychain:\n")).toThrow("empty secret reference");
});

test("builds config from instance state", () => {
Expand Down Expand Up @@ -277,11 +302,96 @@ describe("host tool resolution", () => {
});
});

describe("secret providers", () => {
test("registry routes provider-qualified references", async () => {
const calls: string[] = [];
const registry = new StaticSecretProviderRegistry([
{
id: "macos-keychain",
read: (reference) => {
calls.push(`macos-keychain:${reference}`);
return Promise.resolve(`mac:${reference}`);
},
},
{
id: "aws-prod",
read: (reference) => {
calls.push(`aws-prod:${reference}`);
return Promise.resolve(`prod:${reference}`);
},
},
{
id: "aws-dev",
read: (reference) => {
calls.push(`aws-dev:${reference}`);
return Promise.resolve(`dev:${reference}`);
},
},
]);

await expect(registry.read({ providerId: "macos-keychain", reference: "service" })).resolves.toBe("mac:service");
await expect(registry.read({ providerId: "aws-prod", reference: "secret/name" })).resolves.toBe("prod:secret/name");
await expect(registry.read({ providerId: "aws-dev", reference: "secret/name" })).resolves.toBe("dev:secret/name");
expect(calls).toEqual([
"macos-keychain:service",
"aws-prod:secret/name",
"aws-dev:secret/name",
]);
});

test("registry rejects unknown or duplicate secret providers", async () => {
const registry = new StaticSecretProviderRegistry([
{ id: "macos-keychain", read: () => Promise.resolve("secret") },
]);

await expect(registry.read({ providerId: "missing", reference: "do-not-print" })).rejects.toThrow("unknown secret provider 'missing'");
try {
await registry.read({ providerId: "missing", reference: "do-not-print" });
throw new Error("expected secret lookup to fail");
} catch (error) {
expect(error instanceof Error ? error.message : String(error)).not.toContain("do-not-print");
}
expect(() => new StaticSecretProviderRegistry([
{ id: "aws-prod", read: () => Promise.resolve("one") },
{ id: "aws-prod", read: () => Promise.resolve("two") },
])).toThrow("duplicate secret provider id");
});

test("macOS Keychain provider reads generic passwords", async () => {
const calls: { command: string; args: readonly string[]; allowFailure: boolean | undefined }[] = [];
const provider = new MacOsKeychainSecretProvider("macos-keychain", (command, args, options) => {
calls.push({ command, args, allowFailure: options?.allowFailure });
return { status: 0, stdout: "secret-value\n", stderr: "" };
});

await expect(provider.read("aws-bedrock-api-key")).resolves.toBe("secret-value");
expect(calls).toEqual([
{
command: "security",
args: ["find-generic-password", "-s", "aws-bedrock-api-key", "-w"],
allowFailure: true,
},
]);
});

test("macOS Keychain provider reports missing secrets with add guidance", async () => {
const provider = new MacOsKeychainSecretProvider("macos-keychain", () => ({
status: 44,
stdout: "",
stderr: "not found",
}));

await expect(provider.read("anthropic-api-key")).rejects.toThrow("macOS Keychain secret not found");
await expect(provider.read("anthropic-api-key")).rejects.toThrow("security add-generic-password");
});
});

describe("VM and network providers", () => {
test("factory defaults to Lima providers", () => {
const providers = createProviderBundle(buildConfig("/repo", {}, fakeInstance("dev")), ignoreLog);
expect(providers.network.id).toBe("macos-lima-user-v2");
expect(providers.vm.id).toBe("lima");
expect(providers.secrets.ids).toEqual(["macos-keychain"]);
});

test("macOS Lima user-v2 provider exposes egress firewall and private-only agent attachments", () => {
Expand Down Expand Up @@ -864,7 +974,7 @@ function makeInstanceRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "rootcell-instance-test-"));
mkdirSync(join(repo, "proxy"), { recursive: true });
writeFileSync(join(repo, ".env.defaults"), "AWS_REGION=us-east-1\n", "utf8");
writeFileSync(join(repo, "secrets.env.defaults"), "AWS_BEARER_TOKEN_BEDROCK=aws-bedrock-api-key\n", "utf8");
writeFileSync(join(repo, "secrets.env.defaults"), "AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key\n", "utf8");
for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) {
writeFileSync(join(repo, "proxy", `${file}.defaults`), "\n", "utf8");
}
Expand Down
Loading