From cd3c65663438df2f1c1ac7d5ec3042a882d10c56 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 08:41:01 +0200 Subject: [PATCH 1/7] harden identity authority --- docs/architecture/security-model.md | 11 ++-- gateway/src/auth/username.ts | 11 ++++ gateway/src/kernel/accounts.test.ts | 23 ++++++++ gateway/src/kernel/accounts.ts | 14 +++-- gateway/src/kernel/agents.ts | 2 +- gateway/src/kernel/auth-store.test.ts | 46 ++++++++++++++++ gateway/src/kernel/auth-store.ts | 54 +++++++++++++------ gateway/src/kernel/capabilities.test.ts | 11 +++- gateway/src/kernel/capabilities.ts | 4 +- gateway/src/kernel/connect.test.ts | 39 ++++++++++++-- gateway/src/kernel/connect.ts | 2 +- gateway/src/kernel/do.test.ts | 49 +++++++++++++++++ gateway/src/kernel/do.ts | 24 +++++++-- gateway/src/kernel/pkg.test.ts | 39 ++++++++++++++ gateway/src/kernel/pkg.ts | 5 +- gateway/src/kernel/repo.test.ts | 10 ++++ gateway/src/kernel/repo.ts | 2 +- gateway/src/kernel/schema/migrations.test.ts | 26 ++++++++- gateway/src/kernel/schema/migrations.ts | 4 ++ .../schema/v015_harden_identity_authority.ts | 27 ++++++++++ gateway/src/kernel/sys/bootstrap.test.ts | 17 ++++++ gateway/src/kernel/sys/bootstrap.ts | 6 +-- gateway/src/kernel/sys/setup.test.ts | 48 +++++++++++++++-- gateway/src/kernel/sys/setup.ts | 37 +++++++------ 24 files changed, 446 insertions(+), 65 deletions(-) create mode 100644 gateway/src/auth/username.ts create mode 100644 gateway/src/kernel/auth-store.test.ts create mode 100644 gateway/src/kernel/schema/v015_harden_identity_authority.ts diff --git a/docs/architecture/security-model.md b/docs/architecture/security-model.md index 347a1c2d5..994736c45 100644 --- a/docs/architecture/security-model.md +++ b/docs/architecture/security-model.md @@ -72,6 +72,10 @@ Capabilities are group based. The Kernel stores grants such as `fs.*`, `shell.*`, `proc.*`, `sys.config.get`, or `*` in `group_capabilities`. Every normal syscall is rejected unless the caller's resolved capabilities match the exact syscall, the syscall domain wildcard, or `*`. +The unrestricted `*` grant is reserved for gid 0; object-level root authority +is determined by uid 0, not by capability text. +Account usernames are canonical lower-case ASCII; ASCII capitals are folded +only after the raw input passes bounded ASCII validation. Default groups are intentionally OS-like: @@ -96,6 +100,8 @@ Native GSV file access uses a virtual filesystem. `/sys`, `/proc`, `/dev`, and ordinary paths are stored in R2 with Unix-like uid/gid/mode metadata. Root can read/write broadly. Non-root reads and writes are checked against owner, group, and other mode bits where the backend supports them. +Automatically allocated UID and GID values share a monotonic allocator and are +never reused, because R2 ownership metadata can outlive an account or group row. Device file tools and shell tools are not a sandbox. Relative paths resolve against the device workspace, but absolute paths are used as-is on the device. @@ -147,13 +153,12 @@ authenticated user and still applies normal syscall/device/resource checks. Non-builtin packages require review before they can be enabled. Package metadata records requested bindings and egress grants; default egress is `none`. -Mutating package operations require root, wildcard capability, or ownership of -the user package scope. +Mutating package operations require root or ownership of the user package scope. Git HTTP uses Basic auth with either password or user token credentials. Public repository reads are allowed only for repos explicitly marked public. Package source repositories are readable only when their package is visible to the -caller. Pushes require the repo owner, root, or wildcard capability. +caller. Pushes require the repo owner or root. ## What GSV Does Not Protect Against diff --git a/gateway/src/auth/username.ts b/gateway/src/auth/username.ts new file mode 100644 index 000000000..5f961131b --- /dev/null +++ b/gateway/src/auth/username.ts @@ -0,0 +1,11 @@ +export const ACCOUNT_USERNAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/; +const ACCOUNT_USERNAME_INPUT_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,31}$/; +const ACCOUNT_USERNAME_INPUT_MAX_LENGTH = 64; + +/** Normalize a public username only after proving the input is bounded ASCII. */ +export function normalizeAccountUsername(value: unknown): string | null { + if (typeof value !== "string" || value.length > ACCOUNT_USERNAME_INPUT_MAX_LENGTH) return null; + const input = value.trim(); + if (!ACCOUNT_USERNAME_INPUT_RE.test(input)) return null; + return input.toLowerCase(); +} diff --git a/gateway/src/kernel/accounts.test.ts b/gateway/src/kernel/accounts.test.ts index 7661f576e..c8ab4e0fa 100644 --- a/gateway/src/kernel/accounts.test.ts +++ b/gateway/src/kernel/accounts.test.ts @@ -275,6 +275,20 @@ describe("handleAccountCreate", () => { await expect(handleAccountCreate({ kind: "agent", username: "Bad Name" }, ctx)).rejects.toThrow( /unavailable|invalid/i, ); + await expect(handleAccountCreate({ kind: "agent", username: "\u212Aite" }, ctx)).rejects.toThrow( + /unavailable|invalid/i, + ); + await expect(handleAccountCreate({ kind: "agent", username: `alice${" ".repeat(60)}` }, ctx)) + .rejects.toThrow(/unavailable|invalid/i); + }); + + it("normalizes ordinary ASCII uppercase usernames", async () => { + const { ctxFor } = createCtx(); + const ctx = ctxFor(userIdentity(1000, "alice", ["account.create"])); + + const result = await handleAccountCreate({ kind: "agent", username: "Scout" }, ctx); + + expect(result.account.username).toBe("scout"); }); it("requires root to create a human account", async () => { @@ -286,6 +300,15 @@ describe("handleAccountCreate", () => { ).rejects.toThrow(/root/i); }); + it("does not treat a non-root wildcard capability as root authority", async () => { + const { ctxFor } = createCtx(); + const ctx = ctxFor(userIdentity(1000, "alice", ["*"])); + + await expect( + handleAccountCreate({ kind: "human", username: "bob", password: "password-123" }, ctx), + ).rejects.toThrow(/root/i); + }); + it("rejects a weak human password without mutating auth state", async () => { const { ctxFor, auth, passwd, shadow } = createCtx(); const ctx = ctxFor(userIdentity(0, "root", ["*"])); diff --git a/gateway/src/kernel/accounts.ts b/gateway/src/kernel/accounts.ts index 91743c497..bf0fdb8ad 100644 --- a/gateway/src/kernel/accounts.ts +++ b/gateway/src/kernel/accounts.ts @@ -20,10 +20,12 @@ import { accountHomeRepoRef } from "../fs/ripgit/repos"; import type { KernelContext } from "./context"; import type { AuthStore } from "./auth-store"; import type { PasswdEntry } from "../auth/passwd"; +import { ACCOUNT_USERNAME_RE, normalizeAccountUsername } from "../auth/username"; + +export { ACCOUNT_USERNAME_RE }; const TEXT_ENCODER = new TextEncoder(); -export const ACCOUNT_USERNAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/; export const MIN_PASSWORD_LENGTH = 8; /** A username is available when it collides with no existing user or group. */ @@ -31,14 +33,10 @@ export function isUsernameAvailable(auth: AuthStore, name: string): boolean { return !auth.getPasswdByUsername(name) && !auth.getGroupByName(name); } -/** - * Validate and normalize a candidate username. Returns null when the name is - * malformed or already taken. - */ +/** A valid candidate username that does not collide with a user or group. */ export function normalizeAccountName(auth: AuthStore, value: unknown): string | null { - if (typeof value !== "string") return null; - const name = value.trim().toLowerCase(); - if (!ACCOUNT_USERNAME_RE.test(name)) return null; + const name = normalizeAccountUsername(value); + if (!name) return null; if (!isUsernameAvailable(auth, name)) return null; return name; } diff --git a/gateway/src/kernel/agents.ts b/gateway/src/kernel/agents.ts index e371bc008..035682ce0 100644 --- a/gateway/src/kernel/agents.ts +++ b/gateway/src/kernel/agents.ts @@ -229,7 +229,7 @@ export async function handleAccountCreate( if (kind === "human") { // Creating human accounts is an administrative action. - if (!caller.capabilities.includes("*")) { + if (caller.process.uid !== 0) { throw new Error("Creating human accounts requires root"); } const { identity } = await createAccount(ctx, { diff --git a/gateway/src/kernel/auth-store.test.ts b/gateway/src/kernel/auth-store.test.ts new file mode 100644 index 000000000..eae23e85a --- /dev/null +++ b/gateway/src/kernel/auth-store.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { mockSqlRows, type MockSqlRow } from "../test-support/mock-sql"; +import { AuthStore } from "./auth-store"; + +function createSql(nextId = 1000): SqlStorage & { nextId: () => number } { + let next = nextId; + const exec = (query: string, ...bindings: unknown[]) => { + const sql = query.trim().replace(/\s+/g, " "); + if (sql.startsWith("UPDATE identity_id_allocator SET next_id = next_id + 1")) { + return mockSqlRows([{ id: next++ }] as T[]); + } + if (sql.startsWith("UPDATE identity_id_allocator SET next_id = MAX")) { + next = Math.max(next, (bindings[0] as number) + 1); + } + return mockSqlRows(); + }; + return { exec, nextId: () => next } as SqlStorage & { nextId: () => number }; +} + +describe("AuthStore identity id allocation", () => { + it("shares one monotonic allocator across uids and gids and burns unused ids", () => { + const sql = createSql(); + const auth = new AuthStore(sql); + + expect(auth.nextUid()).toBe(1000); + expect(auth.nextGid()).toBe(1001); + expect(auth.nextUid()).toBe(1002); + expect(sql.nextId()).toBe(1003); + }); + + it("advances past explicitly authored passwd and group ids", () => { + const auth = new AuthStore(createSql()); + auth.addUser({ + username: "alice", + uid: 1200, + gid: 1300, + gecos: "Alice", + home: "/home/alice", + shell: "/bin/init", + }); + expect(auth.nextUid()).toBe(1301); + + auth.addGroup({ name: "external", gid: 1400, members: [] }); + expect(auth.nextGid()).toBe(1401); + }); +}); diff --git a/gateway/src/kernel/auth-store.ts b/gateway/src/kernel/auth-store.ts index 3b209b481..dd4de7cfd 100644 --- a/gateway/src/kernel/auth-store.ts +++ b/gateway/src/kernel/auth-store.ts @@ -18,6 +18,7 @@ import type { ShadowEntry } from "../auth/shadow"; import { parseShadow, serializeShadow, isLocked, makeShadowEntry, hashToken, verify } from "../auth/shadow"; import type { GroupEntry } from "../auth/group"; import { parseGroup, serializeGroup, resolveGids } from "../auth/group"; +import { normalizeAccountUsername } from "../auth/username"; export type AuthIdentity = { uid: number; @@ -159,6 +160,7 @@ export class AuthStore { } addUser(entry: PasswdEntry): void { + this.observeIdentityIds(entry.uid, entry.gid); this.sql.exec( "INSERT INTO passwd (username, uid, gid, gecos, home, shell) VALUES (?, ?, ?, ?, ?, ?)", entry.username, entry.uid, entry.gid, entry.gecos, entry.home, entry.shell, @@ -169,6 +171,7 @@ export class AuthStore { const existing = this.getPasswdByUsername(username); if (!existing) return false; + this.observeIdentityIds(fields.uid, fields.gid); this.sql.exec( "UPDATE passwd SET uid = ?, gid = ?, gecos = ?, home = ?, shell = ? WHERE username = ?", fields.uid ?? existing.uid, @@ -190,11 +193,7 @@ export class AuthStore { } nextUid(): number { - // Allocate above both uids and group gids: User Private Groups use gid = uid, - // and standalone groups (e.g. package-agent access groups) take ids from the - // same space, so a fresh id must clear both tables to avoid later reuse. - const max = this.maxAllocatedId(); - return max < 1000 ? 1000 : max + 1; + return this.allocateIdentityId(); } // --------------------------------------------------------------------------- @@ -283,6 +282,7 @@ export class AuthStore { } addGroup(entry: GroupEntry): void { + this.observeIdentityIds(entry.gid); this.sql.exec( "INSERT INTO groups (name, gid, members) VALUES (?, ?, ?)", entry.name, entry.gid, entry.members.join(","), @@ -307,16 +307,32 @@ export class AuthStore { } nextGid(): number { - const max = this.maxAllocatedId(); - return max < 100 ? 100 : max + 1; + return this.allocateIdentityId(); } - /** Highest id in use across passwd uids and group gids. */ - private maxAllocatedId(): number { - const rows = this.sql.exec<{ m: number | null }>( - "SELECT MAX(m) as m FROM (SELECT MAX(uid) as m FROM passwd UNION ALL SELECT MAX(gid) as m FROM groups)", + private allocateIdentityId(): number { + const rows = this.sql.exec<{ id: number }>( + `UPDATE identity_id_allocator + SET next_id = next_id + 1 + WHERE singleton = 1 + RETURNING next_id - 1 AS id`, ).toArray(); - return rows[0]?.m ?? 0; + const id = rows[0]?.id; + if (!Number.isSafeInteger(id)) { + throw new Error("Identity id allocator is unavailable"); + } + return id; + } + + private observeIdentityIds(...ids: Array): void { + const observed = ids.filter((id): id is number => Number.isSafeInteger(id)); + if (observed.length === 0) return; + this.sql.exec( + `UPDATE identity_id_allocator + SET next_id = MAX(next_id, ? + 1) + WHERE singleton = 1`, + Math.max(...observed), + ); } // --------------------------------------------------------------------------- @@ -333,16 +349,18 @@ export class AuthStore { // --------------------------------------------------------------------------- async authenticate(username: string, credential: string): Promise { - const user = this.getPasswdByUsername(username); + const normalizedUsername = normalizeAccountUsername(username); + if (!normalizedUsername) return { ok: false, error: "Unknown user" }; + const user = this.getPasswdByUsername(normalizedUsername); if (!user) return { ok: false, error: "Unknown user" }; - const shadow = this.getShadowByUsername(username); + const shadow = this.getShadowByUsername(normalizedUsername); if (!shadow) return { ok: false, error: "No credentials found" }; const valid = await verify(credential, shadow.hash); if (!valid) return { ok: false, error: "Authentication failed" }; - const gids = this.resolveGids(username, user.gid); + const gids = this.resolveGids(user.username, user.gid); return { ok: true, @@ -362,7 +380,9 @@ export class AuthStore { token: string, options: TokenAuthOptions = {}, ): Promise { - const user = this.getPasswdByUsername(username); + const normalizedUsername = normalizeAccountUsername(username); + if (!normalizedUsername) return { ok: false, error: "Unknown user" }; + const user = this.getPasswdByUsername(normalizedUsername); if (!user) return { ok: false, error: "Unknown user" }; const tokenHash = await hashToken(token); @@ -417,7 +437,7 @@ export class AuthStore { tokenRow.token_id, ); - const gids = this.resolveGids(username, user.gid); + const gids = this.resolveGids(user.username, user.gid); return { ok: true, identity: { diff --git a/gateway/src/kernel/capabilities.test.ts b/gateway/src/kernel/capabilities.test.ts index 1eb73011a..2948fef04 100644 --- a/gateway/src/kernel/capabilities.test.ts +++ b/gateway/src/kernel/capabilities.test.ts @@ -221,7 +221,6 @@ describe("CapabilityStore", () => { "sched.*", "shell.*", "signal.*", - "sys.bootstrap", "sys.config.get", "sys.config.set", "sys.device.delete", @@ -300,6 +299,16 @@ describe("CapabilityStore", () => { if (!result.ok) expect(result.error).toContain("Invalid capability format"); }); + it("reserves the unrestricted capability for root gid 0", () => { + expect(store.grant(100, "*")).toEqual({ + ok: false, + error: "The unrestricted capability is reserved for root", + }); + expect(store.list(100)).toEqual([]); + expect(store.grant(0, "*")).toEqual({ ok: true }); + expect(store.list(0)).toEqual([{ gid: 0, capability: "*" }]); + }); + it("revoke removes a capability", () => { store.seed(); store.revoke(100, "fs.*"); diff --git a/gateway/src/kernel/capabilities.ts b/gateway/src/kernel/capabilities.ts index b3b8a505b..a815a4ef2 100644 --- a/gateway/src/kernel/capabilities.ts +++ b/gateway/src/kernel/capabilities.ts @@ -61,7 +61,6 @@ const DEFAULT_CAPABILITIES: [number, string[]][] = [ "adapter.status", "sys.config.get", "sys.config.set", - "sys.bootstrap", "sys.device.get", "sys.device.list", "sys.device.update", @@ -125,6 +124,9 @@ export class CapabilityStore { if (!isValidCapability(capability)) { return { ok: false, error: `Invalid capability format: ${capability}` }; } + if (capability === "*" && gid !== 0) { + return { ok: false, error: "The unrestricted capability is reserved for root" }; + } this.sql.exec( `INSERT OR IGNORE INTO group_capabilities (gid, capability) VALUES (?, ?)`, diff --git a/gateway/src/kernel/connect.test.ts b/gateway/src/kernel/connect.test.ts index 998c88d27..cbdda07c0 100644 --- a/gateway/src/kernel/connect.test.ts +++ b/gateway/src/kernel/connect.test.ts @@ -7,7 +7,7 @@ import { CapabilityStore } from "./capabilities"; import { ConfigStore } from "./config"; import { DeviceRegistry } from "./devices"; import { ProcessRegistry } from "./processes"; -import { hashPassword } from "../auth/shadow"; +import { hashPassword, makeShadowEntry } from "../auth/shadow"; import { createMockSqlTables, handleMockSchemaStatement, @@ -415,7 +415,7 @@ describe("handleConnect", () => { { protocol: 2, client: { id: "c1", version: "1", platform: "test", role: "user" }, - auth: { username: "root", token: issued.token }, + auth: { username: "ROOT", token: issued.token }, }, ctx, ); @@ -655,7 +655,7 @@ describe("handleConnect", () => { { protocol: 2, client: { id: "c1", version: "1", platform: "test", role: "user" }, - auth: { username: "root", password: "hunter2" }, + auth: { username: "ROOT", password: "hunter2" }, }, ctx, ); @@ -666,6 +666,39 @@ describe("handleConnect", () => { } }); + it("normalizes ASCII authentication names but rejects Unicode case-fold aliases", async () => { + const ctx = makeCtx(sql); + const passwordHash = await hashPassword("password-123"); + await ctx.auth.bootstrap(); + ctx.auth.addUser({ + username: "kate", + uid: 1000, + gid: 1000, + gecos: "Kate", + home: "/home/kate", + shell: "/bin/init", + }); + ctx.auth.setShadow(makeShadowEntry("kate", passwordHash)); + const issued = await ctx.auth.issueToken({ uid: 1000, kind: "user" }); + + await expect(ctx.auth.authenticate("KATE", "password-123")).resolves.toMatchObject({ + ok: true, + identity: { username: "kate" }, + }); + await expect(ctx.auth.authenticateToken("KATE", issued.token)).resolves.toMatchObject({ + ok: true, + identity: { username: "kate" }, + }); + await expect(ctx.auth.authenticate("\u212Aate", "password-123")).resolves.toEqual({ + ok: false, + error: "Unknown user", + }); + await expect(ctx.auth.authenticateToken("\u212Aate", issued.token)).resolves.toEqual({ + ok: false, + error: "Unknown user", + }); + }); + it("rejects driver password auth when machine-token enforcement is enabled", async () => { const ctx = makeCtx(sql); const pwHash = await hashPassword("hunter2"); diff --git a/gateway/src/kernel/connect.ts b/gateway/src/kernel/connect.ts index b145f158b..329c8994f 100644 --- a/gateway/src/kernel/connect.ts +++ b/gateway/src/kernel/connect.ts @@ -219,7 +219,7 @@ function withDefaultProcessContext(identity: { }; } -function resolveConnectionCapabilities( +export function resolveConnectionCapabilities( role: ConnectArgs["client"]["role"], identity: ProcessIdentity, caps: CapabilityStore, diff --git a/gateway/src/kernel/do.test.ts b/gateway/src/kernel/do.test.ts index e92f9aad1..9ce00d1e2 100644 --- a/gateway/src/kernel/do.test.ts +++ b/gateway/src/kernel/do.test.ts @@ -922,6 +922,55 @@ describe("Kernel device connection cleanup", () => { }); }); +describe("Kernel connection rehydration", () => { + it("refreshes account groups and capabilities before restoring a user connection", () => { + const connection: any = { + id: "user-connection", + state: { + step: "connected", + identity: { + role: "user", + process: { + uid: 1000, + gid: 1000, + gids: [1000, 100], + username: "alice", + home: "/home/alice", + cwd: "/home/alice", + }, + capabilities: ["*"], + }, + }, + setState: vi.fn((state) => { + connection.state = state; + }), + close: vi.fn(), + }; + const kernel = Object.create(Kernel.prototype) as any; + kernel.getConnections = vi.fn(() => [connection]); + kernel.connections = new Map(); + kernel.auth = { + getPasswdByUid: vi.fn(() => ({ + uid: 1000, + gid: 1000, + username: "alice", + home: "/home/alice", + })), + resolveGids: vi.fn(() => [1000]), + }; + kernel.caps = { resolve: vi.fn(() => ["fs.read"]) }; + kernel.devices = { listOnline: vi.fn(() => []) }; + + kernel.rehydrateConnections(); + + expect(connection.state.identity).toMatchObject({ + process: { gids: [1000] }, + capabilities: ["fs.read"], + }); + expect(kernel.connections.get(connection.id)).toBe(connection); + }); +}); + describe("Kernel user signal broadcasts", () => { it("does not send user signals to driver or service sockets", () => { const user = { state: { identity: { role: "user", process: { uid: 1000 } } }, send: vi.fn() }; diff --git a/gateway/src/kernel/do.ts b/gateway/src/kernel/do.ts index e7769cbd7..ec61c398e 100644 --- a/gateway/src/kernel/do.ts +++ b/gateway/src/kernel/do.ts @@ -71,6 +71,7 @@ import { APP_CLIENT_SESSION_TTL_MS, AppSessionStore } from "./app-sessions"; import { ensureKernelBootstrapped, handleConnect, + resolveConnectionCapabilities, setupRequiredDetails, SETUP_REQUIRED_ERROR_CODE, } from "./connect"; @@ -3535,10 +3536,27 @@ export class Kernel extends Host { const state = connection.state; if (!state || state.step !== "connected" || !state.identity) continue; + const account = this.auth.getPasswdByUid(state.identity.process.uid); + if (!account || account.username !== state.identity.process.username) { + connection.close(1008, "Account unavailable"); + continue; + } + const process = { + ...state.identity.process, + gid: account.gid, + gids: this.auth.resolveGids(account.username, account.gid), + home: account.home, + }; + const identity = { + ...state.identity, + process, + capabilities: resolveConnectionCapabilities(state.identity.role, process, this.caps), + } as ConnectionIdentity; + connection.setState({ ...state, identity }); this.connections.set(connection.id, connection); - if (state.identity.role === "driver") { - onlineTargets.add(state.identity.device); - this.devices.setOnline(state.identity.device, true); + if (identity.role === "driver") { + onlineTargets.add(identity.device); + this.devices.setOnline(identity.device, true); } } diff --git a/gateway/src/kernel/pkg.test.ts b/gateway/src/kernel/pkg.test.ts index 2ad3a1247..89e1e782d 100644 --- a/gateway/src/kernel/pkg.test.ts +++ b/gateway/src/kernel/pkg.test.ts @@ -126,6 +126,16 @@ function makeRootIdentity() { }; } +function makeNonRootWildcardIdentity() { + return { + capabilities: ["*"], + process: { + uid: 1000, + username: "alice", + }, + }; +} + describe("pkg syscalls", () => { it("stores public repo state as repo visibility metadata", () => { const config = makeConfig(); @@ -149,6 +159,17 @@ describe("pkg syscalls", () => { expect(config.get("repos/alice/weather/visibility")).toBeNull(); }); + it("does not treat a non-root wildcard capability as repo-owner authority", () => { + const ctx = { + config: makeConfig(), + identity: makeNonRootWildcardIdentity(), + } as unknown as KernelContext; + + expect(() => handlePkgPublicSet({ repo: "bob/weather", public: true }, ctx)).toThrow( + "Forbidden: only bob or root may change visibility for bob/weather", + ); + }); + it("requires a package id for package sync", async () => { await expect(handlePkgSync(undefined, {} as KernelContext)).rejects.toThrow("packageId is required"); }); @@ -389,6 +410,24 @@ describe("pkg syscalls", () => { expect(remove).toHaveBeenCalledWith(record.packageId, record.scope); }); + it("does not treat a non-root wildcard capability as global package authority", async () => { + const record = makeInstalledPackageRecord({ + packageId: "import:root/gsv:packages/wiki", + name: "wiki", + sourceSubdir: "packages/wiki", + }); + const ctx = { + packages: { + resolve: vi.fn(() => record), + }, + identity: makeNonRootWildcardIdentity(), + } as unknown as KernelContext; + + await expect(handlePkgRemove({ packageId: record.packageId }, ctx)).rejects.toThrow( + `${record.packageId} is not installed in your package scope`, + ); + }); + it("scaffolds a user-owned package repo and installs the resolved package", async () => { const fetcher = makeFetcher((url, init) => { if (url.pathname === "/hyperspace/repos/alice/weather/refs") { diff --git a/gateway/src/kernel/pkg.ts b/gateway/src/kernel/pkg.ts index ae1750a98..49d71a9b2 100644 --- a/gateway/src/kernel/pkg.ts +++ b/gateway/src/kernel/pkg.ts @@ -1094,7 +1094,7 @@ function installScopeForActor(ctx: KernelContext): PackageInstallScope { function assertMutablePackageAccess(record: InstalledPackageRecord, ctx: KernelContext): void { const identity = requireIdentity(ctx); - if (identity.process.uid === 0 || (identity.capabilities ?? []).includes("*")) { + if (identity.process.uid === 0) { return; } if (packageScopeEquals(record.scope, { kind: "user", uid: resolveCallerOwnerUid(ctx) })) { @@ -1323,9 +1323,6 @@ function assertRepoOwnerOrRoot( if (identity.process.uid === 0 || identity.process.username === owner) { return; } - if ((identity.capabilities ?? []).includes("*")) { - return; - } throw new Error(`Forbidden: only ${owner} or root may change visibility for ${repo}`); } diff --git a/gateway/src/kernel/repo.test.ts b/gateway/src/kernel/repo.test.ts index 4234b46ed..59ecef903 100644 --- a/gateway/src/kernel/repo.test.ts +++ b/gateway/src/kernel/repo.test.ts @@ -616,6 +616,16 @@ describe("repo syscalls", () => { }); }); + it("does not treat a non-root wildcard capability as an arbitrary write bypass", () => { + const ctx = makeContext(makeFetcher(() => { + throw new Error("ripgit should not be called"); + })); + ctx.identity!.capabilities = ["*"]; + + expect(canWriteRepo("alice/private", ctx)).toBe(true); + expect(canWriteRepo("bob/private", ctx)).toBe(false); + }); + it("compares refs through query parameters so branch names may contain slashes", async () => { const fetcher = makeFetcher((url) => { expect(url.pathname).toBe("/hyperspace/repos/alice/demo/compare"); diff --git a/gateway/src/kernel/repo.ts b/gateway/src/kernel/repo.ts index be335f307..2a76db94d 100644 --- a/gateway/src/kernel/repo.ts +++ b/gateway/src/kernel/repo.ts @@ -466,7 +466,7 @@ export function canReadRepo(rawRepo: string, ctx: KernelContext): boolean { export function canWriteRepo(rawRepo: string, ctx: KernelContext): boolean { const repo = parseRepoSlug(rawRepo); const identity = requireIdentity(ctx); - if (identity.process.uid === 0 || identity.capabilities.includes("*")) { + if (identity.process.uid === 0) { return true; } if (repo.owner === identity.process.username) { diff --git a/gateway/src/kernel/schema/migrations.test.ts b/gateway/src/kernel/schema/migrations.test.ts index 8dc067836..e33ccee88 100644 --- a/gateway/src/kernel/schema/migrations.test.ts +++ b/gateway/src/kernel/schema/migrations.test.ts @@ -31,7 +31,7 @@ function createTableStatement(name: string): string { describe("kernel schema migrations", () => { it("starts the kernel component at a v1 baseline", () => { expect(KERNEL_SCHEMA_COMPONENT).toBe("kernel"); - expect(KERNEL_MIGRATIONS).toHaveLength(14); + expect(KERNEL_MIGRATIONS).toHaveLength(15); expect(KERNEL_MIGRATIONS[0]).toMatchObject({ id: 1, name: "initial_kernel_schema", @@ -88,6 +88,10 @@ describe("kernel schema migrations", () => { id: 14, name: "add_adapter_ingress_delivery_id", }); + expect(KERNEL_MIGRATIONS[14]).toMatchObject({ + id: 15, + name: "harden_identity_authority", + }); }); it("creates the current kernel table set", () => { @@ -125,6 +129,7 @@ describe("kernel schema migrations", () => { "oauth_accounts", "user_mcp_servers", "adapter_ingress_receipts", + "identity_id_allocator", ]); }); @@ -253,6 +258,25 @@ describe("kernel schema migrations", () => { ); }); + it("revokes persisted root-only capabilities from non-root groups", () => { + const statements = normalizedStatements(); + expect(statements).toContain( + "DELETE FROM group_capabilities WHERE gid = 100 AND capability = 'sys.bootstrap'", + ); + expect(statements).toContain( + "DELETE FROM group_capabilities WHERE gid <> 0 AND capability = '*'", + ); + }); + + it("initializes one shared identity allocator above every persisted uid and gid", () => { + const allocator = createTableStatement("identity_id_allocator"); + expect(allocator).toContain("singleton INTEGER PRIMARY KEY CHECK (singleton = 1)"); + expect(allocator).toContain("next_id INTEGER NOT NULL CHECK (next_id >= 1000)"); + expect(normalizedStatements()).toContain( + "INSERT INTO identity_id_allocator (singleton, next_id) SELECT 1, MAX(1000, COALESCE(MAX(id), 999) + 1) FROM ( SELECT uid AS id FROM passwd UNION ALL SELECT gid AS id FROM passwd UNION ALL SELECT gid AS id FROM groups )", + ); + }); + it("includes current indexes owned by the kernel stores", () => { expect(createdIndexes()).toEqual(expect.arrayContaining([ "idx_auth_tokens_uid", diff --git a/gateway/src/kernel/schema/migrations.ts b/gateway/src/kernel/schema/migrations.ts index 43ddb7fc5..c071938bd 100644 --- a/gateway/src/kernel/schema/migrations.ts +++ b/gateway/src/kernel/schema/migrations.ts @@ -25,6 +25,9 @@ import { import { KERNEL_V014_ADD_ADAPTER_INGRESS_DELIVERY_ID, } from "./v014_add_adapter_ingress_delivery_id"; +import { + KERNEL_V015_HARDEN_IDENTITY_AUTHORITY, +} from "./v015_harden_identity_authority"; // Used by Kernel DO startup before the individual stores initialize. export const KERNEL_SCHEMA_COMPONENT = "kernel"; @@ -44,6 +47,7 @@ export const KERNEL_MIGRATIONS: readonly SqlMigration[] = [ KERNEL_V012_ADD_SCHEDULE_ATTEMPT_COUNT, KERNEL_V013_ADD_ADAPTER_INGRESS_RECEIPTS, KERNEL_V014_ADD_ADAPTER_INGRESS_DELIVERY_ID, + KERNEL_V015_HARDEN_IDENTITY_AUTHORITY, ]; export function runKernelSqlMigrations(storage: DurableObjectStorage): void { diff --git a/gateway/src/kernel/schema/v015_harden_identity_authority.ts b/gateway/src/kernel/schema/v015_harden_identity_authority.ts new file mode 100644 index 000000000..c45514093 --- /dev/null +++ b/gateway/src/kernel/schema/v015_harden_identity_authority.ts @@ -0,0 +1,27 @@ +import type { SqlMigration } from "../../schema/runner"; + +export const KERNEL_V015_HARDEN_IDENTITY_AUTHORITY: SqlMigration = { + id: 15, + name: "harden_identity_authority", + statements: [ + "DELETE FROM group_capabilities WHERE gid = 100 AND capability = 'sys.bootstrap'", + "DELETE FROM group_capabilities WHERE gid <> 0 AND capability = '*'", + ` + CREATE TABLE IF NOT EXISTS identity_id_allocator ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + next_id INTEGER NOT NULL CHECK (next_id >= 1000) + ) + `, + ` + INSERT INTO identity_id_allocator (singleton, next_id) + SELECT 1, MAX(1000, COALESCE(MAX(id), 999) + 1) + FROM ( + SELECT uid AS id FROM passwd + UNION ALL + SELECT gid AS id FROM passwd + UNION ALL + SELECT gid AS id FROM groups + ) + `, + ], +}; diff --git a/gateway/src/kernel/sys/bootstrap.test.ts b/gateway/src/kernel/sys/bootstrap.test.ts index ef42f6d18..7b8e97450 100644 --- a/gateway/src/kernel/sys/bootstrap.test.ts +++ b/gateway/src/kernel/sys/bootstrap.test.ts @@ -314,4 +314,21 @@ describe("handleSysBootstrap", () => { "RIPGIT binding is required for system bootstrap", ); }); + + it("requires uid 0 even when the caller has the unrestricted capability", async () => { + const ctx = makeContext(); + ctx.identity!.process = { + uid: 1000, + gid: 1000, + gids: [1000, 100], + username: "alice", + home: "/home/alice", + cwd: "/home/alice", + }; + + await expect(handleSysBootstrap(undefined, ctx)).rejects.toThrow( + "sys.bootstrap requires root", + ); + expect(importFromUpstreamMock).not.toHaveBeenCalled(); + }); }); diff --git a/gateway/src/kernel/sys/bootstrap.ts b/gateway/src/kernel/sys/bootstrap.ts index f6d24be3e..553081830 100644 --- a/gateway/src/kernel/sys/bootstrap.ts +++ b/gateway/src/kernel/sys/bootstrap.ts @@ -55,6 +55,9 @@ export async function handleSysBootstrap( args: SysBootstrapArgs | undefined, ctx: KernelContext, ): Promise { + if (ctx.identity?.process.uid !== 0) { + throw new Error("sys.bootstrap requires root"); + } if (!ctx.env.RIPGIT) { throw new Error("RIPGIT binding is required for system bootstrap"); } @@ -63,9 +66,6 @@ export async function handleSysBootstrap( const { remoteUrl: manualRemoteUrl, ref: manualRef } = resolveManualBootstrapUpstream(ctx.env); const ripgit = new RipgitClient(ctx.env.RIPGIT); - if (!ctx.identity) { - throw new Error("Authenticated identity required"); - } const actorName = ctx.identity.process.username; const startedAt = Date.now(); const timings: BootstrapTiming[] = []; diff --git a/gateway/src/kernel/sys/setup.test.ts b/gateway/src/kernel/sys/setup.test.ts index e19d7ca02..0a5546bad 100644 --- a/gateway/src/kernel/sys/setup.test.ts +++ b/gateway/src/kernel/sys/setup.test.ts @@ -266,7 +266,7 @@ describe("handleSysSetup", () => { expect(groups.find((group) => group.name === "wiki-builder-run")?.members).toEqual(["alice"]); }); - it("seeds shipped skills into root home after first setup bootstrap", async () => { + it("runs system bootstrap as root and preserves first-user skill seeding", async () => { const ripgit = { fetch: vi.fn(async (input: RequestInfo | URL) => { const url = new URL(String(input)); @@ -292,14 +292,14 @@ describe("handleSysSetup", () => { undefined, expect.objectContaining({ identity: expect.objectContaining({ - process: expect.objectContaining({ username: "alice" }), + process: expect.objectContaining({ uid: 0, username: "root" }), }), }), ); expect(seedRepoSkillsToHomeMock).toHaveBeenCalledWith( expect.any(Object), { owner: "root", repo: "gsv", branch: "abc123" }, - expect.objectContaining({ username: "root", home: "/root" }), + expect.objectContaining({ uid: 1000, username: "alice", home: "/home/alice" }), ); }); @@ -327,6 +327,48 @@ describe("handleSysSetup", () => { )).rejects.toThrow("username must match"); }); + it("rejects Unicode case-fold aliases before normalizing setup usernames", async () => { + const { ctx, auth } = createCtx(); + + await expect(handleSysSetup( + { + username: "\u212Aate", + password: "password-123", + }, + ctx, + )).rejects.toThrow("username must match"); + + expect(auth.addUser).not.toHaveBeenCalled(); + }); + + it("bounds setup usernames before trimming them", async () => { + const { ctx, auth } = createCtx(); + + await expect(handleSysSetup( + { + username: `${" ".repeat(64)}alice`, + password: "password-123", + }, + ctx, + )).rejects.toThrow("username must match"); + + expect(auth.addUser).not.toHaveBeenCalled(); + }); + + it("normalizes ordinary ASCII uppercase setup usernames", async () => { + const { ctx } = createCtx(); + + const result = await handleSysSetup( + { + username: "Alice", + password: "password-123", + }, + ctx, + ); + + expect(result.user.username).toBe("alice"); + }); + it("rejects a personal agent username that matches the first user", async () => { const { ctx, auth } = createCtx(); diff --git a/gateway/src/kernel/sys/setup.ts b/gateway/src/kernel/sys/setup.ts index 3496f5948..08a451ec5 100644 --- a/gateway/src/kernel/sys/setup.ts +++ b/gateway/src/kernel/sys/setup.ts @@ -9,8 +9,7 @@ import { RipgitClient } from "../../fs"; import { seedRepoSkillsToHome } from "./skills-seed"; import { ensurePersonalAgent } from "../agents"; import { provisionEnabledPackagesForCaller } from "../package-agents"; - -const USERNAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/; +import { normalizeAccountUsername } from "../../auth/username"; type SetupTiming = { label: string; @@ -74,10 +73,13 @@ function ensureSingleUserBootstrap(passwd: PasswdEntry[]): void { function parseSetupIdentity(args: SysSetupArgs): { username: string; password: string } { const raw = args as Record; - const username = readRequiredString(raw.username, "username"); - if (!USERNAME_RE.test(username)) { + if (typeof raw.username !== "string" || raw.username.length === 0) { + throw new Error("username is required"); + } + const username = normalizeAccountUsername(raw.username); + if (!username) { throw new Error( - "username must match ^[a-z_][a-z0-9_-]{0,31}$", + "username must match ^[A-Za-z_][A-Za-z0-9_-]{0,31}$", ); } @@ -94,10 +96,10 @@ function parseSetupAgentName( value: unknown, username: string, ): string | undefined { - const agentName = readOptionalString(value); - if (!agentName) return undefined; - if (!USERNAME_RE.test(agentName)) { - throw new Error("agentName must match ^[a-z_][a-z0-9_-]{0,31}$"); + if (typeof value !== "string" || value.length === 0) return undefined; + const agentName = normalizeAccountUsername(value); + if (!agentName) { + throw new Error("agentName must match ^[A-Za-z_][A-Za-z0-9_-]{0,31}$"); } if (agentName === username) { throw new Error("agentName must be different from username"); @@ -206,9 +208,9 @@ export async function handleSysSetup( home: "/root", cwd: "/root", }; - const bootstrapIdentity: UserIdentity = { + const rootIdentity: UserIdentity = { role: "user", - process: bootstrapProcessIdentity, + process: rootProcessIdentity, capabilities: ["*"], }; let bootstrap: SysSetupResult["bootstrap"]; @@ -221,7 +223,7 @@ export async function handleSysSetup( "bootstrap-system", () => handleSysBootstrap(rawArgs.bootstrap as SysSetupArgs["bootstrap"], { ...ctx, - identity: bootstrapIdentity, + identity: rootIdentity, }), ); } @@ -316,7 +318,7 @@ export async function handleSysSetup( const bootstrapResult = bootstrap; if (bootstrapResult && ctx.env.RIPGIT) { - // handleSysBootstrap seeds the first setup user's skills; seed root explicitly too. + // Bootstrap runs as root and seeds root; preserve first-user skill seeding here. const ripgit = new RipgitClient(ctx.env.RIPGIT); const sourceRepo = { owner: "root", @@ -325,8 +327,8 @@ export async function handleSysSetup( }; await timeSetupStep( timings, - "seed-root-skills", - () => seedRepoSkillsToHome(ripgit, sourceRepo, rootProcessIdentity), + "seed-user-skills", + () => seedRepoSkillsToHome(ripgit, sourceRepo, bootstrapProcessIdentity), ); } @@ -343,6 +345,11 @@ export async function handleSysSetup( await ensurePersonalAgent(ctx, processIdentity, agentName); }); + const bootstrapIdentity: UserIdentity = { + role: "user", + process: processIdentity, + capabilities: ctx.caps.resolve(processIdentity.gids), + }; await timeSetupStep(timings, "provision-package-agents", async () => { await provisionEnabledPackagesForCaller( { ...ctx, identity: bootstrapIdentity }, From 5d3a80854badd158096978d95a93d31f0852f827 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 08:41:17 +0200 Subject: [PATCH 2/7] preserve filesystem ownership --- gateway/src/fs/backends/kernel.ts | 7 +- gateway/src/fs/backends/r2.ts | 25 +++++-- gateway/src/fs/fs.test.ts | 119 ++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/gateway/src/fs/backends/kernel.ts b/gateway/src/fs/backends/kernel.ts index c9c28fc5d..feec6b2a9 100644 --- a/gateway/src/fs/backends/kernel.ts +++ b/gateway/src/fs/backends/kernel.ts @@ -1097,7 +1097,8 @@ export class KernelMountBackend implements MountBackend { if (path.startsWith("/sys/devices/") && !path.slice("/sys/devices/".length).includes("/")) { const deviceId = path.slice("/sys/devices/".length); - return this.kernel.devices.get(deviceId) !== null; + return this.kernel.devices.get(deviceId) !== null + && this.kernel.devices.canAccess(deviceId, this.identity.uid, this.identity.gids); } if (path.startsWith("/sys/users/") && !path.slice("/sys/users/".length).includes("/")) { @@ -1292,7 +1293,9 @@ export class KernelMountBackend implements MountBackend { const parts = path.slice("/sys/devices/".length).split("/"); if (parts.length === 1 && parts[0]) { const device = this.kernel.devices.get(parts[0]); - if (device) return ["description", "implements", "owner", "platform", "status", "version"]; + if (device && this.kernel.devices.canAccess(parts[0], this.identity.uid, this.identity.gids)) { + return ["description", "implements", "owner", "platform", "status", "version"]; + } } } diff --git a/gateway/src/fs/backends/r2.ts b/gateway/src/fs/backends/r2.ts index ec7d076da..aeb7d23cd 100644 --- a/gateway/src/fs/backends/r2.ts +++ b/gateway/src/fs/backends/r2.ts @@ -110,8 +110,9 @@ export class R2MountBackend implements MountBackend { : inferContentType(p), }, customMetadata: { - uid: String(this.identity.uid), - gid: String(this.identity.gid), + ...existing?.customMetadata, + uid: existing?.customMetadata?.uid ?? String(this.identity.uid), + gid: existing?.customMetadata?.gid ?? String(this.identity.gid), mode: existing?.customMetadata?.mode ?? "644", }, }); @@ -138,8 +139,9 @@ export class R2MountBackend implements MountBackend { this.bucket.put(key, fixed.readable, { httpMetadata: toR2HttpMetadata(p, options), customMetadata: { - uid: String(this.identity.uid), - gid: String(this.identity.gid), + ...existing?.customMetadata, + uid: existing?.customMetadata?.uid ?? String(this.identity.uid), + gid: existing?.customMetadata?.gid ?? String(this.identity.gid), mode: existing?.customMetadata?.mode ?? "644", }, }), @@ -262,9 +264,13 @@ export class R2MountBackend implements MountBackend { const dirKey = key.endsWith("/") ? key : key + "/"; const markerKey = dirKey + ".dir"; const existing = await this.bucket.head(markerKey); - if (existing && !options?.recursive) throw new Error(`EEXIST: file already exists, mkdir '${p}'`); + if (existing) { + if (options?.recursive) return; + throw new Error(`EEXIST: file already exists, mkdir '${p}'`); + } - await this.bucket.put(markerKey, "", { + const stored = await this.bucket.put(markerKey, "", { + onlyIf: { etagDoesNotMatch: "*" }, customMetadata: { uid: String(this.identity.uid), gid: String(this.identity.gid), @@ -272,6 +278,9 @@ export class R2MountBackend implements MountBackend { dirmarker: "1", }, }); + if (!stored && !options?.recursive) { + throw new Error(`EEXIST: file already exists, mkdir '${p}'`); + } } async readdir(path: string): Promise { @@ -361,7 +370,8 @@ export class R2MountBackend implements MountBackend { throw new Error(`EEXIST: file already exists, symlink '${p}'`); } - await this.bucket.put(key, target, { + const stored = await this.bucket.put(key, target, { + onlyIf: { etagDoesNotMatch: "*" }, httpMetadata: { contentType: "text/plain" }, customMetadata: { uid: String(this.identity.uid), @@ -370,6 +380,7 @@ export class R2MountBackend implements MountBackend { symlink: "1", }, }); + if (!stored) throw new Error(`EEXIST: file already exists, symlink '${p}'`); } async readlink(path: string): Promise { diff --git a/gateway/src/fs/fs.test.ts b/gateway/src/fs/fs.test.ts index 684b01cc4..469ed9f39 100644 --- a/gateway/src/fs/fs.test.ts +++ b/gateway/src/fs/fs.test.ts @@ -953,6 +953,58 @@ describe("GsvFs write metadata", () => { expect(head?.customMetadata?.mode).toBe("644"); }); + it("preserves all metadata when a group-authorized writer overwrites files", async () => { + const metadata = { + uid: "1000", + gid: "100", + mode: "660", + classification: "private", + }; + const fs = makeFs(ALICE); + + for (const streamed of [false, true]) { + const key = `${TEST_PREFIX}${streamed ? "streamed" : "buffered"}-group-owned.txt`; + await env.STORAGE.put(key, "before", { customMetadata: metadata }); + + if (streamed) { + const bytes = new TextEncoder().encode("after"); + await fs.writeFileStream(`/${key}`, bytesToStream(bytes), { expectedSize: bytes.byteLength }); + } else { + await fs.writeFile(`/${key}`, "after"); + } + + const object = await env.STORAGE.get(key); + expect(await object?.text()).toBe("after"); + expect(object?.customMetadata).toEqual(metadata); + } + }); + + it("fills missing ownership metadata without discarding existing fields", async () => { + const fs = makeFs(ROOT); + + for (const streamed of [false, true]) { + const key = `${TEST_PREFIX}${streamed ? "streamed" : "buffered"}-partial-metadata.txt`; + await env.STORAGE.put(key, "before", { + customMetadata: { classification: "private" }, + }); + + if (streamed) { + const bytes = new TextEncoder().encode("after"); + await fs.writeFileStream(`/${key}`, bytesToStream(bytes), { expectedSize: bytes.byteLength }); + } else { + await fs.writeFile(`/${key}`, "after"); + } + + const object = await env.STORAGE.get(key); + expect(object?.customMetadata).toEqual({ + classification: "private", + uid: "0", + gid: "0", + mode: "644", + }); + } + }); + it("appends binary files without UTF-8 conversion", async () => { const fs = makeFs(SAM); const path = `/${TEST_PREFIX}binary.dat`; @@ -1244,6 +1296,47 @@ describe("GsvFs directory removal", () => { }); }); +describe("R2 mkdir ownership", () => { + const TEST_PREFIX = "test/mkdir-ownership/"; + + beforeEach(async () => { + const listed = await env.STORAGE.list({ prefix: TEST_PREFIX }); + await env.STORAGE.delete(listed.objects.map((object) => object.key)); + }); + + it("does not rewrite an existing marker during recursive mkdir", async () => { + const marker = `${TEST_PREFIX}claimed/.dir`; + const metadata = { uid: "1001", gid: "100", mode: "700", dirmarker: "1" }; + await env.STORAGE.put(marker, "", { customMetadata: metadata }); + + await makeFs(SAM).mkdir(`/${TEST_PREFIX}claimed`, { recursive: true }); + + await expect(env.STORAGE.head(marker)).resolves.toMatchObject({ customMetadata: metadata }); + }); + + it("does not replace a marker created after its existence check", async () => { + let markerOwner = ALICE.uid; + let putOptions: R2PutOptions | undefined; + const bucket = { + head: async () => null, + put: async ( + _key: string, + _value: Parameters[1], + options?: R2PutOptions, + ) => { + putOptions = options; + if (!options?.onlyIf) markerOwner = SAM.uid; + return null; + }, + } as unknown as R2Bucket; + + await new R2MountBackend(bucket, SAM).mkdir(`/${TEST_PREFIX}raced`, { recursive: true }); + + expect(putOptions?.onlyIf).toEqual({ etagDoesNotMatch: "*" }); + expect(markerOwner).toBe(ALICE.uid); + }); +}); + describe("resolveUserPath", () => { it("resolves ~ to home", () => { expect(resolveUserPath("~", "/home/sam", "/home/sam")).toBe("/home/sam"); @@ -1393,6 +1486,32 @@ describe("GsvFs virtual /sys config tree", () => { await expect(fs.readdir("/sys/users/1001")).rejects.toThrow("ENOENT"); }); + + it("does not expose a guessed inaccessible device directory", async () => { + const kernel = { + auth: null as never, + procs: null as never, + caps: null as never, + config: null as never, + devices: { + get(deviceId: string) { + return deviceId === "alice-device" ? { device_id: deviceId } : null; + }, + canAccess(_deviceId: string, uid: number) { + return uid === 0; + }, + listForUser() { + return []; + }, + } as never, + }; + const fs = new GsvFs(env.STORAGE, SAM, kernel); + + await expect(fs.readdir("/sys/devices/alice-device")).rejects.toThrow("ENOENT"); + await expect(fs.stat("/sys/devices/alice-device")).rejects.toThrow("ENOENT"); + await expect(new GsvFs(env.STORAGE, ROOT, kernel).stat("/sys/devices/missing")) + .rejects.toThrow("ENOENT"); + }); }); describe("GsvFs Linux-like runtime views", () => { From d7021dfe5e66bb1771154a81144bcf63acc1e701 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 08:43:28 +0200 Subject: [PATCH 3/7] consume oauth state atomically --- gateway/src/kernel/oauth-store.test.ts | 39 ++++++++++++++++++++++++++ gateway/src/kernel/oauth-store.ts | 16 ++++------- gateway/src/kernel/sys/oauth.test.ts | 8 +++--- gateway/src/kernel/sys/oauth.ts | 4 +-- 4 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 gateway/src/kernel/oauth-store.test.ts diff --git a/gateway/src/kernel/oauth-store.test.ts b/gateway/src/kernel/oauth-store.test.ts new file mode 100644 index 000000000..a0763b897 --- /dev/null +++ b/gateway/src/kernel/oauth-store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import { runInDurableObject } from "cloudflare:test"; +import { getAgentByName } from "agents"; +import { Kernel } from "./do"; +import { OAuthStore } from "./oauth-store"; + +describe("OAuthStore", () => { + it("atomically consumes callback state once", async () => { + const kernel = await getAgentByName(env.KERNEL, crypto.randomUUID()); + + await runInDurableObject(kernel, (_instance, state) => { + const store = new OAuthStore(state.storage.sql); + const now = Date.now(); + store.createFlow({ + flowId: "flow-1", + stateHash: "state-hash", + uid: 1000, + kind: "generic", + provider: "example", + accountKey: "default", + label: null, + authorizationEndpoint: "https://example.com/authorize", + tokenEndpoint: "https://example.com/token", + clientId: "client", + redirectUri: "https://gsv.example/oauth/callback", + scope: null, + resource: null, + extraAuthParams: {}, + codeVerifier: "verifier", + createdAt: now, + expiresAt: now + 60_000, + }); + + expect(store.consumeFlowByStateHash("state-hash", now)?.flowId).toBe("flow-1"); + expect(store.consumeFlowByStateHash("state-hash", now)).toBeNull(); + }); + }); +}); diff --git a/gateway/src/kernel/oauth-store.ts b/gateway/src/kernel/oauth-store.ts index 76feb6f1e..3131cc8eb 100644 --- a/gateway/src/kernel/oauth-store.ts +++ b/gateway/src/kernel/oauth-store.ts @@ -141,18 +141,12 @@ export class OAuthStore { }; } - getFlowByStateHash(stateHash: string, now = Date.now()): OAuthFlowRecord | null { - const rows = this.sql.exec( - "SELECT * FROM oauth_flows WHERE state_hash = ?", + consumeFlowByStateHash(stateHash: string, now = Date.now()): OAuthFlowRecord | null { + const row = this.sql.exec( + "DELETE FROM oauth_flows WHERE state_hash = ? RETURNING *", stateHash, - ).toArray(); - const row = rows[0]; - if (!row) return null; - if (row.expires_at <= now) { - this.deleteFlow(row.flow_id); - return null; - } - return flowFromRow(row); + ).toArray()[0]; + return row && row.expires_at > now ? flowFromRow(row) : null; } getFlow(flowId: string, uid?: number, now = Date.now()): OAuthFlowRecord | null { diff --git a/gateway/src/kernel/sys/oauth.test.ts b/gateway/src/kernel/sys/oauth.test.ts index 14abc4e85..57cc0502f 100644 --- a/gateway/src/kernel/sys/oauth.test.ts +++ b/gateway/src/kernel/sys/oauth.test.ts @@ -20,7 +20,7 @@ type FakeOAuth = { listFlows: ReturnType; deleteAccount: ReturnType; getFlow: ReturnType; - getFlowByStateHash: ReturnType; + consumeFlowByStateHash: ReturnType; upsertAccount: ReturnType; deleteFlow: ReturnType; }; @@ -57,7 +57,7 @@ function createFakeOAuth(): FakeOAuth { listFlows: vi.fn(() => []), deleteAccount: vi.fn(() => true), getFlow: vi.fn(), - getFlowByStateHash: vi.fn(), + consumeFlowByStateHash: vi.fn(), upsertAccount: vi.fn((input) => ({ accountId: "acct-1", ...input, @@ -442,7 +442,7 @@ describe("sys.oauth handlers", () => { }); it("exchanges an OAuth callback code and stores tokens behind the summary boundary", async () => { - oauth.getFlowByStateHash.mockReturnValue(flow); + oauth.consumeFlowByStateHash.mockReturnValue(flow); const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("authorization_code"); @@ -476,7 +476,7 @@ describe("sys.oauth handlers", () => { expiresAt: 1_700_003_600_000, scope: "openid profile email", })); - expect(oauth.deleteFlow).toHaveBeenCalledWith("flow-1"); + expect(oauth.consumeFlowByStateHash).toHaveBeenCalledOnce(); expect(JSON.stringify(result)).not.toContain("access-secret"); expect(JSON.stringify(result)).not.toContain("refresh-secret"); }); diff --git a/gateway/src/kernel/sys/oauth.ts b/gateway/src/kernel/sys/oauth.ts index 5a7e862a5..ec884494b 100644 --- a/gateway/src/kernel/sys/oauth.ts +++ b/gateway/src/kernel/sys/oauth.ts @@ -417,14 +417,13 @@ export async function completeOAuthCallback( return { ok: false, status: 400, message: "Missing OAuth state" }; } - const flow = oauth.getFlowByStateHash(await sha256Hex(state)); + const flow = oauth.consumeFlowByStateHash(await sha256Hex(state)); if (!flow) { return { ok: false, status: 400, message: "OAuth flow not found or expired" }; } const providerError = input.error?.trim(); if (providerError) { - oauth.deleteFlow(flow.flowId); const detail = input.errorDescription?.trim(); return { ok: false, @@ -463,7 +462,6 @@ export async function completeOAuthCallback( authorizedAt: now, }, }); - oauth.deleteFlow(flow.flowId); return { ok: true, account: summarizeAccount(account) }; } From 6781df5c742d457582025827aecc31b68a973750 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 08:43:36 +0200 Subject: [PATCH 4/7] generate secure link codes --- gateway/src/kernel/link-challenges.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/gateway/src/kernel/link-challenges.ts b/gateway/src/kernel/link-challenges.ts index e577dafef..58de3de33 100644 --- a/gateway/src/kernel/link-challenges.ts +++ b/gateway/src/kernel/link-challenges.ts @@ -154,16 +154,11 @@ export class LinkChallengeStore { private generateCode(): string { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - const part = () => { - let out = ""; - for (let i = 0; i < 4; i++) { - const idx = Math.floor(Math.random() * alphabet.length); - out += alphabet[idx]; - } - return out; - }; - - return `${part()}-${part()}`; + const code = Array.from( + crypto.getRandomValues(new Uint8Array(8)), + (value) => alphabet[value & 31], + ).join(""); + return `${code.slice(0, 4)}-${code.slice(4)}`; } } From 1afc0e85951520afb8c491f51fa5c5226a05b449 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 09:45:55 +0200 Subject: [PATCH 5/7] reject ungrantable package profiles --- gateway/src/kernel/package-agents.test.ts | 31 +++++++++++++++++++++++ gateway/src/kernel/package-agents.ts | 7 +++++ 2 files changed, 38 insertions(+) diff --git a/gateway/src/kernel/package-agents.test.ts b/gateway/src/kernel/package-agents.test.ts index 8328bcf22..12c935e87 100644 --- a/gateway/src/kernel/package-agents.test.ts +++ b/gateway/src/kernel/package-agents.test.ts @@ -146,6 +146,21 @@ describe("ensurePackageAgent", () => { expect(config.get(`users/${identity.uid}/ai/tools/approval`)).toBe('{"rules":[]}'); }); + it.each(["*", "not valid!", undefined])( + "rejects an ungrantable capability before creating the package agent: %s", + async (capability) => { + const { ctx, passwd, caps } = createCtx(); + const profile = { ...BUILDER, capabilities: [capability as string] }; + + await expect(ensurePackageAgent(ctx, record([profile]), profile, 1000)) + .rejects.toThrow("contains an invalid or root-only capability"); + + expect(passwd.find((entry) => entry.username === packageAgentUsername("wiki", "builder"))) + .toBeUndefined(); + expect(caps.grant).not.toHaveBeenCalled(); + }, + ); + it("grants run-as via the access group without leaking caps to the human", async () => { const { ctx, groups, caps, config } = createCtx(); const identity = await ensurePackageAgent(ctx, record([BUILDER]), BUILDER, 1000); @@ -248,6 +263,22 @@ describe("ensurePackageAgent", () => { expect(ctx.caps.resolve([identity.gid])).toEqual(["fs.write"]); expect(config.get(`users/${identity.uid}/ai/tools/approval`)).toBeUndefined(); }); + + it("rejects an ungrantable profile before changing an existing agent", async () => { + const { ctx, caps, capsTable } = createCtx(); + await ensurePackageAgent(ctx, record([BUILDER]), BUILDER, 1000); + const before = [...capsTable]; + caps.grant.mockClear(); + caps.revoke.mockClear(); + const profile = { ...BUILDER, capabilities: ["*"] }; + + await expect(ensurePackageAgent(ctx, record([profile]), profile, 1000)) + .rejects.toThrow("contains an invalid or root-only capability"); + + expect(capsTable).toEqual(before); + expect(caps.grant).not.toHaveBeenCalled(); + expect(caps.revoke).not.toHaveBeenCalled(); + }); }); describe("resolvePackageAgentRunAs", () => { diff --git a/gateway/src/kernel/package-agents.ts b/gateway/src/kernel/package-agents.ts index dc2b0b3ee..813af417b 100644 --- a/gateway/src/kernel/package-agents.ts +++ b/gateway/src/kernel/package-agents.ts @@ -14,6 +14,7 @@ import type { ProcessIdentity } from "@humansandmachines/gsv/protocol"; import type { PasswdEntry } from "../auth/passwd"; import { accountIdentity, createAccount, removeContextFile, writeContextFile } from "./accounts"; import { ensureAccountHomeLayout } from "./account-home"; +import { isValidCapability } from "./capabilities"; import type { KernelContext } from "./context"; import { resolveCallerOwnerUid } from "./context"; import { @@ -78,6 +79,12 @@ export async function ensurePackageAgent( profile: PackageProfileManifest, enablingHumanUid: number, ): Promise { + const hasInvalidCapability = (profile.capabilities ?? []).some( + (capability) => typeof capability !== "string" || capability === "*" || !isValidCapability(capability), + ); + if (hasInvalidCapability) { + throw new Error(`Package profile ${profile.name} contains an invalid or root-only capability`); + } const auth = ctx.auth; const username = packageAgentUsername(record.manifest.name, profile.name); const accessGroupName = packageAgentAccessGroup(username); From d2c4eb62e81f00985a978e8bdcb6619833de1194 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 13:22:38 +0200 Subject: [PATCH 6/7] add authorized user management cli --- cli/src/app.rs | 63 ++++ cli/src/cli.rs | 160 ++++++++++ cli/src/commands/mod.rs | 2 + cli/src/commands/user.rs | 275 +++++++++++++++++ docs/reference/cli-commands.md | 26 ++ docs/reference/syscalls.md | 70 +++++ gateway/src/kernel/accounts.test.ts | 32 +- gateway/src/kernel/agents.ts | 47 +-- gateway/src/kernel/context.ts | 1 + gateway/src/kernel/dispatch.ts | 6 + gateway/src/kernel/do.test.ts | 158 +++++++++- gateway/src/kernel/do.ts | 101 ++++++- gateway/src/kernel/scheduler.test.ts | 19 +- gateway/src/kernel/user-admin.test.ts | 329 +++++++++++++++++++++ gateway/src/kernel/user-admin.ts | 200 +++++++++++++ gateway/src/kernel/user-authority.ts | 35 +++ gateway/src/process/do.test.ts | 32 ++ gateway/src/syscalls/index.ts | 3 +- packages/gsv/src/client.ts | 2 + packages/gsv/src/protocol/index.ts | 1 + packages/gsv/src/protocol/syscalls/map.ts | 3 + packages/gsv/src/protocol/syscalls/user.ts | 46 +++ 22 files changed, 1575 insertions(+), 36 deletions(-) create mode 100644 cli/src/commands/user.rs create mode 100644 gateway/src/kernel/user-admin.test.ts create mode 100644 gateway/src/kernel/user-admin.ts create mode 100644 gateway/src/kernel/user-authority.ts create mode 100644 packages/gsv/src/protocol/syscalls/user.ts diff --git a/cli/src/app.rs b/cli/src/app.rs index 79aa19ede..595c378ca 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -8,6 +8,7 @@ use crate::auth_flow::{ }; use crate::cli::{ AuthAction, Cli, Commands, ConfigAction, DeviceAction, DeviceServiceAction, LocalConfigAction, + UserAction, }; use crate::commands; use crate::device::{ @@ -163,6 +164,68 @@ pub(crate) async fn run() -> Result<(), Box> { .await } }, + Commands::User { action } => match action { + UserAction::Create { + username, + new_password, + } => { + let password = commands::resolve_new_user_password(new_password)?; + let create_action = UserAction::Create { + username, + new_password: Some(password), + }; + run_with_auto_setup_and_login_retry( + &url, + &cfg, + cli_token_override.clone(), + cli_user_override.clone(), + cli_password_override.clone(), + "user", + |auth| async { commands::run_user(&url, auth, create_action.clone()).await }, + ) + .await + } + UserAction::Register { + username, + new_password, + ttl_hours, + } => { + if ttl_hours == 0 { + return Err("--ttl-hours must be greater than 0".into()); + } + let password = commands::resolve_new_user_password(new_password)?; + let create_action = UserAction::Create { + username: username.clone(), + new_password: Some(password.clone()), + }; + run_with_auto_setup_and_login_retry( + &url, + &cfg, + cli_token_override.clone(), + cli_user_override.clone(), + cli_password_override.clone(), + "user", + |auth| async { commands::run_user(&url, auth, create_action.clone()).await }, + ) + .await?; + + run_auth_login(&url, &cfg, Some(username), Some(password), ttl_hours).await + } + permissions_action @ UserAction::Permissions { .. } => { + run_with_auto_setup_and_login_retry( + &url, + &cfg, + cli_token_override.clone(), + cli_user_override.clone(), + cli_password_override.clone(), + "user", + |auth| async { + commands::run_user(&url, auth, permissions_action.clone()).await + }, + ) + .await + } + }, Commands::Device { action } => match action { DeviceAction::Run { id, workspace } => { let device_id = resolve_device_id(id.clone(), &cfg); diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 4a0c6a14e..271b47c5e 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -61,6 +61,12 @@ pub(crate) enum Commands { action: AuthAction, }, + /// User account and permission management + User { + #[command(subcommand)] + action: UserAction, + }, + /// Run and manage the device daemon Device { #[command(subcommand)] @@ -443,6 +449,55 @@ pub(crate) enum AuthAction { }, } +#[derive(Subcommand, Clone)] +pub(crate) enum UserAction { + /// Create a human account + Create { + /// Username for the new account + username: String, + + /// Password for the new account (if omitted, prompts interactively) + #[arg(long = "new-password")] + new_password: Option, + }, + + /// Create a human account and log in as it + Register { + /// Username for the new account + username: String, + + /// Password for the new account (if omitted, prompts interactively) + #[arg(long = "new-password")] + new_password: Option, + + /// Session lifetime in hours (default: 8) + #[arg(long, default_value_t = 8)] + ttl_hours: u32, + }, + + /// View or modify a user's capabilities and group memberships + Permissions { + /// Username to inspect or modify + username: String, + + /// Capability to grant directly (repeat for multiple) + #[arg(long)] + grant: Vec, + + /// Direct capability to revoke (repeat for multiple) + #[arg(long)] + revoke: Vec, + + /// Supplementary group to add (repeat for multiple) + #[arg(long = "add-group")] + add_groups: Vec, + + /// Supplementary group to remove (repeat for multiple) + #[arg(long = "remove-group")] + remove_groups: Vec, + }, +} + #[derive(Subcommand, Clone)] pub(crate) enum AuthTokenAction { /// Create a new auth token @@ -660,3 +715,108 @@ pub(crate) enum LocalConfigAction { value: String, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_user_create() { + let cli = Cli::try_parse_from([ + "gsv", + "user", + "create", + "alice", + "--new-password", + "correct-horse", + ]) + .expect("user create should parse"); + + let Commands::User { + action: + UserAction::Create { + username, + new_password, + }, + } = cli.command + else { + panic!("expected user create"); + }; + assert_eq!(username, "alice"); + assert_eq!(new_password.as_deref(), Some("correct-horse")); + } + + #[test] + fn parses_user_register_with_default_ttl() { + let cli = Cli::try_parse_from(["gsv", "user", "register", "bob"]) + .expect("user register should parse"); + + let Commands::User { + action: + UserAction::Register { + username, + new_password, + ttl_hours, + }, + } = cli.command + else { + panic!("expected user register"); + }; + assert_eq!(username, "bob"); + assert!(new_password.is_none()); + assert_eq!(ttl_hours, 8); + } + + #[test] + fn parses_repeated_user_permission_changes() { + let cli = Cli::try_parse_from([ + "gsv", + "user", + "permissions", + "carol", + "--grant", + "user.admin", + "--grant", + "fs.*", + "--revoke", + "shell.*", + "--add-group", + "operators", + "--add-group", + "reviewers", + "--remove-group", + "users", + ]) + .expect("user permissions should parse"); + + let Commands::User { + action: + UserAction::Permissions { + username, + grant, + revoke, + add_groups, + remove_groups, + }, + } = cli.command + else { + panic!("expected user permissions"); + }; + assert_eq!(username, "carol"); + assert_eq!(grant, ["user.admin", "fs.*"]); + assert_eq!(revoke, ["shell.*"]); + assert_eq!(add_groups, ["operators", "reviewers"]); + assert_eq!(remove_groups, ["users"]); + } + + #[test] + fn user_create_requires_a_username() { + let result = Cli::try_parse_from(["gsv", "user", "create"]); + assert!(result.is_err(), "missing username should be rejected"); + let error = result.err().expect("parse error should be present"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index e6413884c..93f92019f 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -5,6 +5,7 @@ mod config; mod infra; mod packages; mod proc; +mod user; pub(crate) use adapter::run_adapter; pub(crate) use auth::run_auth; @@ -13,6 +14,7 @@ pub(crate) use config::run_config; pub(crate) use infra::run_infra; pub(crate) use packages::run_packages; pub(crate) use proc::run_proc; +pub(crate) use user::{resolve_new_user_password, run_user}; use chrono::{TimeZone, Utc}; diff --git a/cli/src/commands/user.rs b/cli/src/commands/user.rs new file mode 100644 index 000000000..2c18c2d75 --- /dev/null +++ b/cli/src/commands/user.rs @@ -0,0 +1,275 @@ +use gsv::kernel_client::{GatewayAuth, KernelClient}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::auth_flow::{can_prompt_interactively, prompt_secret}; +use crate::cli::UserAction; + +pub(crate) fn resolve_new_user_password( + password: Option, +) -> Result> { + let mut password = password.filter(|value| !value.is_empty()); + if password.is_none() && can_prompt_interactively() { + password = prompt_secret("New user password (min 8 chars)")?; + } + + password.ok_or_else(|| { + "New user password required (pass --new-password or run interactively)".into() + }) +} + +pub(crate) async fn run_user( + url: &str, + auth: GatewayAuth, + action: UserAction, +) -> Result<(), Box> { + let client = KernelClient::connect_user(url, auth, |_| {}).await?; + + let payload = match action { + UserAction::Create { + username, + new_password, + } => { + let password = new_password.ok_or("New user password is required")?; + client + .request_ok( + "user.admin", + Some(json!({ + "action": "create", + "username": username, + "password": password, + })), + ) + .await? + } + UserAction::Permissions { + username, + grant, + revoke, + add_groups, + remove_groups, + } => { + client + .request_ok( + "user.admin", + Some(json!({ + "action": "permissions", + "username": username, + "grant": grant, + "revoke": revoke, + "addGroups": add_groups, + "removeGroups": remove_groups, + })), + ) + .await? + } + UserAction::Register { .. } => { + return Err("user register is handled directly by the CLI entrypoint".into()); + } + }; + + print_user_admin_response(payload)?; + Ok(()) +} + +fn print_user_admin_response(payload: Value) -> Result<(), Box> { + if let Ok(response) = serde_json::from_value::(payload.clone()) { + println!("{}", format_user_create(&response)); + } else if let Ok(response) = serde_json::from_value::(payload.clone()) + { + println!("{}", format_user_permissions(&response)); + } else { + println!("{}", serde_json::to_string_pretty(&payload)?); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UserCreateResponse { + account: AccountSummary, + personal_agent: AccountSummary, +} + +#[derive(Debug, Deserialize)] +struct AccountSummary { + uid: u32, + gid: u32, + username: String, + home: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UserPermissionsResponse { + user: UserSummary, + groups: Vec, + direct_capabilities: Vec, + effective_capabilities: Vec, + changed: bool, +} + +#[derive(Debug, Deserialize)] +struct UserSummary { + username: String, + uid: u32, + gid: u32, +} + +#[derive(Debug, Deserialize)] +struct GroupSummary { + name: String, + gid: u32, + primary: bool, +} + +fn format_user_create(response: &UserCreateResponse) -> String { + format!( + "Created human account {} (uid {}, gid {}).\nHome: {}\nPersonal agent: {} (uid {}, gid {})", + response.account.username, + response.account.uid, + response.account.gid, + response.account.home, + response.personal_agent.username, + response.personal_agent.uid, + response.personal_agent.gid, + ) +} + +fn format_user_permissions(response: &UserPermissionsResponse) -> String { + let groups = if response.groups.is_empty() { + "(none)".to_string() + } else { + response + .groups + .iter() + .map(|group| { + if group.primary { + format!("{} ({}, primary)", group.name, group.gid) + } else { + format!("{} ({})", group.name, group.gid) + } + }) + .collect::>() + .join(", ") + }; + let direct = format_capabilities(&response.direct_capabilities); + let effective = format_capabilities(&response.effective_capabilities); + let outcome = if response.changed { + "Permissions updated." + } else { + "Permissions unchanged." + }; + + format!( + "User: {} (uid {}, gid {})\nGroups: {}\nDirect capabilities:\n{}\nEffective capabilities:\n{}\n{}", + response.user.username, + response.user.uid, + response.user.gid, + groups, + direct, + effective, + outcome, + ) +} + +fn format_capabilities(capabilities: &[String]) -> String { + if capabilities.is_empty() { + return " (none)".to_string(); + } + + capabilities + .iter() + .map(|capability| format!(" {}", capability)) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_an_explicit_password_verbatim() { + assert_eq!( + resolve_new_user_password(Some(" correct horse ".to_string())).unwrap(), + " correct horse ", + ); + } + + #[test] + fn parses_and_formats_create_response() { + let response = serde_json::from_value::(json!({ + "action": "create", + "account": { + "uid": 1000, + "gid": 1000, + "gids": [1000, 100], + "username": "alice", + "home": "/home/alice", + "cwd": "/home/alice" + }, + "personalAgent": { + "uid": 1001, + "gid": 1001, + "gids": [1001, 1000], + "username": "alice-agent", + "home": "/home/alice-agent", + "cwd": "/home/alice-agent" + } + })) + .expect("create response should parse"); + + assert_eq!( + format_user_create(&response), + "Created human account alice (uid 1000, gid 1000).\n\ + Home: /home/alice\n\ + Personal agent: alice-agent (uid 1001, gid 1001)" + ); + } + + #[test] + fn parses_and_formats_permissions_response() { + let response = serde_json::from_value::(json!({ + "action": "permissions", + "user": { "username": "alice", "uid": 1000, "gid": 1000 }, + "groups": [ + { "name": "alice", "gid": 1000, "primary": true }, + { "name": "operators", "gid": 1200, "primary": false } + ], + "directCapabilities": ["user.admin"], + "effectiveCapabilities": ["fs.*", "user.admin"], + "changed": true + })) + .expect("permissions response should parse"); + + assert_eq!( + format_user_permissions(&response), + "User: alice (uid 1000, gid 1000)\n\ + Groups: alice (1000, primary), operators (1200)\n\ + Direct capabilities:\n user.admin\n\ + Effective capabilities:\n fs.*\n user.admin\n\ + Permissions updated." + ); + } + + #[test] + fn formats_empty_permission_sets() { + let response = UserPermissionsResponse { + user: UserSummary { + username: "bob".to_string(), + uid: 1002, + gid: 1002, + }, + groups: Vec::new(), + direct_capabilities: Vec::new(), + effective_capabilities: Vec::new(), + changed: false, + }; + + let output = format_user_permissions(&response); + assert!(output.contains("Groups: (none)")); + assert_eq!(output.matches(" (none)").count(), 2); + assert!(output.ends_with("Permissions unchanged.")); + } +} diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 97c40acac..42f62200e 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -248,6 +248,32 @@ gsv auth token revoke TOKEN_ID [--reason TEXT] [--uid UID] `device` is the default token kind. Use `--device` to bind a driver token to one device ID. `--uid` is for root-managed token operations. +## User Management + +```bash +gsv user create USER [--new-password PASS] +gsv user register USER [--new-password PASS] [--ttl-hours N] +gsv user permissions USER \ + [--grant CAPABILITY] [--revoke CAPABILITY] \ + [--add-group GROUP] [--remove-group GROUP] +``` + +`create` adds a login-capable human account and its personal agent. `register` +does the same, then logs in as the new user and replaces the locally cached +session; its default session lifetime is 8 hours. When `--new-password` is +omitted, both commands prompt without echo in an interactive terminal and fail +in non-interactive use. The global `--password` option authenticates the current +administrator; it is not the new account's password. + +`permissions` without change options displays the user's primary and +supplementary groups, direct capabilities, and effective capabilities. Repeat +`--grant`, `--revoke`, `--add-group`, or `--remove-group` to apply multiple +changes in one request. + +The gateway authorizes these operations before changing account, capability, or +group state. The current user must be uid 0 or have `user.admin` granted +directly on their primary group; other users receive `Permission denied`. + ## Config Commands ```bash diff --git a/docs/reference/syscalls.md b/docs/reference/syscalls.md index 68c1de133..22990c609 100644 --- a/docs/reference/syscalls.md +++ b/docs/reference/syscalls.md @@ -1164,6 +1164,76 @@ type SystemSyscalls = { }; ``` +## User administration: `user.admin` + +`user.admin` is the authenticated, Kernel-owned account-administration +boundary. The Kernel reconstructs the caller from current account state and +checks administrative authority before validating or applying any requested +mutation. uid 0 is root. A non-root caller must have the exact `user.admin` +capability granted directly to its current primary gid; an effective grant +inherited from a supplementary group does not authorize administration. This +keeps personal agents and other cross-members from inheriting a human's +delegated administrative authority. + +Runtime behavior: + +| Syscall | Handler | Behavior | +|---|---|---| +| `user.admin` | `handleUserAdmin` | With `action: "create"`, creates a login-capable human using the shared account-provisioning path, including its personal agent. With `action: "permissions"`, returns the human's current group and capability state and optionally updates direct capabilities or supplementary group memberships. Omitting all four mutation arrays performs a read only. Invalid or unauthorized requests fail before account, group, or capability state is changed. | + +The permissions result separates capabilities granted directly on the target +account's primary gid from the effective capabilities resolved across all of +its current groups. `grant` and `revoke` modify only that direct primary-gid +set. `addGroups` and `removeGroups` modify supplementary membership, which can +change the effective set without changing `directCapabilities`. `changed` is +true only when the request changed stored capability or membership state. + +Root permission state may be viewed, but any requested capability or group +mutation targeting uid 0 is denied. Adding or removing membership in the root +group (gid 0), or in any target's immutable primary group, is also denied. +`*` cannot be granted to a non-root account; an already-corrupt direct `*` +grant on a non-root primary gid may be revoked. These checks are performed +before any mutation in the request, and the complete permission patch commits +in one Kernel SQLite transaction. + +After a successful change, the Kernel re-resolves every live human connection +and process identity so a capability inherited through another user's group +cannot remain cached. Process-originated requests and scheduled work also +revalidate current delegated run-as membership before executing. + +```ts +type UserAdminArgs = + | { + action: "create"; + username: string; + password: string; + gecos?: string; + } + | { + action: "permissions"; + username: string; + grant?: string[]; + revoke?: string[]; + addGroups?: string[]; + removeGroups?: string[]; + }; + +type UserAdminResult = + | { + action: "create"; + account: ProcessIdentity; + personalAgent: ProcessIdentity; + } + | { + action: "permissions"; + user: { username: string; uid: number; gid: number }; + groups: Array<{ name: string; gid: number; primary: boolean }>; + directCapabilities: string[]; + effectiveCapabilities: string[]; + changed: boolean; + }; +``` + ## AI: `ai.*` `ai.tools` and `ai.config` are internal Process bootstrap calls. The media diff --git a/gateway/src/kernel/accounts.test.ts b/gateway/src/kernel/accounts.test.ts index c8ab4e0fa..ad71f76c7 100644 --- a/gateway/src/kernel/accounts.test.ts +++ b/gateway/src/kernel/accounts.test.ts @@ -107,7 +107,12 @@ function createCtx() { function ctxFor(identity: ConnectionIdentity, options: { ripgit?: boolean } = {}): KernelContext { return { auth: auth as unknown as KernelContext["auth"], - caps: { resolve: vi.fn(() => []) } as unknown as KernelContext["caps"], + caps: { + list: vi.fn((gid?: number) => gid === identity.process.gid + ? identity.capabilities.map((capability) => ({ gid, capability })) + : []), + resolve: vi.fn(() => []), + } as unknown as KernelContext["caps"], env: { STORAGE: storage, ...(options.ripgit ? { RIPGIT: ripgit } : {}), @@ -291,13 +296,16 @@ describe("handleAccountCreate", () => { expect(result.account.username).toBe("scout"); }); - it("requires root to create a human account", async () => { - const { ctxFor } = createCtx(); + it("denies human creation before mutating state without user.admin", async () => { + const { ctxFor, auth } = createCtx(); const ctx = ctxFor(userIdentity(1000, "alice", ["account.create"])); await expect( handleAccountCreate({ kind: "human", username: "bob", password: "password-123" }, ctx), - ).rejects.toThrow(/root/i); + ).rejects.toThrow("Permission denied"); + expect(auth.nextUid).not.toHaveBeenCalled(); + expect(auth.addUser).not.toHaveBeenCalled(); + expect(auth.setShadow).not.toHaveBeenCalled(); }); it("does not treat a non-root wildcard capability as root authority", async () => { @@ -306,7 +314,21 @@ describe("handleAccountCreate", () => { await expect( handleAccountCreate({ kind: "human", username: "bob", password: "password-123" }, ctx), - ).rejects.toThrow(/root/i); + ).rejects.toThrow("Permission denied"); + }); + + it("allows a directly delegated user administrator to create a human", async () => { + const { ctxFor, shadow, groups } = createCtx(); + const ctx = ctxFor(userIdentity(1000, "alice", ["user.admin"])); + + const result = await handleAccountCreate( + { kind: "human", username: "bob", password: "password-123" }, + ctx, + ); + + expect(result.account.username).toBe("bob"); + expect(shadow.get("bob")).not.toBe("!"); + expect(groups.find((group) => group.name === "users")?.members).toContain("bob"); }); it("rejects a weak human password without mutating auth state", async () => { diff --git a/gateway/src/kernel/agents.ts b/gateway/src/kernel/agents.ts index 035682ce0..2d839dc7b 100644 --- a/gateway/src/kernel/agents.ts +++ b/gateway/src/kernel/agents.ts @@ -41,6 +41,7 @@ import { import { canOwnerRunAsAccount } from "./account-access"; import { ensureAccountHomeLayout } from "./account-home"; import { DEFAULT_PERSONA_CONTEXT_TEMPLATE } from "../prompts/persona"; +import { requireUserAdmin } from "./user-authority"; /** * Curated, tasteful default names for the personal agent. The first available @@ -208,8 +209,8 @@ export async function ensurePersonalAgent( } /** - * Create an account on behalf of an authenticated caller. Humans are an - * administrative action (root only); agents are owned by the caller's human. + * Create an account on behalf of an authenticated caller. Humans require + * user-administration authority; agents are owned by the caller's human. */ export async function handleAccountCreate( args: AccountCreateArgs, @@ -222,27 +223,16 @@ export async function handleAccountCreate( } const kind: AccountKind = args.kind === "human" ? "human" : "agent"; + if (kind === "human") { + requireUserAdmin(ctx); + return createHumanAccount(args, ctx); + } + const name = normalizeAccountName(auth, args.username); if (!name) { throw new Error(`Invalid or unavailable username: ${String(args.username)}`); } - if (kind === "human") { - // Creating human accounts is an administrative action. - if (caller.process.uid !== 0) { - throw new Error("Creating human accounts requires root"); - } - const { identity } = await createAccount(ctx, { - kind: "human", - username: name, - password: args.password, - gecos: args.gecos?.trim() || undefined, - shared: true, - }); - const agent = await ensurePersonalAgent(ctx, identity); - return { account: identity, kind, personalAgent: agent.identity }; - } - const ownerUid = resolveCallerOwnerUid(ctx); const ownerName = auth.getPasswdByUid(ownerUid)?.username ?? "user"; const contextFiles = normalizeAccountContextFiles(args.contextFiles); @@ -267,6 +257,27 @@ export async function handleAccountCreate( return { account: identity, kind }; } +/** Create and fully provision a login-capable human after caller authorization. */ +export async function createHumanAccount( + args: Pick, + ctx: KernelContext, +): Promise { + const name = normalizeAccountName(ctx.auth, args.username); + if (!name) { + throw new Error(`Invalid or unavailable username: ${String(args.username)}`); + } + + const { identity } = await createAccount(ctx, { + kind: "human", + username: name, + password: args.password, + gecos: args.gecos?.trim() || undefined, + shared: true, + }); + const agent = await ensurePersonalAgent(ctx, identity); + return { account: identity, kind: "human", personalAgent: agent.identity }; +} + /** * List the accounts the owning human may run processes as: their own account, * their personal agent, and any account whose private group they belong to diff --git a/gateway/src/kernel/context.ts b/gateway/src/kernel/context.ts index 7df882076..f2be3bb1d 100644 --- a/gateway/src/kernel/context.ts +++ b/gateway/src/kernel/context.ts @@ -60,6 +60,7 @@ export type KernelContext = { callerOwnerUid?: number; appFrame?: AppFrameContext; serverVersion: string; + transactionSync: (closure: () => T) => T; broadcastToUserUid: (uid: number, signal: string, payload?: unknown) => void; getAppRunner: (uid: number, packageId: string) => unknown; scheduleIpcCallTimeout: (callId: string, deadlineAt: number) => Promise; diff --git a/gateway/src/kernel/dispatch.ts b/gateway/src/kernel/dispatch.ts index 2fb6c299f..ea7477c32 100644 --- a/gateway/src/kernel/dispatch.ts +++ b/gateway/src/kernel/dispatch.ts @@ -50,6 +50,7 @@ import { forwardToProcess, } from "./proc-handlers"; import { handleAccountCreate, handleAccountList } from "./agents"; +import { handleUserAdmin } from "./user-admin"; import { handleSysConfigGet, handleSysConfigSet } from "./sys/config"; import { handleSysDeviceDelete, handleSysDeviceGet, handleSysDeviceList, handleSysDeviceUpdate } from "./sys/device"; import { handleNetFetch, normalizeNetFetchTimeoutMs } from "./net"; @@ -594,6 +595,11 @@ async function dispatchNative( data = handleAccountList(frame.args, ctx); break; + // --- user.* --- + case "user.admin": + data = await handleUserAdmin(frame.args, ctx); + break; + // --- sched.* --- case "sched.list": data = handleSchedulerList(frame.args, ctx); diff --git a/gateway/src/kernel/do.test.ts b/gateway/src/kernel/do.test.ts index 9ce00d1e2..22be93b9d 100644 --- a/gateway/src/kernel/do.test.ts +++ b/gateway/src/kernel/do.test.ts @@ -969,6 +969,141 @@ describe("Kernel connection rehydration", () => { }); expect(kernel.connections.get(connection.id)).toBe(connection); }); + + it("refreshes live authority after a successful user permission change", () => { + const makeConnection = (uid: number, username: string): any => { + const current: any = { + id: `${username}-connection`, + state: { + step: "connected", + identity: { + role: "user", + process: { + uid, + gid: uid, + gids: [uid, 1000], + username, + home: `/home/${username}`, + cwd: "/work", + }, + capabilities: ["user.admin", "net.fetch"], + }, + }, + setState: vi.fn((state) => { + current.state = state; + }), + close: vi.fn(), + }; + return current; + }; + const connection = makeConnection(1000, "alice"); + const crossMember = makeConnection(1001, "bob"); + const accounts = new Map([ + [1000, { uid: 1000, gid: 1000, username: "alice", home: "/home/alice" }], + [1001, { uid: 1001, gid: 1001, username: "bob", home: "/home/bob" }], + ]); + const processes = [{ processId: "proc-as-alice", uid: 1000, ownerUid: 1001 }]; + const kernel = Object.create(Kernel.prototype) as any; + kernel.connections = new Map([ + [connection.id, connection], + [crossMember.id, crossMember], + ]); + kernel.auth = { + getPasswdByUid: vi.fn((uid: number) => accounts.get(uid) ?? null), + resolveGids: vi.fn((username: string) => username === "alice" + ? [1000, 101] + : [1001, 1000]), + }; + kernel.caps = { resolve: vi.fn(() => ["fs.read"]) }; + kernel.procs = { list: vi.fn(() => processes) }; + kernel.reconcileProcessIdentities = vi.fn(); + + kernel.applyPostDispatchEffects( + { call: "user.admin", args: {} }, + { + ok: true, + data: { + action: "permissions", + user: { username: "alice", uid: 1000, gid: 1000 }, + groups: [], + directCapabilities: [], + effectiveCapabilities: ["fs.read"], + changed: true, + }, + }, + ); + + expect(connection.state.identity).toMatchObject({ + process: { + gid: 1000, + gids: [1000, 101], + home: "/home/alice", + cwd: "/work", + }, + capabilities: ["fs.read"], + }); + expect(crossMember.state.identity).toMatchObject({ + process: { gids: [1001, 1000] }, + capabilities: ["fs.read"], + }); + expect(kernel.procs.list).toHaveBeenCalledWith(); + expect(kernel.reconcileProcessIdentities).toHaveBeenCalledWith(processes); + }); + + it("rejects a process after its owner loses delegated run-as access", () => { + const bobGroup = { name: "bob", gid: 1001, members: ["alice"] }; + const accounts = new Map([ + [1000, { uid: 1000, gid: 1000, username: "alice", home: "/home/alice" }], + [1001, { uid: 1001, gid: 1001, username: "bob", home: "/home/bob" }], + ]); + const kernel = Object.create(Kernel.prototype) as any; + kernel.procs = { + get: vi.fn(() => ({ + processId: "proc-bob", + uid: 1001, + ownerUid: 1000, + username: "bob", + cwd: "/home/bob", + })), + }; + kernel.auth = { + getPasswdByUid: vi.fn((uid: number) => accounts.get(uid) ?? null), + getPersonalAgentUid: vi.fn(() => null), + getGroupByGid: vi.fn(() => bobGroup), + getGroupByName: vi.fn(() => null), + resolveGids: vi.fn(() => [1001]), + }; + kernel.caps = { resolve: vi.fn(() => ["fs.read"]) }; + kernel.buildKernelContext = vi.fn((options) => options); + + expect(kernel.buildProcessContext("proc-bob")).not.toBeNull(); + bobGroup.members = []; + expect(kernel.buildProcessContext("proc-bob")).toBeNull(); + }); + + it("rejects a schedule after its owner loses delegated run-as access", () => { + const bobGroup = { name: "bob", gid: 1001, members: ["alice"] }; + const accounts = new Map([ + [1000, { uid: 1000, gid: 1000, username: "alice", home: "/home/alice" }], + [1001, { uid: 1001, gid: 1001, username: "bob", home: "/home/bob" }], + ]); + const kernel = Object.create(Kernel.prototype) as any; + kernel.auth = { + getPasswdByUid: vi.fn((uid: number) => accounts.get(uid) ?? null), + getPersonalAgentUid: vi.fn(() => null), + getGroupByGid: vi.fn(() => bobGroup), + getGroupByName: vi.fn(() => null), + resolveGids: vi.fn(() => [1001]), + }; + const schedule = { + ownerUid: 1000, + runAs: { kind: "user", uid: 1001, username: "bob" }, + }; + + expect(kernel.resolveScheduleIdentity(schedule)).toMatchObject({ uid: 1001 }); + bobGroup.members = []; + expect(() => kernel.resolveScheduleIdentity(schedule)).toThrow("Permission denied"); + }); }); describe("Kernel user signal broadcasts", () => { @@ -2059,9 +2194,12 @@ describe("Kernel process device requests", () => { })); const kernel = Object.create(Kernel.prototype) as { env: Record; - procs: { getIdentity: ReturnType }; + procs: { get: ReturnType }; caps: { resolve: ReturnType }; - auth: { getPasswdByUid: ReturnType }; + auth: { + getPasswdByUid: ReturnType; + resolveGids: ReturnType; + }; devices: { canAccess: ReturnType; get: ReturnType; @@ -2090,8 +2228,10 @@ describe("Kernel process device requests", () => { ): Promise; }; kernel.env = {}; - kernel.procs = { getIdentity: vi.fn(() => ({ + kernel.procs = { get: vi.fn(() => ({ + processId: "proc_1", uid: 0, + ownerUid: 0, gid: 0, gids: [0], username: "root", @@ -2099,7 +2239,15 @@ describe("Kernel process device requests", () => { cwd: "/root", })) }; kernel.caps = { resolve: vi.fn(() => options.capabilities ?? ["net.fetch"]) }; - kernel.auth = { getPasswdByUid: vi.fn(() => null) }; + kernel.auth = { + getPasswdByUid: vi.fn(() => ({ + uid: 0, + gid: 0, + username: "root", + home: "/root", + })), + resolveGids: vi.fn(() => [0]), + }; kernel.devices = { canAccess: vi.fn(() => true), get: vi.fn(() => device), @@ -2122,7 +2270,7 @@ describe("Kernel process device requests", () => { ); expect(result).toMatchObject({ ok: true, data: { status: 204 } }); - expect(kernel.procs.getIdentity).toHaveBeenCalledWith("proc_1"); + expect(kernel.procs.get).toHaveBeenCalledWith("proc_1"); expect(kernel.devices.canAccess).toHaveBeenCalledWith("linux-machine", 0, [0]); expect(requestDevice).toHaveBeenCalledWith( "linux-machine", diff --git a/gateway/src/kernel/do.ts b/gateway/src/kernel/do.ts index ec61c398e..82da7aef5 100644 --- a/gateway/src/kernel/do.ts +++ b/gateway/src/kernel/do.ts @@ -51,7 +51,7 @@ import { type RouteOrigin, } from "./routing"; import { ShellSessionStore, type ShellSessionStatus } from "./shell-sessions"; -import { ProcessRegistry, type ProcessState } from "./processes"; +import { ProcessRegistry, type ProcessRecord, type ProcessState } from "./processes"; import { ConversationRegistry } from "./conversations"; import { AdapterStore } from "./adapter-store"; import { RunRouteStore, type AdapterRunRoute, type RunRoute } from "./run-routes"; @@ -129,6 +129,7 @@ import { isRepoPublic } from "./repo-visibility"; import { canReadRepo, canWriteRepo } from "./repo"; import { handleProcSpawn } from "./proc-handlers"; import { ensureDefaultConversationExecutor } from "./agents"; +import { canOwnerDelegateRunAs } from "./account-access"; import { handleShellExec } from "../drivers/native/shell"; import { getVisibleTarget } from "./targets"; import { runKernelSqlMigrations } from "./schema/migrations"; @@ -1693,11 +1694,32 @@ export class Kernel extends Host { } private buildProcessContext(processId: string, processRunId?: string): KernelContext | null { - const identity = this.procs.getIdentity(processId); - if (!identity) { + const proc = this.procs.get(processId); + if (!proc) { + return null; + } + + const account = this.auth.getPasswdByUid(proc.uid); + if (!account || account.username !== proc.username) { + return null; + } + if ( + proc.ownerUid !== 0 && + proc.ownerUid !== account.uid && + !canOwnerDelegateRunAs(this.auth, proc.ownerUid, account) + ) { return null; } + const identity: ProcessIdentity = { + uid: account.uid, + gid: account.gid, + gids: this.auth.resolveGids(account.username, account.gid), + username: account.username, + home: account.home, + cwd: proc.cwd, + }; + const connIdentity: ConnectionIdentity = { role: "user", process: identity, @@ -1786,6 +1808,7 @@ export class Kernel extends Host { callerOwnerUid: options.callerOwnerUid, appFrame: options.appFrame, serverVersion: SERVER_VERSION, + transactionSync: (closure) => this.ctx.storage.transactionSync(closure), broadcastToUserUid: this.broadcastToUserUid.bind(this), getAppRunner: this.getAppRunner.bind(this), scheduleIpcCallTimeout: this.scheduleIpcCallTimeout.bind(this), @@ -2465,6 +2488,21 @@ export class Kernel extends Host { private applyPostDispatchEffects(frame: RequestFrame, response: ResponseFrame): void { if (!response.ok) return; + if (frame.call === "user.admin") { + const data = (response as { + data?: { + action?: unknown; + changed?: unknown; + }; + }).data; + if ( + data?.action === "permissions" && + data.changed === true + ) { + this.refreshUserAuthority(); + } + } + if (frame.call === "sys.device.delete") { const data = (response as { data?: { @@ -3275,6 +3313,15 @@ export class Kernel extends Host { if (!account) { throw new Error(`Cannot resolve schedule run-as uid ${uid}`); } + if ( + record.ownerUid !== 0 && + record.ownerUid !== account.uid && + !canOwnerDelegateRunAs(this.auth, record.ownerUid, account) + ) { + throw new Error( + `Permission denied: schedule owner ${record.ownerUid} cannot run as ${account.username}`, + ); + } return { uid: account.uid, @@ -3400,7 +3447,11 @@ export class Kernel extends Host { * refreshed, and identity.changed is emitted when it changes. */ private reconcileOwnedIdentities(ownerUid: number): void { - for (const proc of this.procs.list(ownerUid)) { + this.reconcileProcessIdentities(this.procs.list(ownerUid)); + } + + private reconcileProcessIdentities(processes: ProcessRecord[]): void { + for (const proc of processes) { const entry = this.auth.getPasswdByUsername(proc.username); if (!entry) continue; @@ -3434,6 +3485,48 @@ export class Kernel extends Host { } } + /** Make capability and group edits authoritative for every affected principal. */ + private refreshUserAuthority(): void { + for (const connection of this.connections.values()) { + const state = connection.state as ConnectionState | undefined; + if ( + !state || + state.step !== "connected" || + !state.identity || + state.identity.role !== "user" + ) { + continue; + } + const account = this.auth.getPasswdByUid(state.identity.process.uid); + if (!account) { + connection.close(1008, "Account unavailable"); + continue; + } + if (state.identity.process.username !== account.username) { + connection.close(1008, "Account unavailable"); + continue; + } + + const gids = this.auth.resolveGids(account.username, account.gid); + const process = { + ...state.identity.process, + gid: account.gid, + gids, + home: account.home, + }; + connection.setState({ + ...state, + identity: { + ...state.identity, + process, + capabilities: resolveConnectionCapabilities(state.identity.role, process, this.caps), + } as ConnectionIdentity, + }); + } + + this.reconcileProcessIdentities(this.procs.list()); + } + /** * Broadcast a signal to active user WebSockets belonging to a UID. */ diff --git a/gateway/src/kernel/scheduler.test.ts b/gateway/src/kernel/scheduler.test.ts index c4e502302..5c8fd51dc 100644 --- a/gateway/src/kernel/scheduler.test.ts +++ b/gateway/src/kernel/scheduler.test.ts @@ -59,6 +59,7 @@ function addTestAccount( auth: ScheduleTestAuth, identity: ProcessIdentity, gecos: string, + members: string[] = [], ): void { auth.addUser({ username: identity.username, @@ -68,7 +69,7 @@ function addTestAccount( home: identity.home, shell: "/bin/init", }); - auth.addGroup({ name: identity.username, gid: identity.gid, members: [] }); + auth.addGroup({ name: identity.username, gid: identity.gid, members }); } function addTestUser(auth: ScheduleTestAuth): void { @@ -1312,9 +1313,15 @@ describe("scheduler", () => { ctx: DurableObjectState; }; k.caps.seed(); + addTestUser(k.auth); addTestAccount(k.auth, PERSONAL_AGENT_IDENTITY, "Sam Agent"); k.auth.setPersonalAgent(USER_IDENTITY.uid, PERSONAL_AGENT_IDENTITY.uid); - addTestAccount(k.auth, CUSTOM_AGENT_IDENTITY, "Wiki Builder"); + addTestAccount( + k.auth, + CUSTOM_AGENT_IDENTITY, + "Wiki Builder", + [USER_IDENTITY.username], + ); k.caps.grant(CUSTOM_AGENT_IDENTITY.gid, "shell.exec"); const now = Date.now(); @@ -2033,7 +2040,13 @@ describe("scheduler", () => { schedules: ScheduleStore; ctx: DurableObjectState; }; - addTestAccount(k.auth, CUSTOM_AGENT_IDENTITY, "Wiki Builder"); + addTestUser(k.auth); + addTestAccount( + k.auth, + CUSTOM_AGENT_IDENTITY, + "Wiki Builder", + [USER_IDENTITY.username], + ); k.caps.grant(CUSTOM_AGENT_IDENTITY.gid, "proc.spawn"); const now = Date.now(); diff --git a/gateway/src/kernel/user-admin.test.ts b/gateway/src/kernel/user-admin.test.ts new file mode 100644 index 000000000..6a956f558 --- /dev/null +++ b/gateway/src/kernel/user-admin.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionIdentity, ProcessIdentity } from "@humansandmachines/gsv/protocol"; +import type { KernelContext } from "./context"; +import { handleUserAdmin } from "./user-admin"; + +type PasswdRow = { + username: string; + uid: number; + gid: number; + gecos: string; + home: string; + shell: string; +}; +type GroupRow = { name: string; gid: number; members: string[] }; +type CapabilityRow = { gid: number; capability: string }; + +function createCtx() { + const passwd: PasswdRow[] = [ + { username: "root", uid: 0, gid: 0, gecos: "root", home: "/root", shell: "/bin/init" }, + { username: "alice", uid: 1000, gid: 1000, gecos: "Alice", home: "/home/alice", shell: "/bin/init" }, + { username: "bob", uid: 1001, gid: 1001, gecos: "Bob", home: "/home/bob", shell: "/bin/init" }, + { username: "friday", uid: 2000, gid: 2000, gecos: "Friday", home: "/home/friday", shell: "/bin/init" }, + ]; + const groups: GroupRow[] = [ + { name: "root", gid: 0, members: ["root"] }, + { name: "users", gid: 100, members: ["alice", "bob", "friday"] }, + { name: "drivers", gid: 101, members: [] }, + { name: "alice", gid: 1000, members: ["friday"] }, + { name: "bob", gid: 1001, members: [] }, + { name: "friday", gid: 2000, members: ["alice"] }, + ]; + const capabilities: CapabilityRow[] = [ + { gid: 0, capability: "*" }, + { gid: 100, capability: "fs.read" }, + { gid: 101, capability: "fs.*" }, + { gid: 1000, capability: "user.admin" }, + { gid: 1001, capability: "shell.exec" }, + ]; + const shadow = new Map([ + ["root", "root-hash"], + ["alice", "alice-hash"], + ["bob", "bob-hash"], + ["friday", "!"], + ]); + + const resolveGids = (username: string, primaryGid: number): number[] => { + const gids = new Set([primaryGid]); + for (const group of groups) { + if (group.members.includes(username)) gids.add(group.gid); + } + return [...gids].sort((a, b) => a - b); + }; + + const auth = { + getPasswdByUid: vi.fn((uid: number) => passwd.find((entry) => entry.uid === uid) ?? null), + getPasswdByUsername: vi.fn( + (username: string) => passwd.find((entry) => entry.username === username) ?? null, + ), + getGroupByName: vi.fn( + (name: string) => groups.find((entry) => entry.name === name) ?? null, + ), + getShadowByUsername: vi.fn((username: string) => { + const hash = shadow.get(username); + return hash === undefined ? null : { username, hash }; + }), + getGroupEntries: vi.fn(() => groups.map((group) => ({ + ...group, + members: [...group.members], + }))), + updateGroupMembers: vi.fn((name: string, members: string[]) => { + const group = groups.find((entry) => entry.name === name); + if (!group) return false; + group.members = [...members]; + return true; + }), + resolveGids: vi.fn(resolveGids), + nextUid: vi.fn(), + addUser: vi.fn(), + setShadow: vi.fn(), + }; + const caps = { + list: vi.fn((gid?: number) => capabilities + .filter((entry) => gid === undefined || entry.gid === gid) + .map((entry) => ({ ...entry }))), + resolve: vi.fn((gids: number[]) => [...new Set( + capabilities + .filter((entry) => gids.includes(entry.gid)) + .map((entry) => entry.capability), + )]), + grant: vi.fn((gid: number, capability: string) => { + if (capability === "*" && gid !== 0) { + return { ok: false, error: "The unrestricted capability is reserved for root" }; + } + if (!capabilities.some((entry) => entry.gid === gid && entry.capability === capability)) { + capabilities.push({ gid, capability }); + } + return { ok: true }; + }), + revoke: vi.fn((gid: number, capability: string) => { + const index = capabilities.findIndex( + (entry) => entry.gid === gid && entry.capability === capability, + ); + if (index >= 0) capabilities.splice(index, 1); + return { ok: true }; + }), + }; + + function transactionSync(closure: () => T): T { + const groupSnapshot = groups.map((group) => ({ ...group, members: [...group.members] })); + const capabilitySnapshot = capabilities.map((entry) => ({ ...entry })); + try { + return closure(); + } catch (error) { + groups.splice(0, groups.length, ...groupSnapshot); + capabilities.splice(0, capabilities.length, ...capabilitySnapshot); + throw error; + } + } + + function ctxFor(username: string, advertisedCapabilities?: string[]): KernelContext { + const account = passwd.find((entry) => entry.username === username)!; + const gids = resolveGids(account.username, account.gid); + const process: ProcessIdentity = { + uid: account.uid, + gid: account.gid, + gids, + username: account.username, + home: account.home, + cwd: account.home, + }; + const identity: ConnectionIdentity = { + role: "user", + process, + capabilities: advertisedCapabilities ?? caps.resolve(gids), + }; + return { + identity, + auth: auth as unknown as KernelContext["auth"], + caps: caps as unknown as KernelContext["caps"], + transactionSync, + } as KernelContext; + } + + return { ctxFor, auth, caps, groups, capabilities }; +} + +describe("handleUserAdmin", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows direct grants separately from effective group capabilities", async () => { + const { ctxFor } = createCtx(); + + const result = await handleUserAdmin( + { action: "permissions", username: "bob" }, + ctxFor("alice"), + ); + + expect(result).toEqual({ + action: "permissions", + user: { username: "bob", uid: 1001, gid: 1001 }, + groups: [ + { name: "bob", gid: 1001, primary: true }, + { name: "users", gid: 100, primary: false }, + ], + directCapabilities: ["shell.exec"], + effectiveCapabilities: ["fs.read", "shell.exec"], + changed: false, + }); + }); + + it("applies one prevalidated capability and membership patch", async () => { + const { ctxFor, groups } = createCtx(); + + const result = await handleUserAdmin({ + action: "permissions", + username: "bob", + grant: ["net.fetch"], + revoke: ["shell.exec"], + addGroups: ["drivers"], + removeGroups: ["users"], + }, ctxFor("alice")); + + expect(result.action).toBe("permissions"); + if (result.action !== "permissions") throw new Error("unexpected result"); + expect(result.changed).toBe(true); + expect(result.directCapabilities).toEqual(["net.fetch"]); + expect(result.effectiveCapabilities).toEqual(["fs.*", "net.fetch"]); + expect(groups.find((group) => group.name === "users")?.members).not.toContain("bob"); + expect(groups.find((group) => group.name === "drivers")?.members).toContain("bob"); + }); + + it("denies a regular user before any mutation", async () => { + const { ctxFor, auth, caps } = createCtx(); + + await expect(handleUserAdmin({ + action: "permissions", + username: "alice", + grant: ["net.fetch"], + addGroups: ["drivers"], + }, ctxFor("bob"))).rejects.toThrow("Permission denied"); + + expect(caps.grant).not.toHaveBeenCalled(); + expect(caps.revoke).not.toHaveBeenCalled(); + expect(auth.updateGroupMembers).not.toHaveBeenCalled(); + }); + + it("does not let a personal agent inherit human administration", async () => { + const { ctxFor, caps } = createCtx(); + const friday = ctxFor("friday", ["user.admin"]); + + await expect(handleUserAdmin( + { action: "permissions", username: "bob", grant: ["net.fetch"] }, + friday, + )).rejects.toThrow("Permission denied"); + expect(caps.grant).not.toHaveBeenCalled(); + }); + + it.each([ + [{ grant: ["not valid!"] }, "Invalid capability format"], + [{ grant: ["*"] }, "reserved for root"], + [{ grant: ["net.fetch"], revoke: ["net.fetch"] }, "both add and remove capability"], + [{ addGroups: ["drivers"], removeGroups: ["drivers"] }, "both add and remove group"], + [{ addGroups: ["missing"] }, "Unknown group"], + [{ addGroups: ["root"] }, "root group membership is immutable"], + [{ removeGroups: ["bob"] }, "primary group membership is immutable"], + ])("rejects an invalid patch without mutation", async (patch, message) => { + const { ctxFor, auth, caps } = createCtx(); + + await expect(handleUserAdmin({ + action: "permissions", + username: "bob", + ...patch, + }, ctxFor("alice"))).rejects.toThrow(message); + + expect(caps.grant).not.toHaveBeenCalled(); + expect(caps.revoke).not.toHaveBeenCalled(); + expect(auth.updateGroupMembers).not.toHaveBeenCalled(); + }); + + it("keeps root permissions immutable", async () => { + const { ctxFor, auth, caps } = createCtx(); + + await expect(handleUserAdmin({ + action: "permissions", + username: "root", + revoke: ["*"], + }, ctxFor("alice"))).rejects.toThrow("root permissions are immutable"); + + expect(caps.revoke).not.toHaveBeenCalled(); + expect(auth.updateGroupMembers).not.toHaveBeenCalled(); + }); + + it("allows an administrator to revoke a corrupt non-root wildcard", async () => { + const { ctxFor, capabilities } = createCtx(); + capabilities.push({ gid: 1001, capability: "*" }); + + const result = await handleUserAdmin({ + action: "permissions", + username: "bob", + revoke: ["*"], + }, ctxFor("alice")); + + expect(result.action).toBe("permissions"); + if (result.action !== "permissions") throw new Error("unexpected result"); + expect(result.directCapabilities).not.toContain("*"); + expect(result.changed).toBe(true); + }); + + it("rolls back the whole permission patch when a later write fails", async () => { + const { ctxFor, auth, groups, capabilities } = createCtx(); + const originalGroups = structuredClone(groups); + const originalCapabilities = structuredClone(capabilities); + auth.updateGroupMembers.mockImplementationOnce(() => { + throw new Error("injected group write failure"); + }); + + await expect(handleUserAdmin({ + action: "permissions", + username: "bob", + grant: ["net.fetch"], + addGroups: ["drivers"], + }, ctxFor("alice"))).rejects.toThrow("injected group write failure"); + + expect(groups).toEqual(originalGroups); + expect(capabilities).toEqual(originalCapabilities); + }); + + it("rechecks delegated authority from durable state", async () => { + const { ctxFor, capabilities, caps } = createCtx(); + const staleIdentity = ctxFor("alice", ["user.admin"]); + capabilities.splice( + capabilities.findIndex((entry) => entry.gid === 1000 && entry.capability === "user.admin"), + 1, + ); + + await expect(handleUserAdmin( + { action: "permissions", username: "bob", grant: ["net.fetch"] }, + staleIdentity, + )).rejects.toThrow("Permission denied"); + expect(caps.grant).not.toHaveBeenCalled(); + }); + + it("checks authority before dispatching account creation", async () => { + const { ctxFor, auth } = createCtx(); + + await expect(handleUserAdmin({ + action: "create", + username: "carol", + password: "password-123", + }, ctxFor("bob"))).rejects.toThrow("Permission denied"); + + expect(auth.nextUid).not.toHaveBeenCalled(); + expect(auth.addUser).not.toHaveBeenCalled(); + expect(auth.setShadow).not.toHaveBeenCalled(); + }); + + it("requires a password before creating a human account", async () => { + const { ctxFor, auth } = createCtx(); + + await expect(handleUserAdmin({ + action: "create", + username: "carol", + } as never, ctxFor("alice"))).rejects.toThrow("password must be at least 8 characters"); + + expect(auth.nextUid).not.toHaveBeenCalled(); + expect(auth.addUser).not.toHaveBeenCalled(); + expect(auth.setShadow).not.toHaveBeenCalled(); + }); +}); diff --git a/gateway/src/kernel/user-admin.ts b/gateway/src/kernel/user-admin.ts new file mode 100644 index 000000000..30a0d2204 --- /dev/null +++ b/gateway/src/kernel/user-admin.ts @@ -0,0 +1,200 @@ +import type { + UserAdminArgs, + UserAdminPermissionsResult, + UserAdminResult, +} from "@humansandmachines/gsv/protocol"; +import { isLocked } from "../auth/shadow"; +import { normalizeAccountUsername } from "../auth/username"; +import type { GroupEntry } from "../auth/group"; +import type { PasswdEntry } from "../auth/passwd"; +import { isValidCapability } from "./capabilities"; +import type { KernelContext } from "./context"; +import { createHumanAccount } from "./agents"; +import { requireUserAdmin } from "./user-authority"; + +export async function handleUserAdmin( + args: UserAdminArgs, + ctx: KernelContext, +): Promise { + // Repeat the capability gate inside the owning boundary so internal callers + // cannot bypass the WebSocket dispatch check. This must precede all writes. + requireUserAdmin(ctx); + + const raw = args as unknown as Record; + if (raw.action === "create") { + if (typeof raw.username !== "string") { + throw new Error("username is required"); + } + const created = await createHumanAccount({ + username: raw.username, + password: typeof raw.password === "string" ? raw.password : undefined, + gecos: typeof raw.gecos === "string" ? raw.gecos : undefined, + }, ctx); + return { + action: "create", + account: created.account, + personalAgent: created.personalAgent, + }; + } + + if (raw.action !== "permissions") { + throw new Error("action must be one of: create, permissions"); + } + + return updateUserPermissions(raw, ctx); +} + +function updateUserPermissions( + raw: Record, + ctx: KernelContext, +): UserAdminPermissionsResult { + const target = requireHumanAccount(raw.username, ctx); + const grants = readStringSet(raw.grant, "grant"); + const revocations = readStringSet(raw.revoke, "revoke"); + const additions = readStringSet(raw.addGroups, "addGroups"); + const removals = readStringSet(raw.removeGroups, "removeGroups"); + + for (const capability of [...grants, ...revocations]) { + if (!isValidCapability(capability)) { + throw new Error(`Invalid capability format: ${capability}`); + } + } + if (target.uid !== 0 && grants.has("*")) { + throw new Error("The unrestricted capability is reserved for root"); + } + assertDisjoint(grants, revocations, "capability"); + assertDisjoint(additions, removals, "group"); + + const groupEntries = ctx.auth.getGroupEntries(); + const groupsByName = new Map(groupEntries.map((group) => [group.name, group])); + const changedGroups = new Map(); + for (const name of [...additions, ...removals]) { + const group = groupsByName.get(name); + if (!group) { + throw new Error(`Unknown group: ${name}`); + } + if (group.gid === 0) { + throw new Error("Permission denied: root group membership is immutable"); + } + if (group.gid === target.gid) { + throw new Error("A user's primary group membership is immutable"); + } + + const members = new Set(group.members); + if (additions.has(name)) members.add(target.username); + if (removals.has(name)) members.delete(target.username); + changedGroups.set(name, { ...group, members: [...members] }); + } + + const hasRequestedChanges = grants.size > 0 || revocations.size > 0 || changedGroups.size > 0; + if (target.uid === 0 && hasRequestedChanges) { + throw new Error("Permission denied: root permissions are immutable"); + } + + // Every failure above occurs before this first mutation. Capability writes + // and group membership writes share one Kernel SQLite transaction. + return ctx.transactionSync(() => { + let changed = false; + const directCapabilities = new Set( + ctx.caps.list(target.gid).map((entry) => entry.capability), + ); + for (const capability of grants) { + if (directCapabilities.has(capability)) continue; + const result = ctx.caps.grant(target.gid, capability); + if (!result.ok) { + throw new Error(result.error ?? `Could not grant ${capability}`); + } + directCapabilities.add(capability); + changed = true; + } + for (const capability of revocations) { + if (!directCapabilities.delete(capability)) continue; + const result = ctx.caps.revoke(target.gid, capability); + if (!result.ok) { + throw new Error(result.error ?? `Could not revoke ${capability}`); + } + changed = true; + } + for (const [name, group] of changedGroups) { + const existing = groupsByName.get(name)!; + if (sameMembers(existing.members, group.members)) continue; + if (!ctx.auth.updateGroupMembers(name, group.members)) { + throw new Error(`Unknown group: ${name}`); + } + changed = true; + } + + return permissionResult(target, ctx, changed); + }); +} + +function requireHumanAccount(value: unknown, ctx: KernelContext): PasswdEntry { + const username = normalizeAccountUsername(value); + if (!username) { + throw new Error("username is required"); + } + const account = ctx.auth.getPasswdByUsername(username); + const shadow = account ? ctx.auth.getShadowByUsername(account.username) : null; + if (!account || !shadow || (account.uid !== 0 && isLocked(shadow))) { + throw new Error(`Unknown human user: ${username}`); + } + return account; +} + +function readStringSet(value: unknown, field: string): Set { + if (value === undefined) return new Set(); + if (!Array.isArray(value)) { + throw new Error(`${field} must be an array of strings`); + } + + const result = new Set(); + for (const item of value) { + if (typeof item !== "string" || !item.trim()) { + throw new Error(`${field} must contain only non-empty strings`); + } + result.add(item.trim()); + } + return result; +} + +function assertDisjoint(left: Set, right: Set, label: string): void { + for (const value of left) { + if (right.has(value)) { + throw new Error(`Cannot both add and remove ${label}: ${value}`); + } + } +} + +function permissionResult( + target: PasswdEntry, + ctx: KernelContext, + changed: boolean, +): UserAdminPermissionsResult { + const groups = ctx.auth.getGroupEntries() + .filter((group) => group.gid === target.gid || group.members.includes(target.username)) + .map((group) => ({ + name: group.name, + gid: group.gid, + primary: group.gid === target.gid, + })) + .sort((a, b) => Number(b.primary) - Number(a.primary) || a.gid - b.gid); + const gids = groups.map((group) => group.gid); + + return { + action: "permissions", + user: { username: target.username, uid: target.uid, gid: target.gid }, + groups, + directCapabilities: ctx.caps + .list(target.gid) + .map((entry) => entry.capability) + .sort(), + effectiveCapabilities: ctx.caps.resolve(gids).sort(), + changed, + }; +} + +function sameMembers(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + const values = new Set(left); + return right.every((value) => values.has(value)); +} diff --git a/gateway/src/kernel/user-authority.ts b/gateway/src/kernel/user-authority.ts new file mode 100644 index 000000000..44906f614 --- /dev/null +++ b/gateway/src/kernel/user-authority.ts @@ -0,0 +1,35 @@ +import type { PasswdEntry } from "../auth/passwd"; +import type { KernelContext } from "./context"; + +export const USER_ADMIN_CAPABILITY = "user.admin"; + +/** + * Require ship-level user administration authority from current durable state. + * + * uid 0 is the only root identity. Delegated administration must be granted + * directly on the human's primary gid so a personal agent cannot inherit it + * through its supplementary membership in the human's private group. + */ +export function requireUserAdmin(ctx: KernelContext): PasswdEntry { + const identity = ctx.identity; + if (!identity || identity.role !== "user") { + throw new Error("Permission denied"); + } + + const account = ctx.auth.getPasswdByUid(identity.process.uid); + if (!account || account.username !== identity.process.username) { + throw new Error("Permission denied"); + } + if (account.uid === 0) { + return account; + } + + const directlyGranted = ctx.caps + .list(account.gid) + .some((entry) => entry.capability === USER_ADMIN_CAPABILITY); + if (!directlyGranted) { + throw new Error("Permission denied"); + } + + return account; +} diff --git a/gateway/src/process/do.test.ts b/gateway/src/process/do.test.ts index 04cc067f7..d716c686a 100644 --- a/gateway/src/process/do.test.ts +++ b/gateway/src/process/do.test.ts @@ -171,6 +171,38 @@ async function registerInKernel(pid: string, identity: ProcessIdentity) { await runInDurableObject(kernel, (instance: Kernel) => { const k = instance as any; k.caps.seed(); + + const byUid = k.auth.getPasswdByUid(identity.uid); + const byName = k.auth.getPasswdByUsername(identity.username); + if (!byUid && !byName) { + k.auth.addUser({ + username: identity.username, + uid: identity.uid, + gid: identity.gid, + gecos: identity.username, + home: identity.home, + shell: "/bin/init", + }); + } else if (byUid?.username !== identity.username || byName?.uid !== identity.uid) { + throw new Error(`Conflicting test account identity: ${identity.username}/${identity.uid}`); + } + + if (!k.auth.getGroupByGid(identity.gid) && !k.auth.getGroupByName(identity.username)) { + k.auth.addGroup({ name: identity.username, gid: identity.gid, members: [] }); + } + for (const gid of identity.gids) { + if (gid === identity.gid) continue; + let group = k.auth.getGroupByGid(gid); + if (!group) { + const name = gid === 100 ? "users" : `test-${gid}`; + k.auth.addGroup({ name, gid, members: [] }); + group = k.auth.getGroupByGid(gid); + } + if (group && !group.members.includes(identity.username)) { + k.auth.updateGroupMembers(group.name, [...group.members, identity.username]); + } + } + k.procs.spawn(pid, identity, { profile: DEFAULT_PROFILE }); }); } diff --git a/gateway/src/syscalls/index.ts b/gateway/src/syscalls/index.ts index 60e09426d..2219a8001 100644 --- a/gateway/src/syscalls/index.ts +++ b/gateway/src/syscalls/index.ts @@ -24,7 +24,8 @@ type SyscallDomain = | "notification" | "adapter" | "signal" - | "account"; + | "account" + | "user"; function domainOf(syscall: SyscallName): SyscallDomain { return syscall.split(".")[0] as SyscallDomain; diff --git a/packages/gsv/src/client.ts b/packages/gsv/src/client.ts index 391f057b1..e5405dad2 100644 --- a/packages/gsv/src/client.ts +++ b/packages/gsv/src/client.ts @@ -215,6 +215,7 @@ export type GsvSchedNamespace = GsvClientNamespaces["sched"]; export type GsvShellNamespace = GsvClientNamespaces["shell"]; export type GsvSignalNamespace = GsvClientNamespaces["signal"]; export type GsvSysNamespace = GsvClientNamespaces["sys"]; +export type GsvUserNamespace = GsvClientNamespaces["user"]; const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; const PROTOCOL_VERSION = 2; @@ -348,6 +349,7 @@ const SYSCALL_NAMES = [ "sys.link.consume", "account.create", "account.list", + "user.admin", "sched.list", "sched.add", "sched.update", diff --git a/packages/gsv/src/protocol/index.ts b/packages/gsv/src/protocol/index.ts index b1b3d5455..3e016c896 100644 --- a/packages/gsv/src/protocol/index.ts +++ b/packages/gsv/src/protocol/index.ts @@ -13,6 +13,7 @@ export type * from "./syscalls/interaction-origin"; export type * from "./syscalls/notification"; export type * from "./syscalls/ai"; export type * from "./syscalls/apps"; +export type * from "./syscalls/user"; export type * from "./syscalls/map"; export * from "./adapters"; export * from "./adapter-media-body"; diff --git a/packages/gsv/src/protocol/syscalls/map.ts b/packages/gsv/src/protocol/syscalls/map.ts index f88a978e1..8d38aabb6 100644 --- a/packages/gsv/src/protocol/syscalls/map.ts +++ b/packages/gsv/src/protocol/syscalls/map.ts @@ -275,6 +275,7 @@ import type { SignalWatchArgs, SignalWatchResult, } from "./signal"; +import type { UserAdminArgs, UserAdminResult } from "./user"; export type SyscallDomains = { "fs.read": { args: FsReadArgs; result: FsReadResult }; @@ -393,6 +394,8 @@ export type SyscallDomains = { "account.create": { args: AccountCreateArgs; result: AccountCreateResult }; "account.list": { args: AccountListArgs; result: AccountListResult }; + "user.admin": { args: UserAdminArgs; result: UserAdminResult }; + "sched.list": { args: SchedulerListArgs; result: SchedulerListResult }; "sched.add": { args: SchedulerAddArgs; result: SchedulerAddResult }; "sched.update": { args: SchedulerUpdateArgs; result: SchedulerUpdateResult }; diff --git a/packages/gsv/src/protocol/syscalls/user.ts b/packages/gsv/src/protocol/syscalls/user.ts new file mode 100644 index 000000000..d467c0d63 --- /dev/null +++ b/packages/gsv/src/protocol/syscalls/user.ts @@ -0,0 +1,46 @@ +import type { ProcessIdentity } from "./system"; + +export type UserAdminCreateArgs = { + action: "create"; + username: string; + password: string; + gecos?: string; +}; + +export type UserAdminPermissionsArgs = { + action: "permissions"; + username: string; + grant?: string[]; + revoke?: string[]; + addGroups?: string[]; + removeGroups?: string[]; +}; + +export type UserAdminArgs = UserAdminCreateArgs | UserAdminPermissionsArgs; + +export type UserAdminCreateResult = { + action: "create"; + account: ProcessIdentity; + personalAgent: ProcessIdentity; +}; + +export type UserAdminGroupSummary = { + name: string; + gid: number; + primary: boolean; +}; + +export type UserAdminPermissionsResult = { + action: "permissions"; + user: { + username: string; + uid: number; + gid: number; + }; + groups: UserAdminGroupSummary[]; + directCapabilities: string[]; + effectiveCapabilities: string[]; + changed: boolean; +}; + +export type UserAdminResult = UserAdminCreateResult | UserAdminPermissionsResult; From a63eca08ab78c8a024f90ee6b5da851f1ef88081 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 22 Jul 2026 14:42:03 +0200 Subject: [PATCH 7/7] add native user management shell --- docs/reference/hardware-tools.md | 20 ++ docs/reference/syscalls.md | 11 + gateway/src/drivers/native/man-pages.ts | 44 +++ gateway/src/drivers/native/shell.test.ts | 73 +++++ gateway/src/drivers/native/shell/commands.ts | 3 + gateway/src/drivers/native/shell/discovery.ts | 5 + gateway/src/drivers/native/shell/user.test.ts | 247 ++++++++++++++++ gateway/src/drivers/native/shell/user.ts | 275 ++++++++++++++++++ 8 files changed, 678 insertions(+) create mode 100644 gateway/src/drivers/native/shell/user.test.ts create mode 100644 gateway/src/drivers/native/shell/user.ts diff --git a/docs/reference/hardware-tools.md b/docs/reference/hardware-tools.md index ae019b6d0..768863f54 100644 --- a/docs/reference/hardware-tools.md +++ b/docs/reference/hardware-tools.md @@ -137,6 +137,26 @@ mcp codemode mcp call Linear list_issues --args-json '{"assignee":"me","limit":5}' --json ``` +Root and delegated human administrators can manage human accounts through the +same `user.admin` boundary used by API clients: + +```bash +touch /tmp/.alice-password +chmod 600 /tmp/.alice-password +# Populate it with a direct trusted client that does not retain secret input. +user create alice --password-stdin < /tmp/.alice-password +rm /tmp/.alice-password +user permissions alice +user permissions alice --grant repo.create --add-group operators +``` + +Passwords are accepted only on command stdin. Keep them out of `shell.exec` +command text, which may be retained in process history; use a protected input +file. Create an empty file, set mode `0600` before populating it through a +trusted client, and remove it after use. `register` provisions the account and +tells the caller to start a new login, because a shell command cannot replace +its current authenticated WebSocket identity. + Scripts use the same CodeMode shape exposed to agents. A script is treated as the body of an async function: top-level `await` works, and the final value must be returned explicitly. diff --git a/docs/reference/syscalls.md b/docs/reference/syscalls.md index 22990c609..30dcfa653 100644 --- a/docs/reference/syscalls.md +++ b/docs/reference/syscalls.md @@ -1234,6 +1234,17 @@ type UserAdminResult = }; ``` +The native `gsv` shell exposes the same boundary through `user create`, `user +register`, and `user permissions` (with `user edit-permissions` as an alias). +Creation reads the new password only from command stdin via +`--password-stdin`; redirect a protected file instead of placing a password in +the `shell.exec` command text. Set the empty file to mode `0600` before a +trusted client populates it, and remove it after use. Native `register` +provisions the account and prints the next login action, but cannot replace the +identity of the already authenticated WebSocket. Permission changes re-enter +normal syscall dispatch, so they receive the same durable authorization, +atomic mutation, and live identity refresh as a direct `user.admin` request. + ## AI: `ai.*` `ai.tools` and `ai.config` are internal Process bootstrap calls. The media diff --git a/gateway/src/drivers/native/man-pages.ts b/gateway/src/drivers/native/man-pages.ts index 934d31030..4779578f0 100644 --- a/gateway/src/drivers/native/man-pages.ts +++ b/gateway/src/drivers/native/man-pages.ts @@ -198,6 +198,50 @@ export function renderManualPage(topic: string): string | null { "", ].join("\n"); + case "user": + return [ + "USER(1)", + "", + "NAME", + " user - create human accounts and manage their permissions", + "", + "SYNOPSIS", + ...manualSynopsis("user"), + " user edit-permissions USER [--grant CAP] [--revoke CAP] [--add-group GROUP] [--remove-group GROUP] [--json]", + "", + "OVERVIEW", + " `user` wraps the Kernel-owned user.admin boundary. The caller must be root", + " or a human with user.admin granted directly to its primary group. The", + " Kernel repeats that check from current durable state before every mutation.", + "", + "PASSWORD INPUT", + " create and register accept the new password only from stdin. Redirect a", + " protected file; do not put a password in the shell command, where it may be", + " retained as shell.exec input. Create the empty file, chmod it to 0600 before", + " populating it through a trusted client, and remove it after use. One trailing", + " line ending is removed.", + "", + "REGISTER", + " register provisions the same human account and personal agent as create,", + " then prints the next login action. It cannot replace the identity of the", + " already-authenticated WebSocket running this shell command.", + "", + "PERMISSIONS", + " With no change options, permissions displays primary and supplementary", + " groups plus direct and effective capabilities. Repeat change options to", + " apply one atomic patch. edit-permissions is an alias for permissions.", + "", + "EXAMPLES", + " touch /tmp/.alice-password", + " chmod 600 /tmp/.alice-password", + " # Populate it with a direct trusted client that does not retain secret input.", + " user create alice --password-stdin < /tmp/.alice-password", + " rm /tmp/.alice-password", + " user permissions alice", + " user permissions alice --grant repo.create --add-group operators", + "", + ].join("\n"); + case "sched": return [ "SCHED(1)", diff --git a/gateway/src/drivers/native/shell.test.ts b/gateway/src/drivers/native/shell.test.ts index 70123c888..a101dadeb 100644 --- a/gateway/src/drivers/native/shell.test.ts +++ b/gateway/src/drivers/native/shell.test.ts @@ -448,6 +448,67 @@ describe("native shell execution", () => { expect(read.body && await bodyToText(read.body)).toContain("from shell"); }); + it("runs user administration with a password redirected from GSV storage", async () => { + const ctx = makeContext({ + capabilities: ["shell.exec", "user.admin", "fs.write"], + caps: { + list: vi.fn((gid?: number) => gid === IDENTITY.gid + ? [{ gid: IDENTITY.gid, capability: "user.admin" }] + : []), + resolve: vi.fn(() => []), + } as unknown as KernelContext["caps"], + }); + const passwordPath = "/tmp/.new-user-password"; + await expect(handleFsWrite({ path: passwordPath, content: "" }, ctx)) + .resolves.toMatchObject({ ok: true }); + await expect(handleShellExec({ input: `chmod 600 ${passwordPath}` }, ctx)) + .resolves.toMatchObject({ status: "completed", exitCode: 0 }); + await handleFsWrite({ path: passwordPath, content: "password-123\n" }, ctx); + await expect(handleShellExec({ input: `stat -c %a ${passwordPath}` }, ctx)) + .resolves.toMatchObject({ status: "completed", stdout: "600\n" }); + const request = vi.fn(async (frame: RequestFrame): Promise => ({ + type: "res", + id: frame.id, + ok: true, + data: { + action: "create", + account: { + uid: 1002, + gid: 1002, + gids: [1002, 100], + username: "alice", + home: "/home/alice", + cwd: "/home/alice", + }, + personalAgent: { + uid: 1003, + gid: 1003, + gids: [1003, 1002, 100], + username: "friday", + home: "/home/friday", + cwd: "/home/friday", + }, + }, + } as ResponseFrame)); + + const result = await handleShellExec( + { input: `user create alice --password-stdin < ${passwordPath}` }, + ctx, + { request }, + ); + + expect(result).toMatchObject({ status: "completed", exitCode: 0 }); + expect(result.stdout).toContain("Created human account alice"); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ + call: "user.admin", + args: { + action: "create", + username: "alice", + password: "password-123", + }, + }), expect.any(AbortSignal)); + }); + it("preserves filesystem errors from fs.read", async () => { const result = await handleFsRead({ path: "/tmp/does-not-exist" }, makeContext()); @@ -616,6 +677,18 @@ describe("native shell capability discovery", () => { expect(result.stdout).not.toContain("GSV manual pages"); }); + it("documents secure native user administration without requiring authority", async () => { + const result = await handleShellExec( + { input: "man user" }, + makeContext({ capabilities: ["shell.exec"] }), + ); + + expect(result).toMatchObject({ status: "completed", exitCode: 0 }); + expect(result.stdout).toContain("user create USER --password-stdin"); + expect(result.stdout).toContain("protected file; do not put a password"); + expect(result.stdout).toContain("already-authenticated WebSocket"); + }); + it.each([ ["put the image from this chat on my connected machine", "cp"], ["create a picture from words", "txt2img"], diff --git a/gateway/src/drivers/native/shell/commands.ts b/gateway/src/drivers/native/shell/commands.ts index a5eee7c26..c9c4adc32 100644 --- a/gateway/src/drivers/native/shell/commands.ts +++ b/gateway/src/drivers/native/shell/commands.ts @@ -25,6 +25,7 @@ import { buildSchedCommand } from "./sched"; import { buildSkillsCommand } from "./skills"; import { buildStatCommand } from "./stat"; import { buildTargetsCommands } from "./targets"; +import { buildUserCommand } from "./user"; import { buildWikiCommand } from "./wiki"; import { ShellDiscoveryCatalog } from "./discovery"; @@ -63,6 +64,7 @@ export function buildCustomCommands( const message = buildMessageCommand(fs, ctx); const netCommands = buildNetCommands(ctx, options?.netFetchTransport); const oauth = buildOAuthCommand(ctx); + const user = buildUserCommand(ctx, options?.request); const notifyCommands = buildNotifyCommands(ctx); const flynn = defineCommand("flynn", async (): Promise => ({ stdout: `General Systems Vehicle ${ctx.config.get("config/server/version") ?? "0.1.6"} - Steve James.\n\n"I kept dreaming of a world I thought I'd never see. And then, one day... I got in."`, @@ -84,6 +86,7 @@ export function buildCustomCommands( ...targets, ...netCommands, oauth, + user, llm, ...mediaCommands, message, diff --git a/gateway/src/drivers/native/shell/discovery.ts b/gateway/src/drivers/native/shell/discovery.ts index b71969c00..38b97d6aa 100644 --- a/gateway/src/drivers/native/shell/discovery.ts +++ b/gateway/src/drivers/native/shell/discovery.ts @@ -78,6 +78,11 @@ const NATIVE_COMMAND_DESCRIPTORS: Record = { net: command("Make a streamed HTTP request through GSV or another target.", "Fetch a URL or call an HTTP API with explicit request and response control.", ["http", "network", "url", "download", "api", "fetch"]), "gsv-fetch": command("Compatibility form of the native streamed HTTP client.", "Fetch a URL or call an HTTP API from GSV.", ["http", "network", "url", "download", "api", "fetch"], ["net"]), oauth: command("Inspect and manage OAuth connections.", "Connect, inspect, or forget a provider account used by GSV.", ["login", "account", "provider", "authentication", "oauth"]), + user: command("Create human accounts and manage their permissions.", "Register a new human or inspect and change a human account's capabilities and groups.", ["account", "human", "register", "permissions", "capabilities", "groups", "admin"], [], [ + "user create USER --password-stdin [--json]", + "user register USER --password-stdin [--json]", + "user permissions USER [--grant CAP] [--revoke CAP] [--add-group GROUP] [--remove-group GROUP] [--json]", + ], ["user.admin"]), llm: command("Generate one text response without running an agent loop.", "Perform a one-shot text generation, rewrite, classification, or summarization.", ["ai", "text", "generate", "summarize", "rewrite", "classify"], [], ["llm [OPTIONS] PROMPT..."], ["ai.text.generate"]), img2txt: command("Describe or read an image with the configured vision model.", "Understand, inspect, OCR, or describe a photo, picture, screenshot, or image file.", ["image", "photo", "picture", "screenshot", "vision", "ocr", "describe", "read"], [], ["img2txt [OPTIONS] IMAGE"], ["ai.image.read"]), txt2img: command("Generate an image file from a text prompt.", "Create, draw, or generate a picture, photo, illustration, or image from words.", ["image", "photo", "picture", "illustration", "draw", "create", "generate"], [], ["txt2img [OPTIONS] -o PATH PROMPT..."], ["ai.image.generate"]), diff --git a/gateway/src/drivers/native/shell/user.test.ts b/gateway/src/drivers/native/shell/user.test.ts new file mode 100644 index 000000000..49441d56f --- /dev/null +++ b/gateway/src/drivers/native/shell/user.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "just-bash"; +import type { + ProcessIdentity, + UserAdminCreateResult, + UserAdminPermissionsResult, +} from "@humansandmachines/gsv/protocol"; +import type { KernelContext } from "../../../kernel/context"; +import type { RequestFrame, ResponseFrame } from "../../../protocol/frames"; +import { buildUserCommand } from "./user"; + +const ACCOUNT: ProcessIdentity = { + uid: 1002, + gid: 1002, + gids: [1002, 100], + username: "alice", + home: "/home/alice", + cwd: "/home/alice", +}; + +const PERSONAL_AGENT: ProcessIdentity = { + uid: 1003, + gid: 1003, + gids: [1003, 1002, 100], + username: "friday", + home: "/home/friday", + cwd: "/home/friday", +}; + +const CREATE_RESULT: UserAdminCreateResult = { + action: "create", + account: ACCOUNT, + personalAgent: PERSONAL_AGENT, +}; + +const PERMISSIONS_RESULT: UserAdminPermissionsResult = { + action: "permissions", + user: { username: "alice", uid: 1002, gid: 1002 }, + groups: [ + { name: "alice", gid: 1002, primary: true }, + { name: "operators", gid: 1200, primary: false }, + ], + directCapabilities: ["repo.create"], + effectiveCapabilities: ["fs.read", "repo.create"], + changed: true, +}; + +describe("native user command", () => { + it("creates a human through user.admin with a password read from stdin", async () => { + const request = successfulRequest(CREATE_RESULT); + const result = await execute( + ["create", "alice", "--password-stdin"], + " correct horse \n", + request, + ); + + expect(request).toHaveBeenCalledWith(expect.objectContaining({ + call: "user.admin", + args: { + action: "create", + username: "alice", + password: " correct horse ", + }, + }), undefined); + expect(result).toMatchObject({ exitCode: 0, stderr: "" }); + expect(result.stdout).toContain("Created human account alice (uid 1002, gid 1002)."); + expect(result.stdout).toContain("Personal agent: friday (uid 1003, gid 1003)"); + expect(result.stdout).not.toContain("correct horse"); + }); + + it("registers through the same create action and requires a fresh login", async () => { + const request = successfulRequest(CREATE_RESULT); + const result = await execute( + ["register", "alice", "--password-stdin"], + "password-123", + request, + ); + + expect(request.mock.calls[0][0]).toMatchObject({ + call: "user.admin", + args: { action: "create", username: "alice", password: "password-123" }, + }); + expect(result.stdout).toContain("Registration complete. Start a new login as alice."); + }); + + it("maps permission views and repeated edits to one atomic patch request", async () => { + const request = successfulRequest(PERMISSIONS_RESULT); + const edited = await execute([ + "permissions", + "alice", + "--grant", + "repo.create", + "--grant", + "repo.delete", + "--revoke", + "net.fetch", + "--add-group", + "operators", + "--remove-group", + "guests", + ], "", request); + await execute(["permissions", "alice"], "", request); + + expect(request.mock.calls[0][0]).toMatchObject({ + call: "user.admin", + args: { + action: "permissions", + username: "alice", + grant: ["repo.create", "repo.delete"], + revoke: ["net.fetch"], + addGroups: ["operators"], + removeGroups: ["guests"], + }, + }); + expect(request.mock.calls[1][0]).toMatchObject({ + args: { action: "permissions", username: "alice" }, + }); + expect(request.mock.calls[1][0].args).not.toHaveProperty("grant"); + expect(edited.stdout).toContain("Direct capabilities:\n repo.create"); + expect(edited.stdout).toContain("Permissions updated."); + }); + + it("denies a regular user before sending any administration request", async () => { + const request = successfulRequest(CREATE_RESULT); + const result = await execute( + ["create", "alice", "--password-stdin"], + "password-123", + request, + ["shell.exec"], + ); + + expect(result).toMatchObject({ exitCode: 1, stdout: "" }); + expect(result.stderr).toBe("user: Permission denied: user.admin\n"); + expect(request).not.toHaveBeenCalled(); + }); + + it("denies inherited or stale administration before materializing a request", async () => { + for (const ctx of [ + kernelContext(["shell.exec", "user.admin"], { directlyGranted: false }), + kernelContext(["shell.exec", "user.admin"], { + directlyGranted: false, + identity: PERSONAL_AGENT, + }), + ]) { + const request = successfulRequest(CREATE_RESULT); + const result = await buildUserCommand(ctx, request).execute( + ["create", "alice", "--password-stdin"], + commandContext("password-123"), + ); + + expect(result.stderr).toBe("user: Permission denied\n"); + expect(request).not.toHaveBeenCalled(); + } + }); + + it("propagates the durable authority denial from nested syscall dispatch", async () => { + const request = vi.fn(async (frame: RequestFrame): Promise => ({ + type: "res", + id: frame.id, + ok: false, + error: { code: 403, message: "Permission denied" }, + })); + const result = await execute( + ["permissions", "alice", "--grant", "repo.create"], + "", + request, + ); + + expect(result.stderr).toBe("user: Permission denied\n"); + expect(request).toHaveBeenCalledOnce(); + }); + + it("shows help without administration authority or syscall transport", async () => { + const command = buildUserCommand(kernelContext(["shell.exec"])); + const result = await command.execute(["--help"], commandContext("")); + + expect(result).toMatchObject({ exitCode: 0, stderr: "" }); + expect(result.stdout).toContain("user create USER --password-stdin"); + }); +}); + +function successfulRequest(result: UserAdminCreateResult | UserAdminPermissionsResult) { + return vi.fn(async (frame: RequestFrame): Promise => ({ + type: "res", + id: frame.id, + ok: true, + data: result, + } as ResponseFrame)); +} + +async function execute( + args: string[], + stdin: string, + request: (frame: RequestFrame, signal?: AbortSignal) => Promise, + capabilities = ["shell.exec", "user.admin"], +) { + return await buildUserCommand(kernelContext(capabilities), request) + .execute(args, commandContext(stdin)); +} + +function kernelContext( + capabilities: string[], + options?: { directlyGranted?: boolean; identity?: ProcessIdentity }, +): KernelContext { + const identity = options?.identity ?? { + uid: 1000, + gid: 1000, + gids: [1000, 100], + username: "root-admin", + home: "/home/root-admin", + cwd: "/home/root-admin", + }; + const directlyGranted = options?.directlyGranted ?? capabilities.includes("user.admin"); + return { + auth: { + getPasswdByUid: vi.fn((uid: number) => uid === identity.uid + ? { + username: identity.username, + uid: identity.uid, + gid: identity.gid, + gecos: identity.username, + home: identity.home, + shell: "/bin/init", + } + : null), + }, + caps: { + list: vi.fn((gid?: number) => directlyGranted && gid === identity.gid + ? [{ gid: identity.gid, capability: "user.admin" }] + : []), + }, + identity: { + role: "user", + process: identity, + capabilities, + }, + } as KernelContext; +} + +function commandContext(stdin: string): CommandContext { + return { + fs: {} as CommandContext["fs"], + cwd: "/home/root-admin", + env: new Map(), + stdin, + }; +} diff --git a/gateway/src/drivers/native/shell/user.ts b/gateway/src/drivers/native/shell/user.ts new file mode 100644 index 000000000..9bc9005fa --- /dev/null +++ b/gateway/src/drivers/native/shell/user.ts @@ -0,0 +1,275 @@ +import { defineCommand } from "just-bash"; +import type { CommandContext, ExecResult } from "just-bash"; +import type { + UserAdminArgs, + UserAdminCreateResult, + UserAdminPermissionsResult, + UserAdminResult, +} from "@humansandmachines/gsv/protocol"; +import type { KernelContext } from "../../../kernel/context"; +import { + requireUserAdmin, + USER_ADMIN_CAPABILITY, +} from "../../../kernel/user-authority"; +import type { RequestFrame, ResponseFrame } from "../../../protocol/frames"; +import { requireCommandCapability, requireShellOptionValue } from "./common"; + +type NativeShellRequest = ( + frame: RequestFrame, + signal?: AbortSignal, +) => Promise; + +type CreateOptions = { + username: string; + json: boolean; +}; + +type PermissionsOptions = { + username: string; + grant: string[]; + revoke: string[]; + addGroups: string[]; + removeGroups: string[]; + json: boolean; +}; + +export function buildUserCommand( + kernelCtx: KernelContext, + request?: NativeShellRequest, +) { + return defineCommand("user", async (args, shellCtx): Promise => { + try { + return await runUserCommand(args, shellCtx, kernelCtx, request); + } catch (error) { + return { + stdout: "", + stderr: `user: ${error instanceof Error ? error.message : String(error)}\n`, + exitCode: 1, + }; + } + }); +} + +async function runUserCommand( + args: string[], + shellCtx: CommandContext, + kernelCtx: KernelContext, + request?: NativeShellRequest, +): Promise { + const [subcommand = "help", ...rest] = args; + if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + return success(userUsage()); + } + if (rest.includes("--help") || rest.includes("-h")) { + return success(userUsage()); + } + + // Fail on both advertised and current durable authority before parsing + // credentials. The user.admin handler repeats the durable check at mutation. + requireCommandCapability(kernelCtx, USER_ADMIN_CAPABILITY); + requireUserAdmin(kernelCtx); + if (!request) { + throw new Error("direct syscall transport is unavailable"); + } + + switch (subcommand) { + case "create": + case "register": { + const options = parseCreateOptions(rest, subcommand); + const result = await requestUserAdmin(request, { + action: "create", + username: options.username, + password: passwordFromStdin(shellCtx.stdin), + }, shellCtx.signal); + if (result.action !== "create") { + throw new Error("invalid user.admin create response"); + } + return options.json + ? jsonResult(result) + : success(formatCreateResult(result, subcommand === "register")); + } + + case "permissions": + case "edit-permissions": { + const options = parsePermissionsOptions(rest, subcommand); + const result = await requestUserAdmin(request, { + action: "permissions", + username: options.username, + ...(options.grant.length > 0 ? { grant: options.grant } : {}), + ...(options.revoke.length > 0 ? { revoke: options.revoke } : {}), + ...(options.addGroups.length > 0 ? { addGroups: options.addGroups } : {}), + ...(options.removeGroups.length > 0 ? { removeGroups: options.removeGroups } : {}), + }, shellCtx.signal); + if (result.action !== "permissions") { + throw new Error("invalid user.admin permissions response"); + } + return options.json ? jsonResult(result) : success(formatPermissionsResult(result)); + } + + default: + throw new Error(`unknown subcommand: ${subcommand}`); + } +} + +async function requestUserAdmin( + request: NativeShellRequest, + args: UserAdminArgs, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const frame: RequestFrame<"user.admin"> = { + type: "req", + id: crypto.randomUUID(), + call: "user.admin", + args, + }; + const response = await request(frame, signal); + signal?.throwIfAborted(); + if (!response.ok) { + throw new Error(response.error.message); + } + if (!response.data) { + throw new Error("user.admin returned no result"); + } + return response.data as UserAdminResult; +} + +function parseCreateOptions(args: string[], subcommand: string): CreateOptions { + const positionals: string[] = []; + let passwordStdin = false; + let json = false; + + for (const arg of args) { + if (arg === "--password-stdin") { + passwordStdin = true; + } else if (arg === "--json") { + json = true; + } else if (arg.startsWith("-")) { + throw new Error(`unknown option: ${arg}`); + } else { + positionals.push(arg); + } + } + + if (positionals.length !== 1 || !passwordStdin) { + throw new Error(`usage: user ${subcommand} USER --password-stdin [--json]`); + } + return { username: positionals[0], json }; +} + +function parsePermissionsOptions(args: string[], subcommand: string): PermissionsOptions { + const parsed: PermissionsOptions = { + username: "", + grant: [], + revoke: [], + addGroups: [], + removeGroups: [], + json: false, + }; + const positionals: string[] = []; + const valueOptions: Record> = { + "--grant": "grant", + "--revoke": "revoke", + "--add-group": "addGroups", + "--remove-group": "removeGroups", + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--json") { + parsed.json = true; + continue; + } + const field = valueOptions[arg]; + if (field) { + index += 1; + parsed[field].push(requireShellOptionValue(args[index], arg)); + continue; + } + if (arg.startsWith("-")) { + throw new Error(`unknown option: ${arg}`); + } + positionals.push(arg); + } + + if (positionals.length !== 1) { + throw new Error( + `usage: user ${subcommand} USER [--grant CAP] [--revoke CAP] ` + + "[--add-group GROUP] [--remove-group GROUP] [--json]", + ); + } + parsed.username = positionals[0]; + return parsed; +} + +function passwordFromStdin(stdin: string): string { + const password = stdin.endsWith("\r\n") + ? stdin.slice(0, -2) + : stdin.endsWith("\n") + ? stdin.slice(0, -1) + : stdin; + if (!password) { + throw new Error("new user password is required on stdin"); + } + return password; +} + +function formatCreateResult(result: UserAdminCreateResult, registered: boolean): string { + const lines = [ + `Created human account ${result.account.username} (uid ${result.account.uid}, gid ${result.account.gid}).`, + `Home: ${result.account.home}`, + `Personal agent: ${result.personalAgent.username} (uid ${result.personalAgent.uid}, gid ${result.personalAgent.gid})`, + ]; + if (registered) { + lines.push(`Registration complete. Start a new login as ${result.account.username}.`); + } + return `${lines.join("\n")}\n`; +} + +function formatPermissionsResult(result: UserAdminPermissionsResult): string { + const groups = result.groups.length === 0 + ? "(none)" + : result.groups.map((group) => + `${group.name} (${group.gid}${group.primary ? ", primary" : ""})` + ).join(", "); + return [ + `User: ${result.user.username} (uid ${result.user.uid}, gid ${result.user.gid})`, + `Groups: ${groups}`, + "Direct capabilities:", + formatCapabilities(result.directCapabilities), + "Effective capabilities:", + formatCapabilities(result.effectiveCapabilities), + result.changed ? "Permissions updated." : "Permissions unchanged.", + "", + ].join("\n"); +} + +function formatCapabilities(capabilities: string[]): string { + return capabilities.length > 0 + ? capabilities.map((capability) => ` ${capability}`).join("\n") + : " (none)"; +} + +function userUsage(): string { + return [ + "Usage:", + " user create USER --password-stdin [--json]", + " user register USER --password-stdin [--json]", + " user permissions USER [--grant CAP] [--revoke CAP] [--add-group GROUP] [--remove-group GROUP] [--json]", + " user edit-permissions USER [--grant CAP] [--revoke CAP] [--add-group GROUP] [--remove-group GROUP] [--json]", + "", + "Passwords are accepted only on stdin so they do not appear in shell argv.", + "", + ].join("\n"); +} + +function success(stdout: string): ExecResult { + return { stdout, stderr: "", exitCode: 0 }; +} + +function jsonResult(value: unknown): ExecResult { + return success(`${JSON.stringify(value, null, 2)}\n`); +}