@forgezero/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ import { type NodeKeyPair } from '@forgezero/runtime/identity';
3
+ import { startAgent, type AgentOptions, type AttestationSource } from './socket';
4
+ import type { SecretCache } from './cache';
5
+ /**
6
+ * fz-agent — runs on the metal, driven over SSH before the platform exists and
7
+ * over HTTPS after it does. One implementation, two front doors: bootstrap is
8
+ * not a special case, it is the general case run first, because a tenant's own
9
+ * bare metal has no ForgeZero on it either.
10
+ *
11
+ * Everything it does is recorded in a hash-chained journal from its first
12
+ * command — before there is a database to log into. A flat log written then is
13
+ * a log anyone could have edited afterwards; the chain either verifies or
14
+ * visibly does not, and it is replayed into `audit_log` once the platform is
15
+ * operational.
16
+ *
17
+ * ## What it serves
18
+ *
19
+ * A unix socket that SIGNS and never surrenders the key. `@forgezero/vault`
20
+ * discovers `/run/forgezero.sock` and prefers it over `FORGEZERO_API_KEY`, so
21
+ * moving an application onto managed compute means deleting an environment
22
+ * variable rather than changing a line of code — and a machine that used to hold
23
+ * a signing seed now holds nothing an attacker can take.
24
+ *
25
+ * That preference was implemented in the client long before anything listened.
26
+ * See `socket.ts` for why it signs rather than handing back a token.
27
+ */
28
+ export declare const VERSION = "0.1.0";
29
+ export { startAgent, handleRequest } from './socket';
30
+ export type { AgentOptions, AttestationSource, Request, Response } from './socket';
31
+ export { createSecretCache, CacheError } from './cache';
32
+ export type { SecretCache, CacheOptions } from './cache';
33
+ /**
34
+ * The node seed, on disk, owner-only.
35
+ *
36
+ * Generated on first run rather than shipped: a seed baked into an image is a
37
+ * seed every guest from that image shares, and a fleet becomes one identity
38
+ * wearing many hostnames. Losing the file loses the node's identity, which is
39
+ * correct — re-enrolment is the recovery path, not a backup of the secret.
40
+ */
41
+ export declare function loadOrCreateSeed(path: string): Uint8Array;
42
+ export interface AgentConfig {
43
+ socketPath?: string;
44
+ seedPath?: string;
45
+ /** As the platform knows this node. Absent before enrolment. */
46
+ nodeKey?: string;
47
+ attestation?: AttestationSource;
48
+ /**
49
+ * Secrets cached in memory on this guest, if this node serves any.
50
+ *
51
+ * Never written to disk. That would hand an attacker with filesystem access
52
+ * every secret the guest has ever read — precisely the file that does not
53
+ * exist today, and the whole reason a node holds a signing key rather than a
54
+ * `.env`. A restart re-fetches; that costs one round trip.
55
+ */
56
+ cache?: SecretCache;
57
+ record?: AgentOptions['record'];
58
+ }
59
+ /**
60
+ * The path the agent LISTENS on, taken from the client that discovers it.
61
+ *
62
+ * Both ends declared this literal independently — `@forgezero/vault` as
63
+ * `DEFAULT_SOCKET` because discovery is part of its published contract, and the
64
+ * agent as its own constant because it is the thing that binds. Moving either
65
+ * one leaves an agent listening where nothing looks, and the symptom is every
66
+ * app silently falling back to an API key: strictly weaker, entirely working,
67
+ * and invisible.
68
+ *
69
+ * The published client is the authority here rather than the other way round.
70
+ * A tenant's application ships with `@forgezero/vault` and cannot be asked to
71
+ * follow a path this repository moved.
72
+ */
73
+ export declare const DEFAULT_SOCKET_PATH = "/run/forgezero.sock";
74
+ export declare const DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
75
+ /**
76
+ * Bring the agent up.
77
+ *
78
+ * Returns the keys alongside the server so an enrolment flow can read the public
79
+ * halves without going through the socket — the same values it would get there,
80
+ * from the process that already holds them.
81
+ */
82
+ export declare function runAgent(config?: AgentConfig): {
83
+ server: ReturnType<typeof startAgent>;
84
+ keys: NodeKeyPair;
85
+ nodeKey: string;
86
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * CI/CD on the compute, through the agent — and never around it.
3
+ *
4
+ * A deploy needs two things at the same time: the tenant's secrets, and the
5
+ * right to run commands on their machine. Every hosted CI answers that by
6
+ * copying the secrets INTO the runner, which is why a compromised build step is
7
+ * a compromised production credential everywhere that model is used.
8
+ *
9
+ * The agent already holds the secrets and already runs on the box. So the build
10
+ * happens here, the secrets are handed to the step's process and to nothing
11
+ * else, and nothing is ever written where a later step could read it.
12
+ *
13
+ * ## Attestation gates the run, not the read
14
+ *
15
+ * Reading a secret is gated by the socket's filesystem permissions, and that is
16
+ * proportionate — an application on this box is meant to read its own config.
17
+ * RUNNING A PIPELINE is different: it executes attacker-chosen commands if the
18
+ * pipeline definition is attacker-chosen, so the platform has to know it is
19
+ * talking to the machine it thinks it is.
20
+ *
21
+ * On an attested compute that is a hardware report. On one that is merely
22
+ * enrolled it is the node's hybrid signature, which proves possession of a key
23
+ * the platform issued and NOT what the machine is running. Both are honest
24
+ * postures and the difference is recorded on the run, so a tenant can require
25
+ * the stronger one — and `requireAttestation` is what makes that a refusal
26
+ * rather than a preference.
27
+ *
28
+ * ## Secrets never reach disk, and never reach the log
29
+ *
30
+ * They are passed as environment to the spawned process and redacted from
31
+ * captured output on the way back. The redaction is a last line, not the
32
+ * mechanism: a step that deliberately prints a secret has already been given it.
33
+ * What redaction buys is that a step which prints its environment while
34
+ * debugging does not put a production key in a log somebody ships to a vendor.
35
+ */
36
+ export type StepOutcome = 'ok' | 'failed' | 'skipped';
37
+ export interface PipelineStep {
38
+ name: string;
39
+ run: string;
40
+ /** Secret names this step needs. Nothing it does not name is in its env. */
41
+ secrets?: readonly string[];
42
+ /** Run even when an earlier step failed — cleanup, teardown, notifications. */
43
+ always?: boolean;
44
+ timeoutMs?: number;
45
+ }
46
+ export interface Pipeline {
47
+ name: string;
48
+ steps: readonly PipelineStep[];
49
+ /**
50
+ * Refuse to run at all unless the agent can produce a hardware attestation.
51
+ *
52
+ * A tenant deploying something that matters sets this. It is the difference
53
+ * between "a machine holding our node key ran this" and "a machine we can
54
+ * prove is running the image we expect ran this".
55
+ */
56
+ requireAttestation?: boolean;
57
+ }
58
+ export interface StepResult {
59
+ name: string;
60
+ outcome: StepOutcome;
61
+ exitCode: number | null;
62
+ /** Combined output, with every secret value replaced. */
63
+ log: string;
64
+ durationMs: number;
65
+ }
66
+ export interface RunResult {
67
+ pipeline: string;
68
+ ok: boolean;
69
+ /** How the machine authenticated itself for this run. Recorded, not inferred. */
70
+ assurance: 'attested' | 'enrolled';
71
+ steps: readonly StepResult[];
72
+ }
73
+ export declare class PipelineError extends Error {
74
+ readonly code: 'ATTESTATION_REQUIRED' | 'ATTESTATION_FAILED' | 'SECRET_MISSING';
75
+ constructor(code: 'ATTESTATION_REQUIRED' | 'ATTESTATION_FAILED' | 'SECRET_MISSING', message: string);
76
+ }
77
+ export interface RunOptions {
78
+ pipeline: Pipeline;
79
+ /** Reads one secret. The agent's replica, so this is a memory lookup. */
80
+ secret(name: string): Promise<string>;
81
+ /** Spawns a step. Injected, so a pipeline is testable without a shell. */
82
+ exec(input: {
83
+ command: string;
84
+ env: Record<string, string>;
85
+ timeoutMs?: number;
86
+ }): Promise<{
87
+ exitCode: number;
88
+ output: string;
89
+ }>;
90
+ /**
91
+ * Produces a hardware report, or throws. The agent's `attest` operation,
92
+ * which REFUSES when no source is configured rather than returning something
93
+ * attestation-shaped.
94
+ */
95
+ attest?: () => Promise<{
96
+ report: string;
97
+ source: string;
98
+ }>;
99
+ now?: () => number;
100
+ }
101
+ /**
102
+ * Replace every secret value wherever it appears.
103
+ *
104
+ * Longest first, so a value that contains another value does not leave the
105
+ * shorter one's suffix exposed after the longer one is replaced.
106
+ */
107
+ export declare function redact(text: string, values: readonly string[]): string;
108
+ export declare function runPipeline(options: RunOptions): Promise<RunResult>;
@@ -0,0 +1,81 @@
1
+ // src/pipeline.ts
2
+ class PipelineError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "PipelineError";
8
+ }
9
+ }
10
+ function redact(text, values) {
11
+ let out = text;
12
+ for (const value of [...values].sort((a, b) => b.length - a.length)) {
13
+ if (value.length < 8)
14
+ continue;
15
+ out = out.split(value).join("••••redacted••••");
16
+ }
17
+ return out;
18
+ }
19
+ async function runPipeline(options) {
20
+ const now = options.now ?? (() => Date.now());
21
+ const { pipeline } = options;
22
+ let assurance = "enrolled";
23
+ if (options.attest) {
24
+ try {
25
+ await options.attest();
26
+ assurance = "attested";
27
+ } catch (cause) {
28
+ if (pipeline.requireAttestation) {
29
+ throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
30
+ }
31
+ }
32
+ } else if (pipeline.requireAttestation) {
33
+ throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
34
+ }
35
+ const steps = [];
36
+ let failed = false;
37
+ for (const step of pipeline.steps) {
38
+ if (failed && !step.always) {
39
+ steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
40
+ continue;
41
+ }
42
+ const env = {};
43
+ const values = [];
44
+ for (const name of step.secrets ?? []) {
45
+ try {
46
+ const value = await options.secret(name);
47
+ env[name] = value;
48
+ values.push(value);
49
+ } catch {
50
+ throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
51
+ }
52
+ }
53
+ const started = now();
54
+ let exitCode;
55
+ let output;
56
+ try {
57
+ const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
58
+ exitCode = result.exitCode;
59
+ output = result.output;
60
+ } catch (cause) {
61
+ exitCode = -1;
62
+ output = cause.message;
63
+ }
64
+ const outcome = exitCode === 0 ? "ok" : "failed";
65
+ if (outcome === "failed")
66
+ failed = true;
67
+ steps.push({
68
+ name: step.name,
69
+ outcome,
70
+ exitCode,
71
+ log: redact(output, values),
72
+ durationMs: now() - started
73
+ });
74
+ }
75
+ return { pipeline: pipeline.name, ok: !failed, assurance, steps };
76
+ }
77
+ export {
78
+ runPipeline,
79
+ redact,
80
+ PipelineError
81
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,117 @@
1
+ /**
2
+ * How a machine becomes one that can run the agent — as DATA, with no transport.
3
+ *
4
+ * There are two ways this ever happens and they must not be two implementations:
5
+ *
6
+ * fz agent install an operator, on the box, executing locally
7
+ * the platform enrolling a compute, executing the same steps over SSH
8
+ *
9
+ * So nothing here connects to anything. A check is a command string plus a
10
+ * predicate over its output; a plan is an ordered list of them. `fz` runs them
11
+ * with `Bun.spawn`, the control plane runs them through an SSH session, and
12
+ * because it is the same list, a machine provisioned by hand and one provisioned
13
+ * by the platform end up identical.
14
+ *
15
+ * This was briefly two implementations — a `Check`-based preflight inside the
16
+ * API that could run remotely, and a separate local-only planner in the CLI that
17
+ * called `existsSync` directly and could not. Two answers to "is this box ready"
18
+ * is one answer nobody can trust, and the local one would have been the one that
19
+ * drifted, because it is the one somebody runs while debugging.
20
+ *
21
+ * ## Every command must be non-interactive and repeatable
22
+ *
23
+ * Non-interactive because there is nobody at the terminal on the SSH path, and a
24
+ * prompt there does not fail — it HANGS, which is much worse. Repeatable because
25
+ * enrolment is retried: a half-provisioned box that cannot be re-provisioned is
26
+ * a box somebody rebuilds by hand.
27
+ */
28
+ export interface Check {
29
+ /** Run over SSH or locally. Must be non-interactive and safe to repeat. */
30
+ command: string;
31
+ /** True when the output proves the property. */
32
+ satisfied: (stdout: string, exitCode: number) => boolean;
33
+ /** Shown when it fails. Says what to do, not merely what is wrong. */
34
+ remedy: string;
35
+ }
36
+ /**
37
+ * Semver-ish floor check tolerant of `v` prefixes and build suffixes.
38
+ *
39
+ * Version output is not a standard. `v1.2.3`, `1.2.3-rc1` and
40
+ * `bun 1.2.3 (abc123)` all mean the same thing to a human and nothing to a
41
+ * naive comparison, and a preflight that rejects a good box is a preflight
42
+ * people learn to skip.
43
+ */
44
+ export declare function atLeast(version: string, floor: string): boolean;
45
+ export type AgentMode = 'attested' | 'enrolled';
46
+ /**
47
+ * What the machine can prove about itself.
48
+ *
49
+ * Expressed as checks rather than as filesystem calls precisely so the answer is
50
+ * the same whether it was gathered on the box or over SSH. A control plane that
51
+ * decided `attested` from something it could only observe locally would be
52
+ * deciding it from nothing at all.
53
+ */
54
+ export declare const CAPABILITY_CHECKS: Record<string, Check> & {
55
+ snpGuest: Check;
56
+ systemd: Check;
57
+ bun: Check;
58
+ };
59
+ export type CapabilityId = 'snpGuest' | 'systemd' | 'bun';
60
+ /** The answers, however they were gathered. */
61
+ export type Capabilities = Record<CapabilityId, boolean>;
62
+ /**
63
+ * Which posture the agent runs in.
64
+ *
65
+ * Derived, never chosen. An operator picking `attested` would make attestation
66
+ * a claim, and a claim is the one thing an attestation must not be.
67
+ */
68
+ export declare const modeFor: (capabilities: Pick<Capabilities, "snpGuest">) => AgentMode;
69
+ export declare const reasonFor: (mode: AgentMode) => string;
70
+ export interface UnitOptions {
71
+ mode: AgentMode;
72
+ socketPath: string;
73
+ seedPath: string;
74
+ /** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
75
+ binPath?: string;
76
+ user?: string;
77
+ apiUrl?: string;
78
+ project?: string;
79
+ environment?: string;
80
+ }
81
+ /**
82
+ * A systemd unit for the agent.
83
+ *
84
+ * The hardening is not decoration. This process holds a node signing key and a
85
+ * project-scoped vault replica in memory, so the two things worth spending
86
+ * effort on are keeping that memory out of a core dump and keeping the
87
+ * filesystem read-only.
88
+ */
89
+ export declare function agentUnit(options: UnitOptions): string;
90
+ export interface Step {
91
+ /** Shell, non-interactive, safe to repeat. */
92
+ command: string;
93
+ /** What it is for, in an operator's words. */
94
+ label: string;
95
+ /** A step that may fail without failing the provision. */
96
+ optional?: boolean;
97
+ }
98
+ export interface ProvisionPlan {
99
+ mode: AgentMode;
100
+ reason: string;
101
+ unitPath: string;
102
+ unit: string;
103
+ socketPath: string;
104
+ user: string;
105
+ steps: readonly Step[];
106
+ }
107
+ export declare const UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
108
+ /**
109
+ * Everything that has to happen, in order, on a machine that is going to run
110
+ * the agent.
111
+ *
112
+ * The last two steps are the reason this is not just "write a file and start
113
+ * it". A unit that starts and immediately exits is `enabled` and
114
+ * `active (exited)`, which reads as success at a glance — so the plan checks the
115
+ * service is running AND that the socket applications actually need exists.
116
+ */
117
+ export declare function planProvision(options: UnitOptions): ProvisionPlan;
@@ -0,0 +1,128 @@
1
+ // src/provision.ts
2
+ function atLeast(version, floor) {
3
+ const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
4
+ const got = parse(version);
5
+ const want = parse(floor);
6
+ if (got.length === 0)
7
+ return false;
8
+ for (let index = 0;index < want.length; index += 1) {
9
+ const a = got[index] ?? 0;
10
+ const b = want[index] ?? 0;
11
+ if (a > b)
12
+ return true;
13
+ if (a < b)
14
+ return false;
15
+ }
16
+ return true;
17
+ }
18
+ var CAPABILITY_CHECKS = {
19
+ snpGuest: {
20
+ command: "test -e /dev/sev-guest && echo yes || echo no",
21
+ satisfied: (stdout) => stdout.trim() === "yes",
22
+ remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
23
+ },
24
+ systemd: {
25
+ command: "test -d /run/systemd/system && echo yes || echo no",
26
+ satisfied: (stdout) => stdout.trim() === "yes",
27
+ remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
28
+ },
29
+ bun: {
30
+ command: "bun --version 2>/dev/null || echo missing",
31
+ satisfied: (stdout) => atLeast(stdout, "1.1.0"),
32
+ remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
33
+ }
34
+ };
35
+ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
36
+ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
37
+ function agentUnit(options) {
38
+ const bin = options.binPath ?? "fz-agent";
39
+ const user = options.user ?? "forgezero";
40
+ const environment = [
41
+ `FZ_SOCKET_PATH=${options.socketPath}`,
42
+ `FZ_SEED_PATH=${options.seedPath}`,
43
+ `FZ_AGENT_MODE=${options.mode}`,
44
+ options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
45
+ options.project ? `FZ_PROJECT=${options.project}` : null,
46
+ options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null
47
+ ].filter((line) => line !== null);
48
+ return `[Unit]
49
+ Description=ForgeZero node agent (${options.mode})
50
+ Documentation=https://forgezero.net/docs/agent
51
+ After=network-online.target
52
+ Wants=network-online.target
53
+
54
+ [Service]
55
+ Type=simple
56
+ User=${user}
57
+ Group=${user}
58
+ ExecStart=${bin}
59
+ Restart=always
60
+ RestartSec=2
61
+
62
+ ${environment.map((line) => `Environment=${line}`).join(`
63
+ `)}
64
+
65
+ # The node seed and the vault replica live in this process's memory. A core dump
66
+ # writes both to disk, which is the one artefact this design exists to remove.
67
+ LimitCORE=0
68
+
69
+ # The socket is the entire interface: anything that can read it can read the
70
+ # scope. So it lives in a directory systemd creates with a known owner rather
71
+ # than wherever the process happened to have write access.
72
+ RuntimeDirectory=forgezero
73
+ RuntimeDirectoryMode=0710
74
+
75
+ NoNewPrivileges=true
76
+ PrivateTmp=true
77
+ ProtectSystem=strict
78
+ ProtectHome=true
79
+ ProtectKernelTunables=true
80
+ ProtectKernelModules=true
81
+ ProtectControlGroups=true
82
+ RestrictSUIDSGID=true
83
+ RestrictRealtime=true
84
+ MemoryDenyWriteExecute=true
85
+ LockPersonality=true
86
+ ReadWritePaths=${options.seedPath.replace(/\/[^/]+$/, "")}
87
+
88
+ [Install]
89
+ WantedBy=multi-user.target
90
+ `;
91
+ }
92
+ var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
93
+ function planProvision(options) {
94
+ const mode = options.mode;
95
+ const user = options.user ?? "forgezero";
96
+ const seedDir = options.seedPath.replace(/\/[^/]+$/, "");
97
+ return {
98
+ mode,
99
+ reason: reasonFor(mode),
100
+ unitPath: UNIT_PATH,
101
+ unit: agentUnit({ ...options, mode }),
102
+ socketPath: options.socketPath,
103
+ user,
104
+ steps: [
105
+ {
106
+ label: "service account",
107
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
108
+ },
109
+ {
110
+ label: "seed directory",
111
+ command: `install -d -o ${user} -g ${user} -m 0700 ${seedDir}`
112
+ },
113
+ { label: "reload units", command: "systemctl daemon-reload" },
114
+ { label: "enable and start", command: "systemctl enable --now forgezero-agent.service" },
115
+ { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
116
+ { label: "prove the socket exists", command: `test -S ${options.socketPath}` }
117
+ ]
118
+ };
119
+ }
120
+ export {
121
+ reasonFor,
122
+ planProvision,
123
+ modeFor,
124
+ atLeast,
125
+ agentUnit,
126
+ UNIT_PATH,
127
+ CAPABILITY_CHECKS
128
+ };
@@ -0,0 +1,163 @@
1
+ import { type Server } from 'node:net';
2
+ import { signRequest, type NodeKeyPair } from '@forgezero/runtime/identity';
3
+ import { type Pipeline, type RunResult, type RunOptions } from './pipeline';
4
+ import type { SecretCache } from './cache';
5
+ /**
6
+ * The signing socket — why the application on managed compute holds nothing.
7
+ *
8
+ * `@forgezero/vault` discovers `/run/forgezero.sock` and prefers it over
9
+ * `FORGEZERO_API_KEY`, so moving an app onto managed compute is DELETING an
10
+ * environment variable. That only means anything if something is listening, and
11
+ * for a long time nothing was: the client knew how to prefer the socket and the
12
+ * agent was a version constant.
13
+ *
14
+ * ## The key never leaves
15
+ *
16
+ * There is no `getKey` operation and there is deliberately no way to add one:
17
+ * the socket signs, and signing is all it does. An application that could ask
18
+ * for the key is an application that holds the key the moment it is compromised,
19
+ * which is the entire difference between this and an API key in the environment.
20
+ *
21
+ * ## Why a signature and not a token
22
+ *
23
+ * A token would have to be handed to the caller, which puts it in a process we
24
+ * do not control, in memory we do not own, for as long as the process lives. A
25
+ * signature is bound to one method, one path and one body digest by
26
+ * `canonicalString`, so a caller that captures one cannot reuse it for anything
27
+ * else — and it expires with the clock-skew window rather than with the process.
28
+ *
29
+ * ## Peer credentials, not a shared secret
30
+ *
31
+ * Access control is the filesystem's: the socket is created 0600, owned by the
32
+ * user the agent runs as, and only that user's processes can connect. A token
33
+ * checked over the socket would have to live somewhere both sides can read,
34
+ * which is the problem this exists to remove rather than a solution to it.
35
+ */
36
+ export declare class AgentError extends Error {
37
+ readonly code: string;
38
+ constructor(code: string, message: string);
39
+ }
40
+ /** One line of JSON in, one line of JSON out. */
41
+ export type Request = {
42
+ op: 'identity';
43
+ } | {
44
+ op: 'sign';
45
+ method: string;
46
+ path: string;
47
+ query?: string;
48
+ body?: string;
49
+ } | {
50
+ op: 'attest';
51
+ nonce?: string;
52
+ } | {
53
+ op: 'get';
54
+ name: string;
55
+ } | {
56
+ op: 'sync';
57
+ } | {
58
+ op: 'held';
59
+ } | {
60
+ op: 'run';
61
+ pipeline: Pipeline;
62
+ };
63
+ export type Response = {
64
+ ok: true;
65
+ op: 'identity';
66
+ nodeKey: string;
67
+ publicKeys: NodeKeyPair['ed25519'] & {
68
+ mlDsa: string;
69
+ };
70
+ } | {
71
+ ok: true;
72
+ op: 'sign';
73
+ envelope: ReturnType<typeof signRequest>;
74
+ } | {
75
+ ok: true;
76
+ op: 'attest';
77
+ report: string;
78
+ source: string;
79
+ } | {
80
+ ok: true;
81
+ op: 'get';
82
+ value: string;
83
+ } | {
84
+ ok: true;
85
+ op: 'sync';
86
+ invalidated: string[];
87
+ cursor: number;
88
+ resync: boolean;
89
+ } | {
90
+ ok: true;
91
+ op: 'held';
92
+ names: string[];
93
+ staleForMs: number;
94
+ } | {
95
+ ok: true;
96
+ op: 'run';
97
+ result: RunResult;
98
+ } | {
99
+ ok: false;
100
+ error: {
101
+ code: string;
102
+ message: string;
103
+ };
104
+ };
105
+ /**
106
+ * Produces a hardware attestation report for this guest.
107
+ *
108
+ * Injected rather than implemented here, and ABSENT by default. A SEV-SNP report
109
+ * is only meaningful if something verifies it against a measurement and a
110
+ * freshness window, and the freshness window is an open architectural question
111
+ * in this repo — `architecture.ts` carries it as `status: 'open'`. Inventing a
112
+ * number would produce a check that looks like attestation and is not one.
113
+ *
114
+ * So with no source configured, `attest` REFUSES. It does not return an empty
115
+ * report, and it does not silently succeed: a caller that believes it verified
116
+ * an attestation when nothing did is worse off than one told plainly that
117
+ * attestation is unavailable here.
118
+ */
119
+ export interface AttestationSource {
120
+ readonly name: string;
121
+ report(nonce: string): Promise<string>;
122
+ }
123
+ export interface AgentOptions {
124
+ socketPath: string;
125
+ keys: NodeKeyPair;
126
+ /** The node's identifier, as the platform knows it. */
127
+ nodeKey: string;
128
+ attestation?: AttestationSource;
129
+ /**
130
+ * Runs a pipeline step. ABSENT by default, and that is the security posture.
131
+ *
132
+ * An agent that can execute arbitrary commands is a remote shell with a vault
133
+ * attached. Shipping one on by default would mean every compute that installs
134
+ * the agent for its secrets also accepts remote execution, which is not the
135
+ * trade anybody agreed to. An operator turns it on for the boxes that deploy.
136
+ */
137
+ exec?: RunOptions['exec'];
138
+ /**
139
+ * Secrets, cached in memory on this guest.
140
+ *
141
+ * Optional: an agent that only signs is a complete agent, and a box with no
142
+ * project bound to it has nothing to cache. When absent, `get` says so rather
143
+ * than returning nothing — an application that receives an empty value for a
144
+ * database URL fails somewhere far from here.
145
+ */
146
+ cache?: SecretCache;
147
+ /** Every request, for the hash-chained journal. */
148
+ record?: (entry: {
149
+ op: string;
150
+ outcome: 'ok' | 'refused';
151
+ detail?: string;
152
+ }) => void;
153
+ }
154
+ export declare function handleRequest(options: AgentOptions, request: Request): Promise<Response>;
155
+ /**
156
+ * Listen, with the socket file replaced rather than reused.
157
+ *
158
+ * A stale socket from a killed process makes `listen` fail with EADDRINUSE, and
159
+ * an agent that will not start after an unclean shutdown is an outage that needs
160
+ * a human. The file is unlinked first — safe because only one agent runs per
161
+ * guest, and a live one would be holding the path.
162
+ */
163
+ export declare function startAgent(options: AgentOptions): Server;
@@ -0,0 +1 @@
1
+ export {};