diff --git a/AGENTS.md b/AGENTS.md index 1ea1528..f9f2e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Run `mise run check` and `mise run generate:check` for every change. Release changes also require the focused release/workflow tests and a current-host compiled archive smoke through `mise run release:smoke`. +The hand-written Agent OS provider-ingress command and its security suites are +specified in `docs/architecture.md`, `test/secure-token-file.test.ts`, and +`test/agent-os-loader.test.ts`. + ## Maintaining this file Keep this file concise and durable. Add only repository-wide rules that are not diff --git a/docs/architecture.md b/docs/architecture.md index c45c1fb..dde632b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,64 @@ Status: greenfield scaffold with local auth/config MVP. spec fetch task performs only a read-only OpenAPI GET and rejects non-HTTPS source URLs. +## Agent OS HCloud Provider Loader Companion + +The one exception to the generated-public-command boundary is the compiled, +non-generated command: + +```sh +akua agent-os load-hcloud-provider \ + --workspace \ + --token-file \ + [--expected-ssh-key-fingerprint \ + [--expected-ssh-key-name ]] +``` + +It is a deliberately thin local companion to the server-owned cnap Agent OS +provider-loader transaction (`POST /v1/agent_os/hcloud_provider_loads`). The +cnap transaction is the canonical source of truth for workspace authorization, +provider identity and inventory validation, storage, idempotency, compensation, +revocation, and all provider policy. This CLI never implements an inventory, +uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a +shell child, or falls back to another endpoint. + +The command requires workspace and token-file flags and rejects positional +input, `--token`, stdin, provider-token environment/profile input, API URL +overrides, debug body output, and retry transports. A provider-returned SSH key +fingerprint is optional and may be sent only when predeclared; its optional name +requires the fingerprint. With no expected key, cnap requires a fully empty +inventory. The CLI never derives identity from the provider token. It reads +normal Akua caller authentication only from the protected local Akua config; +`AKUA_API_TOKEN` is rejected for this command. +It sends that authentication in `Authorization`, the explicit selection in +`Akua-Context`, a newly generated `Idempotency-Key`, and a body containing the +provider token plus optional `expected_ssh_key_fingerprint` and +`expected_ssh_key_name`. The production base URL and route are fixed; tests may +inject a fake HTTPS transport only through an internal dependency seam. The +client allowlists only `loader_id`, `attestation_id`, `secret_id`, +`secret_version_id`, `compute_config_id`, and `expected_ssh_key_fingerprint`, +preserving the secret-version continuity field before spend. + +The provider file is opened exactly once in the compiled process by a dedicated +Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` +file. It obtains pre-open `lstat` metadata, opens with `O_NOFOLLOW | O_CLOEXEC`, +compares device/inode/UID/mode with `fstat`, performs one bounded descriptor +read, and closes before HTTP submission. It rejects symlinks, substitutions, +directories, devices, FIFOs, sockets, wrong owners, empty input, and oversized +input. The token is held only in a mutable byte buffer for request assembly; +the buffer is overwritten immediately after the single request attempt. Bun +cannot promise physical heap zeroisation, so the security contract is no +deliberate secret persistence or exposure through CLI interfaces, logs, reports, +or configuration. A stronger heap guarantee requires a reviewed native module, +not a weaker file or API contract. + +The endpoint is a release dependency delivered by +[cnap #545](https://github.com/akua-dev/cnap/pull/545), implementing +`agentOs.hcloudProviderLoads.create`: its production delivery must complete and +its released route contract must exactly match this companion before CLI +publication or Phase A invocation. The companion's eventual CLI-owned release is coordinated as +`0.9.0`; it does not duplicate the multi-platform distribution scope in PR #21. + ## Current Repo Boundary The old Go/CNAP implementation is removed from the active build surface. The diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md new file mode 100644 index 0000000..7856233 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -0,0 +1,87 @@ +# Agent OS HCloud Provider Loader CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it with a workspace-bound issuer attestation and optional predeclared anchor fingerprint to the dedicated cnap transaction without exposing or retaining token contents. + +**Architecture:** A command parser validates only the two required flags and obtains normal caller authentication from protected local config. A Unix descriptor reader performs the file security checks and returns a single mutable byte buffer. A fixed-route HTTPS client builds the request in-process, sends precisely once with an idempotency key, clears the buffer, and projects the response through a fixed non-secret allowlist. The cnap server remains the owner of validation, persistence, inventory, rollback, and revocation. + +**Tech Stack:** Bun 1.3, TypeScript, Node-compatible `fs` descriptor APIs, Bun test, built-in `fetch` HTTPS transport, synthetic fixtures only. + +## Global Constraints + +- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file --project-identity-attestation [--project-anchor-ssh-key-fingerprint ]`. +- Held production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body with `provider_token`, verbatim non-secret `project_identity_attestation`, and optional predeclared `project_anchor_ssh_key_fingerprint`. cnap must verify the attestation binds the exact workspace and one-shot request; no-anchor requires fully empty inventory. Never derive either identity input from the token; do not add a project-anchor name unless cnap #540's released contract requires it. Preserve allowlisted `secret_version_id` and `transaction_id` for pre-spend continuity checks. +- No provider secret may enter argv, stdin, environment, profile, browser, shell child, curl, config, cache, log, stdout, stderr, error message, crash report, or test report. +- Reject `AKUA_API_TOKEN` and all provider-token/environment/API-base override inputs for this command; do not retry a request after a transport outcome is uncertain. +- The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. +- Clear every mutable token/request byte buffer in `finally`; no JavaScript runtime can prove physical heap zeroisation. +- Output may contain only the allowlisted server result or a fixed failure code, status, request ID, and opaque resource IDs. +- [cnap #540](https://github.com/akua-dev/cnap/issues/540) is a hard merge and Phase-A dependency. Do not open a CLI PR, publish, tag, deploy, merge, or create a release until its HCloud project-identity security gate resolves and its released route contract exactly matches this client. Coordinate the first CLI-owned release as `0.9.0`, separate from PR #21's distribution scope. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/runtime/secure-token-file.ts` | Secure Unix metadata/open/read/close primitive and buffer clear helper. | +| `src/runtime/platform-client.ts` | Fixed-route, one-attempt HTTPS request and allowlisted response projection. | +| `src/commands/agent-os.ts` | Flag parser, auth ordering, request orchestration, and safe command envelope. | +| `src/commands/auth.ts` | Expose a protected-config-only caller credential reader without changing auth command precedence. | +| `src/bin/akua.ts` | Route and help entry for the single command. | +| `test/secure-token-file.test.ts` | Synthetic descriptor, mode, owner, one-read, and swap tests. | +| `test/agent-os-loader.test.ts` | Fake HTTPS transport, no-retention, ordering, idempotency, allowlist, revocation, and no-retry tests. | +| `test/cli.test.ts` | Entrypoint routing and public help regression coverage. | +| `docs/architecture.md` | CLI ownership, endpoint, release handoff, and security boundary. | + +### Task 1: Secure descriptor reader + +**Files:** Create `src/runtime/secure-token-file.ts`; create `test/secure-token-file.test.ts`. + +**Interfaces:** Produce `readSecureTokenFile(path, dependencies?): Promise` and `clearBytes(bytes): void`. Dependencies expose `lstat`, `open`, `fstat`, and one descriptor `read` only to make substitution/read-count tests deterministic. + +- [x] **Step 1: Write failing secure-reader tests** for a synthetic `0600` regular file, rejected relative/symlink/directory/FIFO/device/wrong-owner/wrong-mode/empty/oversize files, exactly one descriptor read, close-before-return, and a hook that swaps a path between `lstat` and `open`. +- [x] **Step 2: Run `bun test test/secure-token-file.test.ts`** and confirm failures name the missing module/functions rather than fixture setup. +- [x] **Step 3: Implement the minimal reader** with absolute-path validation; `lstat`; numeric `O_RDONLY | O_NOFOLLOW | O_CLOEXEC`; `fstat` identity/mode/UID checks; exactly one bounded `read`; `finally` close; and fixed `AkuaCliError` codes/messages that never interpolate the supplied path. +- [x] **Step 4: Re-run `bun test test/secure-token-file.test.ts`** and confirm all focused cases pass. +- [x] **Step 5: Commit the reader and its tests** with a focused conventional commit. + +### Task 2: Fixed transport and response projection + +**Files:** Create `src/runtime/platform-client.ts`; create `test/agent-os-loader.test.ts`. + +**Interfaces:** Produce `submitHcloudProviderLoad({ workspace, callerToken, providerToken, idempotencyKey }, dependencies?): Promise`. The request is one POST to the fixed route; dependency injection may replace transport for a local fake HTTPS server, never expose a CLI option. + +- [x] **Step 1: Write failing transport tests** using synthetic sentinels and a fake HTTPS server/transport: fixed method/path/headers/body shape, one submission, idempotency-key relay, response field allowlist, server fixed-error projection, no retry after a thrown/ambiguous submission, and byte clearing after both success and failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts`** and confirm the expected module/function failures. +- [x] **Step 3: Implement the minimal client** with a fixed HTTPS URL, manual byte-oriented JSON quoting, one `fetch` invocation, no debug/body logging, `finally` buffer overwrites, and strict success/failure schema projection that discards unknown server fields. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts`** and confirm the focused transport suite passes. +- [x] **Step 5: Commit the transport and focused tests** with a focused conventional commit. + +### Task 3: Command, protected auth, and routing + +**Files:** Modify `src/commands/auth.ts`, `src/commands/agent-os.ts`, `src/bin/akua.ts`, `test/agent-os-loader.test.ts`, and `test/cli.test.ts`. + +**Interfaces:** `agentOsView(argv, env, dependencies?): Promise` accepts only the exact command flags. `readProtectedCallerToken(env)` returns the locally stored caller token or a fixed auth error and never reads `AKUA_API_TOKEN` for this command. + +- [x] **Step 1: Write failing command tests** for required explicit workspace/token file, safe rejection of token/stdin/positionals/env/profile/API URL flags, config-only auth, auth-before-file/network ordering, file-before-network ordering, no child-process calls, stdout/stderr/error sentinel absence, fixed exit codes, success allowlist, and a fake-server revoke then post-revoke failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm failures are caused by the absent command behavior. +- [x] **Step 3: Implement the minimal orchestration**: parse only the two flags, reject environment auth, read protected config auth before opening the provider file, create one UUID idempotency key, read/close token file, call fixed transport once, and emit only the projected result through the existing renderer. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm all loader and routing tests pass. +- [x] **Step 5: Commit the command/routing slice** with a focused conventional commit. + +### Task 4: Contract documentation and full verification + +**Files:** Modify `docs/architecture.md`; this plan; optionally `AGENTS.md` only if the repository lacks durable command/testing guidance. + +- [x] **Step 1: Write failing documentation/contract assertions** where practical (route absence from generated registry; command appears in help; no deprecated provider-input path appears in the architecture document). +- [x] **Step 2: Run the focused assertions** and observe the expected red condition before any production behavior they cover. +- [x] **Step 3: Update architecture documentation** with the exact non-secret route, thin-client/server ownership, safe file contract, no-retention constraints, cnap-first release ordering, PR #21 boundary, and `0.9.0` coordination. +- [x] **Step 4: Run `mise run check` and `mise run build:binary`**; inspect the compiled help and the full test output for failures or sentinel leakage. +- [x] **Step 5: Commit remaining documentation and run `git diff --check`, `git status --short`, and the full validation commands** before reporting completion. + +## Self-Review + +- [x] Coverage: Tasks 1–3 cover every local CLI requirement: exact flags, protected config authentication, one bounded secure read, fixed single HTTPS submission, buffer clear, output projection, ordering, idempotency, no retry, revocation, and synthetic-only security regression tests. Task 4 covers architecture/release boundaries and whole-repo validation. +- [x] Dependency: [cnap #540](https://github.com/akua-dev/cnap/issues/540) is the canonical server task. Its HCloud project-identity security gate and exact released route contract are mandatory before any CLI PR, merge, release, or Phase-A invocation. +- [x] Placeholder scan: no `TODO`, `TBD`, “implement later,” or undefined interface names remain. +- [x] Consistency: `readSecureTokenFile` produces the mutable bytes consumed by `submitHcloudProviderLoad`; `agentOsView` is the only caller and clears/handles failure before rendering. diff --git a/src/bin/akua.ts b/src/bin/akua.ts index 987ce46..bd8762e 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { authView } from "../commands/auth"; +import { agentOsView } from "../commands/agent-os"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors"; @@ -47,6 +48,10 @@ async function route(argv: readonly string[], env: Record arg.startsWith("-")); if (unknownFlag) { throw usageError(`Unknown flag: ${flagName(unknownFlag)}`); @@ -95,6 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", + " akua agent-os load-hcloud-provider --workspace --token-file [--expected-ssh-key-fingerprint [--expected-ssh-key-name ]]", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts new file mode 100644 index 0000000..9885743 --- /dev/null +++ b/src/commands/agent-os.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; + +import { readProtectedCallerToken } from "./auth"; +import { usageError, AkuaCliError } from "../runtime/errors"; +import { + submitHcloudProviderLoad, + type HCloudProviderLoadInput, + type HCloudProviderLoadResult, +} from "../runtime/platform-client"; +import { clearBytes, readSecureTokenFile } from "../runtime/secure-token-file"; +import type { RenderEnvelope } from "../runtime/render"; + +export interface AgentOsDependencies { + readProtectedCallerToken(env: Record): Promise; + readSecureTokenFile(path: string): Promise; + submit(input: HCloudProviderLoadInput): Promise; + createIdempotencyKey(): string; +} + +const productionDependencies: AgentOsDependencies = { + readProtectedCallerToken, + readSecureTokenFile, + submit: submitHcloudProviderLoad, + createIdempotencyKey: randomUUID, +}; + +export async function agentOsView( + argv: readonly string[], + env: Record, + dependencies: AgentOsDependencies = productionDependencies, +): Promise { + if (argv[0] !== "load-hcloud-provider") { + throw usageError("Unknown agent-os subcommand."); + } + const options = parseLoadHcloudProviderFlags(argv.slice(1)); + if (env.AKUA_API_TOKEN !== undefined && env.AKUA_API_TOKEN !== "") { + throw new AkuaCliError({ + type: "usage_error", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + message: "Environment authentication is not accepted for this provider loader.", + exitCode: 2, + }); + } + + const callerToken = await dependencies.readProtectedCallerToken(env); + const providerToken = await dependencies.readSecureTokenFile(options.tokenFile); + try { + const data = await dependencies.submit({ + workspace: options.workspace, + callerToken, + providerToken, + expectedSshKeyFingerprint: options.expectedSshKeyFingerprint, + expectedSshKeyName: options.expectedSshKeyName, + idempotencyKey: dependencies.createIdempotencyKey(), + }); + return { + command: "akua agent-os load-hcloud-provider", + data, + }; + } finally { + clearBytes(providerToken); + } +} + +interface LoadHcloudProviderOptions { + workspace: string; + tokenFile: string; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; +} + +function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { + let workspace: string | undefined; + let tokenFile: string | undefined; + let expectedSshKeyFingerprint: string | undefined; + let expectedSshKeyName: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("-")) { + throw usageError("Unexpected argument for agent-os provider loader."); + } + const name = flagName(value); + if ( + name !== "--workspace" && + name !== "--token-file" && + name !== "--expected-ssh-key-fingerprint" && + name !== "--expected-ssh-key-name" + ) { + throw usageError("Unsupported agent-os provider loader option."); + } + const parsed = readFlagValue(argv, index, name); + if (parsed.value === undefined || parsed.value === "") { + throw usageError(`Missing value for ${name}.`); + } + if (parsed.consumedNext) { + index += 1; + } + if (name === "--workspace") { + if (workspace !== undefined) { + throw usageError("The workspace may be specified only once."); + } + workspace = parsed.value; + } else if (name === "--token-file") { + if (tokenFile !== undefined) { + throw usageError("The token file may be specified only once."); + } + tokenFile = parsed.value; + } else if (name === "--expected-ssh-key-fingerprint") { + if (expectedSshKeyFingerprint !== undefined) { + throw usageError("The expected SSH key fingerprint may be specified only once."); + } + expectedSshKeyFingerprint = parsed.value; + } else { + if (expectedSshKeyName !== undefined) { + throw usageError("The expected SSH key name may be specified only once."); + } + expectedSshKeyName = parsed.value; + } + } + if (workspace === undefined) { + throw usageError("Missing required --workspace flag."); + } + if (tokenFile === undefined) { + throw usageError("Missing required --token-file flag."); + } + if (tokenFile === "-" || !isAbsolute(tokenFile)) { + throw usageError("The provider token must be supplied through an absolute file path."); + } + if (expectedSshKeyFingerprint !== undefined && !isSafeExpectedSshField(expectedSshKeyFingerprint)) { + throw usageError("The expected SSH key fingerprint is malformed."); + } + if (expectedSshKeyName !== undefined && !isSafeExpectedSshField(expectedSshKeyName)) { + throw usageError("The expected SSH key name is malformed."); + } + if (expectedSshKeyName !== undefined && expectedSshKeyFingerprint === undefined) { + throw usageError("--expected-ssh-key-name requires --expected-ssh-key-fingerprint."); + } + return { workspace, tokenFile, expectedSshKeyFingerprint, expectedSshKeyName }; +} + +function isSafeExpectedSshField(value: string): boolean { + return /^[\x21-\x7e]{1,200}$/.test(value); +} + +function readFlagValue( + argv: readonly string[], + index: number, + flag: string, +): { value: string | undefined; consumedNext: boolean } { + const value = argv[index]; + if (value === flag) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("-")) { + return { value: undefined, consumedNext: false }; + } + return { value: next, consumedNext: true }; + } + return { value: value.slice(flag.length + 1), consumedNext: false }; +} + +function flagName(value: string): string { + return value.includes("=") ? value.slice(0, value.indexOf("=")) : value; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 64fd13e..18a78be 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -41,6 +41,28 @@ export async function authView(argv: readonly string[], env: Record): Promise { + if (hasEnvToken(env)) { + throw new AkuaCliError({ + type: "usage_error", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + message: "Environment authentication is not accepted for this provider loader.", + exitCode: 2, + }); + } + + const token = (await readConfig(resolveConfigPath(env))).token; + if (typeof token !== "string" || token === "") { + throw new AkuaCliError({ + type: "authentication_error", + code: "AKUA_LOADER_AUTH_REQUIRED", + message: "A protected local Akua credential is required for this provider loader.", + exitCode: 3, + }); + } + return token; +} + async function loginView(argv: readonly string[], env: Record): Promise { const token = parseLoginFlags(argv); const configPath = resolveConfigPath(env); diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts new file mode 100644 index 0000000..ca9296b --- /dev/null +++ b/src/runtime/platform-client.ts @@ -0,0 +1,235 @@ +import { AkuaCliError } from "./errors"; +import { clearBytes } from "./secure-token-file"; + +const HCloudProviderLoadUrl = "https://api.akua.dev/v1/agent_os/hcloud_provider_loads"; +const responseFields = new Set([ + "loader_id", + "attestation_id", + "secret_id", + "secret_version_id", + "compute_config_id", + "expected_ssh_key_fingerprint", +]); + +export interface HCloudProviderLoadRequest { + url: typeof HCloudProviderLoadUrl; + method: "POST"; + headers: Readonly>; + body: Uint8Array; +} + +interface HCloudProviderLoadResponse { + status: number; + body: unknown; +} + +export interface HCloudProviderLoadDependencies { + send(request: HCloudProviderLoadRequest): Promise; +} + +export interface HCloudProviderLoadInput { + workspace: string; + callerToken: string; + providerToken: Uint8Array; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; + idempotencyKey: string; +} + +export type HCloudProviderLoadResult = Readonly>; + +export class HCloudProviderLoadError extends AkuaCliError {} + +const productionDependencies: HCloudProviderLoadDependencies = { send: sendHttpsRequest }; + +export async function submitHcloudProviderLoad( + input: HCloudProviderLoadInput, + dependencies: HCloudProviderLoadDependencies = productionDependencies, +): Promise { + let body: Uint8Array | undefined; + try { + body = encodeProviderTokenBody(input.providerToken, input.expectedSshKeyFingerprint, input.expectedSshKeyName); + const response = await dependencies.send({ + url: HCloudProviderLoadUrl, + method: "POST", + headers: { + authorization: `Bearer ${input.callerToken}`, + "akua-context": input.workspace, + "idempotency-key": input.idempotencyKey, + "content-type": "application/json", + }, + body, + }); + if (response.status !== 201) { + throw serverRejectedError(response.status, response.body); + } + return allowlistedResult(response.body); + } catch (error) { + if (error instanceof HCloudProviderLoadError) { + throw error; + } + throw new HCloudProviderLoadError({ + type: "transport_error", + code: "AKUA_LOADER_SUBMISSION_UNKNOWN", + message: "The provider-load submission outcome is unknown and was not retried.", + exitCode: 1, + }); + } finally { + clearBytes(input.providerToken); + if (body) { + clearBytes(body); + } + } +} + +async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body as unknown as BodyInit, + }); + const text = await response.text(); + if (text.length > 16_384) { + throw invalidServerResponseError(); + } + try { + return { status: response.status, body: text === "" ? {} : JSON.parse(text) }; + } catch { + throw invalidServerResponseError(); + } +} + +function encodeProviderTokenBody( + providerToken: Uint8Array, + expectedSshKeyFingerprint: string | undefined, + expectedSshKeyName: string | undefined, +): Uint8Array { + const encoder = new TextEncoder(); + const fields: Array = [ + [encoder.encode("provider_token"), providerToken], + ...(expectedSshKeyFingerprint === undefined + ? [] + : [[encoder.encode("expected_ssh_key_fingerprint"), encoder.encode(expectedSshKeyFingerprint)] as const]), + ...(expectedSshKeyName === undefined + ? [] + : [[encoder.encode("expected_ssh_key_name"), encoder.encode(expectedSshKeyName)] as const]), + ]; + let encodedLength = 2 + Math.max(0, fields.length - 1); + for (const [name, value] of fields) { + encodedLength += name.byteLength + 5; + for (const byte of value) { + encodedLength += escapedLength(byte); + } + } + const body = new Uint8Array(encodedLength); + let cursor = 0; + body[cursor++] = 123; + for (const [index, [name, value]] of fields.entries()) { + if (index > 0) { + body[cursor++] = 44; + } + body[cursor++] = 34; + body.set(name, cursor); + cursor += name.byteLength; + body.set([34, 58, 34], cursor); + cursor += 3; + cursor = writeEscapedBytes(body, cursor, value); + body[cursor++] = 34; + } + body[cursor] = 125; + return body; +} + +function writeEscapedBytes(body: Uint8Array, start: number, bytes: Uint8Array): number { + let cursor = start; + for (const byte of bytes) { + if (byte === 34 || byte === 92) { + body[cursor++] = 92; + body[cursor++] = byte; + } else if (byte === 8) { + body.set([92, 98], cursor); + cursor += 2; + } else if (byte === 9) { + body.set([92, 116], cursor); + cursor += 2; + } else if (byte === 10) { + body.set([92, 110], cursor); + cursor += 2; + } else if (byte === 12) { + body.set([92, 102], cursor); + cursor += 2; + } else if (byte === 13) { + body.set([92, 114], cursor); + cursor += 2; + } else if (byte < 32) { + body.set([92, 117, 48, 48, hex(byte >> 4), hex(byte & 15)], cursor); + cursor += 6; + } else { + body[cursor++] = byte; + } + } + return cursor; +} + +function escapedLength(byte: number): number { + if (byte === 34 || byte === 92 || byte === 8 || byte === 9 || byte === 10 || byte === 12 || byte === 13) { + return 2; + } + return byte < 32 ? 6 : 1; +} + +function hex(value: number): number { + return value < 10 ? 48 + value : 87 + value; +} + +function allowlistedResult(body: unknown): HCloudProviderLoadResult { + if (!isRecord(body)) { + throw invalidServerResponseError(); + } + const result: Record = {}; + for (const [field, value] of Object.entries(body)) { + if (!responseFields.has(field)) { + continue; + } + if (typeof value === "string" || (field === "expected_ssh_key_fingerprint" && value === null)) { + result[field] = value; + } + } + if ( + typeof result.loader_id !== "string" || + typeof result.attestation_id !== "string" || + typeof result.secret_id !== "string" || + typeof result.secret_version_id !== "string" || + typeof result.compute_config_id !== "string" || + (typeof result.expected_ssh_key_fingerprint !== "string" && result.expected_ssh_key_fingerprint !== null) + ) { + throw invalidServerResponseError(); + } + return result; +} + +function serverRejectedError(status: number, body: unknown): HCloudProviderLoadError { + const error = isRecord(body) && isRecord(body.error) ? body.error : {}; + const requestId = typeof error.request_id === "string" ? error.request_id : undefined; + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status, + requestId, + message: "The provider-load server rejected the request.", + exitCode: status === 401 || status === 403 ? 3 : 1, + }); +} + +function invalidServerResponseError(): HCloudProviderLoadError { + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_RESPONSE_INVALID", + message: "The provider-load server returned an invalid response.", + exitCode: 1, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/runtime/secure-token-file.ts b/src/runtime/secure-token-file.ts new file mode 100644 index 0000000..17e3890 --- /dev/null +++ b/src/runtime/secure-token-file.ts @@ -0,0 +1,160 @@ +import { constants } from "node:fs"; +import { lstat, open, type FileHandle } from "node:fs/promises"; +import { isAbsolute } from "node:path"; + +import { AkuaCliError } from "./errors"; + +export const MAX_PROVIDER_TOKEN_BYTES = 4096; + +// Bun's Node type declarations omit O_CLOEXEC although its Unix open syscall +// accepts it. Linux uses 0x80000; Darwin and the supported BSD target use +// 0x1000000. This command is intentionally Unix-only. +const O_CLOEXEC = process.platform === "linux" ? 0x80000 : 0x1000000; +export const SECURE_OPEN_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | O_CLOEXEC; + +export interface SecureTokenFileStat { + dev: number; + ino: number; + uid: number; + mode: number; + size: number; + isFile(): boolean; + isSymbolicLink(): boolean; +} + +interface SecureTokenFileHandle { + stat(): Promise; + read(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<{ bytesRead: number }>; + close(): Promise; +} + +export interface SecureTokenFileDependencies { + getuid(): number; + lstat(path: string): Promise; + open(path: string, flags: number): Promise; +} + +const productionDependencies: SecureTokenFileDependencies = { + getuid: () => { + if (typeof process.getuid !== "function") { + throw unsafeFileError(); + } + return process.getuid(); + }, + lstat: async (path) => lstat(path), + open: async (path, flags) => open(path, flags), +}; + +export async function readSecureTokenFile( + path: string, + dependencies: SecureTokenFileDependencies = productionDependencies, +): Promise { + if (!isAbsolute(path)) { + throw new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_PATH_INVALID", + message: "The provider token file must use an absolute path.", + exitCode: 2, + }); + } + + const preOpen = await safeLstat(path, dependencies); + validateStat(preOpen, dependencies.getuid()); + validateSize(preOpen.size); + + let handle: SecureTokenFileHandle | undefined; + try { + handle = await dependencies.open(path, SECURE_OPEN_FLAGS); + const opened = await safeFstat(handle); + validateStat(opened, dependencies.getuid()); + if (!sameFile(preOpen, opened)) { + throw changedFileError(); + } + validateSize(opened.size); + + const bytes = new Uint8Array(opened.size); + try { + const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0); + if (bytesRead !== bytes.byteLength) { + clearBytes(bytes); + throw unsafeFileError(); + } + return bytes; + } catch (error) { + clearBytes(bytes); + throw error; + } + } catch (error) { + if (error instanceof AkuaCliError) { + throw error; + } + throw unsafeFileError(); + } finally { + await handle?.close().catch(() => undefined); + } +} + +export function clearBytes(bytes: Uint8Array): void { + bytes.fill(0); +} + +async function safeLstat(path: string, dependencies: SecureTokenFileDependencies): Promise { + try { + return await dependencies.lstat(path); + } catch { + throw unsafeFileError(); + } +} + +async function safeFstat(handle: SecureTokenFileHandle): Promise { + try { + return await handle.stat(); + } catch { + throw unsafeFileError(); + } +} + +function validateStat(stat: SecureTokenFileStat, expectedUid: number): void { + if (stat.isSymbolicLink() || !stat.isFile() || stat.uid !== expectedUid || (stat.mode & 0o777) !== 0o600) { + throw unsafeFileError(); + } +} + +function validateSize(size: number): void { + if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PROVIDER_TOKEN_BYTES) { + throw new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", + message: "The provider token file size is invalid.", + exitCode: 2, + }); + } +} + +function sameFile(before: SecureTokenFileStat, opened: SecureTokenFileStat): boolean { + return ( + before.dev === opened.dev && + before.ino === opened.ino && + before.uid === opened.uid && + before.mode === opened.mode && + before.size === opened.size + ); +} + +function unsafeFileError(): AkuaCliError { + return new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + message: "The provider token file does not meet the required security checks.", + exitCode: 2, + }); +} + +function changedFileError(): AkuaCliError { + return new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", + message: "The provider token file changed while it was being opened.", + exitCode: 2, + }); +} diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts new file mode 100644 index 0000000..c465414 --- /dev/null +++ b/test/agent-os-loader.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; + +import { agentOsView, type AgentOsDependencies } from "../src/commands/agent-os"; +import { + HCloudProviderLoadError, + type HCloudProviderLoadInput, + submitHcloudProviderLoad, + type HCloudProviderLoadRequest, +} from "../src/runtime/platform-client"; +import { renderError, renderSuccess } from "../src/runtime/render"; + +const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); +const SYNTHETIC_ECHO = "synthetic-response-field"; +const PROJECT_ANCHOR_FINGERPRINT = `SHA256:${"A".repeat(43)}`; +const PROJECT_ANCHOR_NAME = "agent-os-production-anchor"; + +describe("submitHcloudProviderLoad", () => { + test("submits one fixed-route request without an optional anchor, explicit context, and relayed idempotency", async () => { + const requests: HCloudProviderLoadRequest[] = []; + let bodyHasProviderField = false; + + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000001", + }, + { + send: async (request) => { + requests.push(request); + const body = new TextDecoder().decode(request.body); + bodyHasProviderField = + body.includes('"provider_token":"synthetic"') && + !body.includes("expected_ssh_key_fingerprint") && + !body.includes("expected_ssh_key_name"); + return successResponse(); + }, + }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://api.akua.dev/v1/agent_os/hcloud_provider_loads"); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers).toMatchObject({ + authorization: "Bearer caller-auth-fixture", + "akua-context": "ws_synthetic", + "idempotency-key": "00000000-0000-4000-8000-000000000001", + "content-type": "application/json", + }); + expect(bodyHasProviderField).toBe(true); + expect(result).toEqual({ + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", + secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", + compute_config_id: "cfg_synthetic", + expected_ssh_key_fingerprint: null, + }); + }); + + test("relays a predeclared optional SSH anchor without deriving identity from provider bytes", async () => { + let bodyHasOptionalAnchor = false; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + expectedSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + expectedSshKeyName: PROJECT_ANCHOR_NAME, + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async (request) => { + const body = new TextDecoder().decode(request.body); + bodyHasOptionalAnchor = + body.includes(`"expected_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`) && + body.includes(`"expected_ssh_key_name":"${PROJECT_ANCHOR_NAME}"`); + return successResponse(); + }, + }, + ); + + expect(bodyHasOptionalAnchor).toBe(true); + }); + + test("projects success through a strict response allowlist", async () => { + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async () => ({ + status: 201, + body: { + ...successResponse().body, + echoed_provider_token: SYNTHETIC_ECHO, + project_identity_attestation: "synthetic-attestation-echo", + transaction_id: "txn_synthetic", + nested: { secret: SYNTHETIC_ECHO }, + }, + }), + }, + ); + + expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); + expect(result).not.toHaveProperty("echoed_provider_token"); + expect(result).not.toHaveProperty("project_identity_attestation"); + expect(result).not.toHaveProperty("transaction_id"); + expect(result).not.toHaveProperty("nested"); + }); + + test("clears provider and request byte buffers after a successful send", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let submittedBody: Uint8Array | undefined; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000003", + }, + { + send: async (request) => { + submittedBody = request.body; + return successResponse(); + }, + }, + ); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect([...(submittedBody ?? [])].every((byte) => byte === 0)).toBe(true); + }); + + test("makes no retry after an uncertain transport failure and clears buffers", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let attempts = 0; + + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000004", + }, + { + send: async () => { + attempts += 1; + throw new Error("synthetic transport interruption"); + }, + }, + ), + ).rejects.toMatchObject({ code: "AKUA_LOADER_SUBMISSION_UNKNOWN" }); + + expect(attempts).toBe(1); + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + }); + + test("projects server failures to fixed safe fields", async () => { + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000005", + }, + { + send: async () => ({ + status: 403, + body: { + error: { + code: "SERVER_PRIVATE_CODE", + message: SYNTHETIC_ECHO, + request_id: "req_synthetic_denied", + secret_id: "sec_synthetic", + }, + }, + }), + }, + ), + ).rejects.toMatchObject({ + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + } satisfies Partial); + }); +}); + +describe("agent-os load-hcloud-provider", () => { + test("accepts no anchor and rejects malformed anchor inputs before auth, file, or network access", async () => { + const dependencies = fakeCommandDependencies(); + const rejectedValue = "synthetic-argv-value"; + + await expect(agentOsView(commandArgs(), {}, dependencies.dependencies)).resolves.toMatchObject({ + data: successResponse().body, + }); + await expect( + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--expected-ssh-key-fingerprint", + "bad\u0000fingerprint", + ], + {}, + dependencies.dependencies, + ), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + await expect( + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--expected-ssh-key-name", + PROJECT_ANCHOR_NAME, + ], + {}, + dependencies.dependencies, + ), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + + const error = await captureError(() => agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies)); + expect(renderError(error, "json")).not.toContain(rejectedValue); + expect(dependencies.fileReads).toBe(1); + expect(dependencies.submissions).toBe(1); + }); + + test("rejects environment caller authentication before config, file, or network access", async () => { + const dependencies = fakeCommandDependencies(); + + await expect( + agentOsView(commandArgs(), { AKUA_API_TOKEN: "synthetic-environment-auth" }, dependencies.dependencies), + ).rejects.toMatchObject({ code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN" }); + expect(dependencies.events).toEqual([]); + }); + + test("authenticates from protected config before reading the provider file and relays idempotency once", async () => { + const dependencies = fakeCommandDependencies(); + + const view = await agentOsView(commandArgs(), {}, dependencies.dependencies); + + expect(dependencies.events).toEqual(["auth", "file", "network"]); + expect(dependencies.submissions).toBe(1); + expect(dependencies.input?.workspace).toBe("ws_synthetic"); + expect(dependencies.input?.expectedSshKeyFingerprint).toBeUndefined(); + expect(dependencies.input?.expectedSshKeyName).toBeUndefined(); + expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); + expect(view.data).toEqual(successResponse().body); + expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); + }); + + test("clears the reader bytes and renders only fixed safe failures", async () => { + const providerToken = new Uint8Array([112, 114, 111, 118, 105, 100, 101, 114, 45, 115, 101, 99, 114, 101, 116]); + const providerMarker = "provider-secret"; + const dependencies = fakeCommandDependencies({ + readSecureTokenFile: async () => providerToken, + submit: async () => { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + message: "The provider-load server rejected the request.", + }); + }, + }); + + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect(renderError(error, "json")).not.toContain(providerMarker); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + }); + + test("fails post-revocation against the fake HTTPS server without a retry or leaked prior result", async () => { + const server = new FakeHttpsServer(); + const dependencies = fakeCommandDependencies({ + submit: async (input) => server.submit(input), + }); + + const first = await agentOsView(commandArgs(), {}, dependencies.dependencies); + server.revoke(); + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect(first.data).toEqual(successResponse().body); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + expect(server.requests).toEqual([ + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + ]); + }); + + test("contains no child-process or shell fallback implementation", async () => { + const [commandSource, transportSource] = await Promise.all([ + readFile("src/commands/agent-os.ts", "utf8"), + readFile("src/runtime/platform-client.ts", "utf8"), + ]); + + for (const source of [commandSource, transportSource]) { + expect(source).not.toContain("Bun.spawn"); + expect(source).not.toContain("node:child_process"); + expect(source).not.toContain("curl"); + } + }); +}); + +function successResponse() { + return { + status: 201, + body: { + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", + secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", + compute_config_id: "cfg_synthetic", + expected_ssh_key_fingerprint: null, + }, + }; +} + +function commandArgs(): string[] { + return [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + ]; +} + +function fakeCommandDependencies(overrides: Partial = {}) { + const events: string[] = []; + let fileReads = 0; + let submissions = 0; + let input: HCloudProviderLoadInput | undefined; + const dependencies: AgentOsDependencies = { + readProtectedCallerToken: async () => { + events.push("auth"); + return "caller-auth-fixture"; + }, + readSecureTokenFile: async () => { + events.push("file"); + fileReads += 1; + return SYNTHETIC_TOKEN.slice(); + }, + submit: async (submitted) => { + events.push("network"); + submissions += 1; + input = submitted; + return successResponse().body; + }, + createIdempotencyKey: () => "00000000-0000-4000-8000-000000000006", + ...overrides, + }; + return { + dependencies, + events, + get fileReads() { + return fileReads; + }, + get submissions() { + return submissions; + }, + get input() { + return input; + }, + }; +} + +async function captureError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error as HCloudProviderLoadError; + } + throw new Error("Expected the loader action to fail."); +} + +class FakeHttpsServer { + readonly requests: Array<{ workspace: string; tokenLength: number }> = []; + #revoked = false; + + revoke(): void { + this.#revoked = true; + } + + async submit(input: HCloudProviderLoadInput) { + this.requests.push({ workspace: input.workspace, tokenLength: input.providerToken.byteLength }); + if (this.#revoked) { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_revoked", + message: "The provider-load server rejected the request.", + }); + } + return successResponse().body; + } +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 6484209..2858b5d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { authView } from "../src/commands/auth"; +import { authView, readProtectedCallerToken } from "../src/commands/auth"; import { renderSuccess } from "../src/runtime/render"; describe("akua entrypoint", () => { @@ -18,6 +18,19 @@ describe("akua entrypoint", () => { }); }); + test("documents the compiled Agent OS provider loader without exposing a credential input", async () => { + const { stdout, exitCode } = await runAkua(["--help", "--json"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("akua agent-os load-hcloud-provider"); + expect(stdout).toContain("--token-file"); + expect(stdout).toContain("--expected-ssh-key-fingerprint"); + expect(stdout).toContain("--expected-ssh-key-name"); + expect(stdout).not.toContain("--project-identity-attestation"); + expect(stdout).not.toContain("--project-anchor-ssh-key-fingerprint"); + expect(stdout).not.toContain("--token <"); + }); + test("fails invalid explicit output modes before routing", async () => { const { stdout, exitCode } = await runAkua(["--output", "yaml", "--version"]); expect(exitCode).toBe(2); @@ -414,6 +427,25 @@ describe("akua entrypoint", () => { await rm(home, { recursive: true, force: true }); } }); + + test("loader caller authentication reads only the protected local config", async () => { + const home = await makeTempHome(); + try { + const configDir = join(home, ".config", "akua"); + const configPath = join(configDir, "config.json"); + await mkdir(configDir, { recursive: true, mode: 0o700 }); + await writeFile(configPath, JSON.stringify({ token: "caller-auth-fixture" }), { mode: 0o600 }); + await chmod(configDir, 0o700); + await chmod(configPath, 0o600); + + await expect(readProtectedCallerToken({ HOME: home })).resolves.toBe("caller-auth-fixture"); + await expect(readProtectedCallerToken({ HOME: home, AKUA_API_TOKEN: "environment-auth-fixture" })).rejects.toMatchObject({ + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); }); async function runAkua(args: readonly string[], env: Record = {}) { diff --git a/test/secure-token-file.test.ts b/test/secure-token-file.test.ts new file mode 100644 index 0000000..7438bd1 --- /dev/null +++ b/test/secure-token-file.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { constants } from "node:fs"; +import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { + MAX_PROVIDER_TOKEN_BYTES, + readSecureTokenFile, + SECURE_OPEN_FLAGS, + type SecureTokenFileDependencies, + type SecureTokenFileStat, +} from "../src/runtime/secure-token-file"; + +const SYNTHETIC_BYTES = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); +const UID = 501; + +describe("readSecureTokenFile", () => { + test("reads a caller-owned 0600 regular file through one descriptor read and closes it", async () => { + const fixture = makeFakeFixture(); + + const result = await readSecureTokenFile("/synthetic/provider", fixture.dependencies); + + expect(Array.from(result)).toEqual(Array.from(SYNTHETIC_BYTES)); + expect(fixture.reads).toBe(1); + expect(fixture.closed).toBe(1); + expect(fixture.flags).toBe(SECURE_OPEN_FLAGS); + expect(SECURE_OPEN_FLAGS & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); + const closeOnExec = process.platform === "linux" ? 0x80000 : 0x1000000; + expect(SECURE_OPEN_FLAGS & closeOnExec).toBe(closeOnExec); + }); + + test("rejects a relative file path without opening it", async () => { + const fixture = makeFakeFixture(); + + await expect(readSecureTokenFile("relative/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_PATH_INVALID", + }); + expect(fixture.opens).toBe(0); + }); + + test("rejects a symlink before opening it", async () => { + const fixture = makeFakeFixture({ pre: fakeStat({ symbolicLink: true }) }); + + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + }); + expect(fixture.opens).toBe(0); + }); + + test("rejects a directory, FIFO/device-like entry, wrong owner, and wrong mode", async () => { + for (const pre of [ + fakeStat({ regular: false }), + fakeStat({ regular: false }), + fakeStat({ uid: UID + 1 }), + fakeStat({ mode: 0o640 }), + ]) { + const fixture = makeFakeFixture({ pre }); + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + }); + expect(fixture.opens).toBe(0); + } + }); + + test("rejects empty and oversized input before descriptor read", async () => { + for (const size of [0, MAX_PROVIDER_TOKEN_BYTES + 1]) { + const fixture = makeFakeFixture({ pre: fakeStat({ size }), opened: fakeStat({ size }) }); + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", + }); + expect(fixture.reads).toBe(0); + expect(fixture.closed).toBe(0); + } + }); + + test("rejects a descriptor substitution after lstat before it can be read", async () => { + const fixture = makeFakeFixture({ opened: fakeStat({ ino: 99 }) }); + + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", + }); + expect(fixture.reads).toBe(0); + expect(fixture.closed).toBe(1); + }); + + test("rejects a real symlink with no descriptor access", async () => { + const directory = await mkdtemp(join(process.cwd(), ".tmp-akua-loader-file-")); + try { + const target = join(directory, "target"); + const link = join(directory, "link"); + await writeFile(target, SYNTHETIC_BYTES, { mode: 0o600 }); + await chmod(target, 0o600); + await symlink(target, link); + + await expect(readSecureTokenFile(link)).rejects.toMatchObject({ code: "AKUA_LOADER_TOKEN_FILE_UNSAFE" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +function makeFakeFixture(options: { pre?: SecureTokenFileStat; opened?: SecureTokenFileStat } = {}) { + let opens = 0; + let reads = 0; + let closed = 0; + let flags: number | undefined; + const pre = options.pre ?? fakeStat(); + const opened = options.opened ?? fakeStat(); + const dependencies: SecureTokenFileDependencies = { + getuid: () => UID, + lstat: async () => pre, + open: async (_path, openFlags) => { + opens += 1; + flags = openFlags; + return { + stat: async () => opened, + read: async (buffer) => { + reads += 1; + buffer.set(SYNTHETIC_BYTES); + return { bytesRead: SYNTHETIC_BYTES.byteLength }; + }, + close: async () => { + closed += 1; + }, + }; + }, + }; + + return { + dependencies, + get opens() { + return opens; + }, + get reads() { + return reads; + }, + get closed() { + return closed; + }, + get flags() { + return flags; + }, + }; +} + +function fakeStat(options: { + dev?: number; + ino?: number; + uid?: number; + mode?: number; + size?: number; + regular?: boolean; + symbolicLink?: boolean; +} = {}): SecureTokenFileStat { + return { + dev: options.dev ?? 1, + ino: options.ino ?? 2, + uid: options.uid ?? UID, + mode: options.mode ?? 0o100600, + size: options.size ?? SYNTHETIC_BYTES.byteLength, + isFile: () => options.regular ?? true, + isSymbolicLink: () => options.symbolicLink ?? false, + }; +}