@forgezero/agent 0.1.18 → 0.1.20

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.
package/README.md CHANGED
@@ -90,10 +90,11 @@ evidence: report acquisition and AMD-rooted measurement verification still have
90
90
  to cross the production fleet. Until then the node is honestly `enrolled`; the
91
91
  Agent never manufactures an attestation-shaped fallback.
92
92
 
93
- Every tenant Vault sync request uses the same hybrid signature codec as an
94
- external API key and signs its one-use hybrid ML-KEM-768 + X25519 response key.
95
- Vault values are AES-256-GCM sealed to that request before crossing the edge;
96
- there is no Ed25519-only or plaintext-response downgrade.
93
+ Every Agent request uses the same hybrid signature codec as an external API key
94
+ and signs its one-use hybrid ML-KEM-768 + X25519 response key. Vault replication,
95
+ deployment claims, attestation and metal provisioning responses are AES-256-GCM
96
+ sealed to that request before crossing the edge; there is no Ed25519-only or
97
+ successful plaintext-response downgrade.
97
98
 
98
99
  The first guest enrolment uses the same contract before the identity exists in
99
100
  the database: the guest signs the exact token and public-key body with both keys
@@ -103,23 +104,19 @@ acknowledgement. The short-lived token is therefore not an unsigned Agent path.
103
104
  ## The socket interface
104
105
 
105
106
  ```
106
- identity who this node is
107
- sign sign a request with the node key
108
- attest a hardware report, or a refusal — never a fake
109
107
  get one secret
110
108
  sync what changed since a cursor
111
109
  held what this guest is holding, by name only
112
110
  ```
113
111
 
114
- The vault socket never accepts commands or deployment definitions. Deployment
115
- enters through the root-only control socket or the signed outbound claim path,
116
- then the deployment manager validates the checked-in definition and submits it
117
- to the common keyed queue. Keeping execution off the vault socket prevents a
118
- compromised application from turning secret-read access into a local shell.
119
-
120
- `attest` **refuses** when no source is configured rather than returning
121
- something attestation-shaped. An operator who believes they have an attestation
122
- when nothing produced one is worse off than one told plainly it is unavailable.
112
+ The application Vault socket never accepts identity, signing, attestation,
113
+ commands or deployment definitions. The Agent signs and attests only inside its
114
+ own outbound clients. Otherwise a compromised application could ask for a valid
115
+ node signature over a deployment or whole-project sync request and impersonate
116
+ the Agent without ever extracting its key. Deployment enters through the
117
+ root-only control socket or signed outbound claim path, then the deployment
118
+ manager validates the checked-in definition and submits it to the common keyed
119
+ queue.
123
120
 
124
121
  ## Deployment definitions
125
122
 
@@ -139,7 +136,8 @@ code, and only an awaited successful pipeline writes `deployed`. An expired
139
136
  claim can be recovered; its stale token cannot renew or finish.
140
137
 
141
138
  The same package also runs the identity-only Metal Agent. It initiates outbound
142
- signed HTTPS to pull only claims assigned to its enrolled hostname, then passes
139
+ hybrid-signed HTTPS and requires request-bound PQ-sealed responses while pulling
140
+ only claims assigned to its enrolled hostname, then passes
143
141
  the fixed claim over a local Unix socket to a narrowly privileged root helper.
144
142
  The helper can materialize the audited QEMU profile; it cannot clone a project,
145
143
  read Vault data, accept arbitrary commands, or retain tenant credentials. Root
package/dist/cache.d.ts CHANGED
@@ -60,7 +60,8 @@ export interface CacheOptions {
60
60
  * Every name in the assigned scope. Present means REPLICA mode.
61
61
  *
62
62
  * Absent means the old behaviour — fetch on demand, hold what was asked for.
63
- * Present means the agent pulls the whole project+environment at startup and
63
+ * Present means the agent pulls the whole bound project across every
64
+ * environment at startup and
64
65
  * on every resync, so an application reads with no round trip and keeps
65
66
  * working while the platform is unreachable.
66
67
  *
package/dist/fz-agent.js CHANGED
@@ -123,19 +123,19 @@ class SignedNodeHttpError extends Error {
123
123
  this.name = "SignedNodeHttpError";
124
124
  }
125
125
  }
126
- async function postSignedNode(options, path, body, sealedResponse = false) {
126
+ async function postSignedNode(options, path, body) {
127
127
  const url = new URL(options.apiUrl);
128
128
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
129
129
  url.search = "";
130
130
  url.hash = "";
131
131
  const raw = JSON.stringify(body);
132
- const recipient = sealedResponse ? generateResponseRecipient() : undefined;
132
+ const recipient = generateResponseRecipient();
133
133
  const envelope = signRequest(options.keys, options.nodeKey, {
134
134
  method: "POST",
135
135
  path: url.pathname,
136
136
  query: "",
137
137
  body: raw,
138
- responseKey: recipient?.publicKey
138
+ responseKey: recipient.publicKey
139
139
  });
140
140
  const signature = encodeSignatureHeader(envelope);
141
141
  const response = await (options.fetch ?? globalThis.fetch)(url, {
@@ -144,7 +144,7 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
144
144
  "content-type": "application/json",
145
145
  "x-fz-node": options.nodeKey,
146
146
  "x-fz-signature": signature,
147
- ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
147
+ [RESPONSE_KEY_HEADER]: recipient.publicKey
148
148
  },
149
149
  body: raw,
150
150
  signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
@@ -155,14 +155,11 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
155
155
  const reason = failure ? failure.error?.message ?? failure.message : undefined;
156
156
  throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
157
157
  }
158
- if (recipient) {
159
- try {
160
- return await openResponse(recipient.secretKey, signature, payload);
161
- } catch {
162
- throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
163
- }
158
+ try {
159
+ return await openResponse(recipient.secretKey, signature, payload);
160
+ } catch {
161
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
164
162
  }
165
- return payload;
166
163
  }
167
164
 
168
165
  // src/node-vault.ts
@@ -194,7 +191,7 @@ function tenantNodeApiUrl(apiUrl, tenantSlug) {
194
191
  return url.toString().replace(/\/$/, "");
195
192
  }
196
193
  function createNodeVaultCache(options) {
197
- const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body, true);
194
+ const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
198
195
  return createSecretCache({
199
196
  ttlMs: options.ttlMs,
200
197
  maxStaleMs: options.maxStaleMs,
@@ -255,6 +252,13 @@ function startNodeVaultSync(cache, options = {}) {
255
252
  }
256
253
 
257
254
  // src/socket.ts
255
+ var APPLICATION_VAULT_OPS = new Set(["get", "held", "sync"]);
256
+ function handleApplicationRequest(options, request) {
257
+ if (!APPLICATION_VAULT_OPS.has(request?.op)) {
258
+ return Promise.resolve(refuse("APP_OPERATION_REFUSED", "The application Vault socket serves scoped replica reads only; node signing and attestation are internal."));
259
+ }
260
+ return handleRequest(options, request);
261
+ }
258
262
  var MAX_LINE_BYTES = 64 * 1024;
259
263
  function handleRequest(options, request) {
260
264
  switch (request?.op) {
@@ -390,7 +394,7 @@ async function respond(options, socket, line) {
390
394
  return;
391
395
  let response;
392
396
  try {
393
- response = await handleRequest(options, JSON.parse(line));
397
+ response = await handleApplicationRequest(options, JSON.parse(line));
394
398
  } catch (cause) {
395
399
  response = refuse("MALFORMED", cause instanceof Error ? cause.message : "Malformed request.");
396
400
  }
@@ -1209,7 +1213,7 @@ async function enrolGuestIdentity(options) {
1209
1213
  ed25519: options.keys.ed25519.publicKey,
1210
1214
  mlDsa: options.keys.mlDsa.publicKey
1211
1215
  }
1212
- }, true);
1216
+ });
1213
1217
  if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
1214
1218
  throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
1215
1219
  }
@@ -2711,7 +2715,7 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2711
2715
  }
2712
2716
 
2713
2717
  // src/version.ts
2714
- var VERSION = "0.1.18";
2718
+ var VERSION = "0.1.20";
2715
2719
 
2716
2720
  // src/index.ts
2717
2721
  function loadOrCreateSeed(path) {
@@ -3215,6 +3219,7 @@ export {
3215
3219
  loadOrCreateSeed,
3216
3220
  loadGuestBinding,
3217
3221
  handleRequest,
3222
+ handleApplicationRequest,
3218
3223
  guestNameFor,
3219
3224
  enrolGuestIdentity,
3220
3225
  createSystemdDeploymentSecrets,
package/dist/fz.js CHANGED
@@ -774,7 +774,7 @@ async function resolveIdentity(selector, socketPath) {
774
774
  }
775
775
 
776
776
  // src/version.ts
777
- var VERSION = "0.1.18";
777
+ var VERSION = "0.1.20";
778
778
 
779
779
  // src/cli/index.ts
780
780
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
@@ -28,19 +28,19 @@ class SignedNodeHttpError extends Error {
28
28
  this.name = "SignedNodeHttpError";
29
29
  }
30
30
  }
31
- async function postSignedNode(options, path, body, sealedResponse = false) {
31
+ async function postSignedNode(options, path, body) {
32
32
  const url = new URL(options.apiUrl);
33
33
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
34
34
  url.search = "";
35
35
  url.hash = "";
36
36
  const raw = JSON.stringify(body);
37
- const recipient = sealedResponse ? generateResponseRecipient() : undefined;
37
+ const recipient = generateResponseRecipient();
38
38
  const envelope = signRequest(options.keys, options.nodeKey, {
39
39
  method: "POST",
40
40
  path: url.pathname,
41
41
  query: "",
42
42
  body: raw,
43
- responseKey: recipient?.publicKey
43
+ responseKey: recipient.publicKey
44
44
  });
45
45
  const signature = encodeSignatureHeader(envelope);
46
46
  const response = await (options.fetch ?? globalThis.fetch)(url, {
@@ -49,7 +49,7 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
49
49
  "content-type": "application/json",
50
50
  "x-fz-node": options.nodeKey,
51
51
  "x-fz-signature": signature,
52
- ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
52
+ [RESPONSE_KEY_HEADER]: recipient.publicKey
53
53
  },
54
54
  body: raw,
55
55
  signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
@@ -60,14 +60,11 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
60
60
  const reason = failure ? failure.error?.message ?? failure.message : undefined;
61
61
  throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
62
62
  }
63
- if (recipient) {
64
- try {
65
- return await openResponse(recipient.secretKey, signature, payload);
66
- } catch {
67
- throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
68
- }
63
+ try {
64
+ return await openResponse(recipient.secretKey, signature, payload);
65
+ } catch {
66
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
69
67
  }
70
- return payload;
71
68
  }
72
69
 
73
70
  // src/guest-enrolment.ts
@@ -121,7 +118,7 @@ async function enrolGuestIdentity(options) {
121
118
  ed25519: options.keys.ed25519.publicKey,
122
119
  mlDsa: options.keys.mlDsa.publicKey
123
120
  }
124
- }, true);
121
+ });
125
122
  if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
126
123
  throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
127
124
  }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type NodeKeyPair } from '@forgezero/runtime/identity';
3
3
  import { startAgent, type AgentOptions, type AttestationSource } from './socket';
4
4
  import type { SecretCache } from './cache';
5
5
  export { VERSION } from './version';
6
- export { startAgent, handleRequest } from './socket';
6
+ export { startAgent, handleRequest, handleApplicationRequest } from './socket';
7
7
  export type { AgentOptions, AttestationSource, Request, Response } from './socket';
8
8
  export { createSecretCache, CacheError } from './cache';
9
9
  export type { SecretCache, CacheOptions } from './cache';
@@ -108,19 +108,19 @@ class SignedNodeHttpError extends Error {
108
108
  this.name = "SignedNodeHttpError";
109
109
  }
110
110
  }
111
- async function postSignedNode(options, path, body, sealedResponse = false) {
111
+ async function postSignedNode(options, path, body) {
112
112
  const url = new URL(options.apiUrl);
113
113
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
114
114
  url.search = "";
115
115
  url.hash = "";
116
116
  const raw = JSON.stringify(body);
117
- const recipient = sealedResponse ? generateResponseRecipient() : undefined;
117
+ const recipient = generateResponseRecipient();
118
118
  const envelope = signRequest(options.keys, options.nodeKey, {
119
119
  method: "POST",
120
120
  path: url.pathname,
121
121
  query: "",
122
122
  body: raw,
123
- responseKey: recipient?.publicKey
123
+ responseKey: recipient.publicKey
124
124
  });
125
125
  const signature = encodeSignatureHeader(envelope);
126
126
  const response = await (options.fetch ?? globalThis.fetch)(url, {
@@ -129,7 +129,7 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
129
129
  "content-type": "application/json",
130
130
  "x-fz-node": options.nodeKey,
131
131
  "x-fz-signature": signature,
132
- ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
132
+ [RESPONSE_KEY_HEADER]: recipient.publicKey
133
133
  },
134
134
  body: raw,
135
135
  signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
@@ -140,14 +140,11 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
140
140
  const reason = failure ? failure.error?.message ?? failure.message : undefined;
141
141
  throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
142
142
  }
143
- if (recipient) {
144
- try {
145
- return await openResponse(recipient.secretKey, signature, payload);
146
- } catch {
147
- throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
148
- }
143
+ try {
144
+ return await openResponse(recipient.secretKey, signature, payload);
145
+ } catch {
146
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
149
147
  }
150
- return payload;
151
148
  }
152
149
 
153
150
  // src/node-vault.ts
@@ -179,7 +176,7 @@ function tenantNodeApiUrl(apiUrl, tenantSlug) {
179
176
  return url.toString().replace(/\/$/, "");
180
177
  }
181
178
  function createNodeVaultCache(options) {
182
- const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body, true);
179
+ const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
183
180
  return createSecretCache({
184
181
  ttlMs: options.ttlMs,
185
182
  maxStaleMs: options.maxStaleMs,
@@ -15,19 +15,19 @@ class SignedNodeHttpError extends Error {
15
15
  this.name = "SignedNodeHttpError";
16
16
  }
17
17
  }
18
- async function postSignedNode(options, path, body, sealedResponse = false) {
18
+ async function postSignedNode(options, path, body) {
19
19
  const url = new URL(options.apiUrl);
20
20
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
21
21
  url.search = "";
22
22
  url.hash = "";
23
23
  const raw = JSON.stringify(body);
24
- const recipient = sealedResponse ? generateResponseRecipient() : undefined;
24
+ const recipient = generateResponseRecipient();
25
25
  const envelope = signRequest(options.keys, options.nodeKey, {
26
26
  method: "POST",
27
27
  path: url.pathname,
28
28
  query: "",
29
29
  body: raw,
30
- responseKey: recipient?.publicKey
30
+ responseKey: recipient.publicKey
31
31
  });
32
32
  const signature = encodeSignatureHeader(envelope);
33
33
  const response = await (options.fetch ?? globalThis.fetch)(url, {
@@ -36,7 +36,7 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
36
36
  "content-type": "application/json",
37
37
  "x-fz-node": options.nodeKey,
38
38
  "x-fz-signature": signature,
39
- ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
39
+ [RESPONSE_KEY_HEADER]: recipient.publicKey
40
40
  },
41
41
  body: raw,
42
42
  signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
@@ -47,14 +47,11 @@ async function postSignedNode(options, path, body, sealedResponse = false) {
47
47
  const reason = failure ? failure.error?.message ?? failure.message : undefined;
48
48
  throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
49
49
  }
50
- if (recipient) {
51
- try {
52
- return await openResponse(recipient.secretKey, signature, payload);
53
- } catch {
54
- throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
55
- }
50
+ try {
51
+ return await openResponse(recipient.secretKey, signature, payload);
52
+ } catch {
53
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
56
54
  }
57
- return payload;
58
55
  }
59
56
 
60
57
  // src/provisioning-pull.ts
@@ -11,4 +11,4 @@ export declare class SignedNodeHttpError extends Error {
11
11
  constructor(status: number, message: string);
12
12
  }
13
13
  /** One implementation of the hybrid-signed machine HTTP contract. */
14
- export declare function postSignedNode<T>(options: SignedNodeHttpOptions, path: string, body: object, sealedResponse?: boolean): Promise<T>;
14
+ export declare function postSignedNode<T>(options: SignedNodeHttpOptions, path: string, body: object): Promise<T>;
package/dist/socket.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Server } from 'node:net';
2
2
  import { signRequest, type NodeKeyPair } from '@forgezero/runtime/identity';
3
3
  import type { SecretCache } from './cache';
4
4
  /**
5
- * The signing socket — why the application on managed compute holds nothing.
5
+ * The application Vault socket — why managed code holds no remote credential.
6
6
  *
7
7
  * `@forgezero/vault` discovers `/run/forgezero/vault.sock` and prefers it over
8
8
  * `FORGEZERO_API_KEY`, so moving an app onto managed compute is DELETING an
@@ -10,20 +10,14 @@ import type { SecretCache } from './cache';
10
10
  * for a long time nothing was: the client knew how to prefer the socket and the
11
11
  * agent was a version constant.
12
12
  *
13
- * ## The key never leaves
13
+ * ## The node identity never becomes an application signing oracle
14
14
  *
15
- * There is no `getKey` operation and there is deliberately no way to add one:
16
- * the socket signs, and signing is all it does. An application that could ask
17
- * for the key is an application that holds the key the moment it is compromised,
18
- * which is the entire difference between this and an API key in the environment.
19
- *
20
- * ## Why a signature and not a token
21
- *
22
- * A token would have to be handed to the caller, which puts it in a process we
23
- * do not control, in memory we do not own, for as long as the process lives. A
24
- * signature is bound to one method, one path and one body digest by
25
- * `canonicalString`, so a caller that captures one cannot reuse it for anything
26
- * else — and it expires with the clock-skew window rather than with the process.
15
+ * The Agent hybrid-signs its own outbound HTTPS requests internally. This
16
+ * socket never exposes `identity`, `sign` or `attest`: otherwise any tenant
17
+ * process admitted to the Vault group could ask the Agent to sign a deployment,
18
+ * heartbeat or whole-project replication request and act as the node without
19
+ * ever extracting its key. Applications receive only the environment they ask
20
+ * for from the already-bound, memory-only project replica.
27
21
  *
28
22
  * ## Peer credentials, not a shared secret
29
23
  *
@@ -99,6 +93,8 @@ export type Response = {
99
93
  message: string;
100
94
  };
101
95
  };
96
+ /** The real Unix-socket boundary; privileged machine operations stay in-process. */
97
+ export declare function handleApplicationRequest(options: AgentOptions, request: Request): Promise<Response>;
102
98
  /**
103
99
  * Produces a hardware attestation report for this guest.
104
100
  *
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.18";
2
+ export declare const VERSION = "0.1.20";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/agent",
4
- "version": "0.1.18",
4
+ "version": "0.1.20",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",