@knowttl/kubernetes-axi 0.0.0-stage → 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.
Files changed (48) hide show
  1. package/README.md +306 -2
  2. package/THIRD_PARTY_NOTICES.md +13 -0
  3. package/axi-credential.schema.json +31 -0
  4. package/bin/kubernetes-axi.js +10 -0
  5. package/command-metadata.json +36947 -0
  6. package/dist/byte-process.d.ts +9 -0
  7. package/dist/byte-process.js +56 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +68 -0
  10. package/dist/credentials.d.ts +28 -0
  11. package/dist/credentials.js +113 -0
  12. package/dist/errors.d.ts +12 -0
  13. package/dist/errors.js +11 -0
  14. package/dist/exec-shim.d.ts +1 -0
  15. package/dist/exec-shim.js +16 -0
  16. package/dist/grammar.d.ts +50 -0
  17. package/dist/grammar.js +248 -0
  18. package/dist/hosted-session.d.ts +1 -0
  19. package/dist/hosted-session.js +201 -0
  20. package/dist/metadata-pin.d.ts +3 -0
  21. package/dist/metadata-pin.js +4 -0
  22. package/dist/mutations.d.ts +11 -0
  23. package/dist/mutations.js +526 -0
  24. package/dist/native.d.ts +26 -0
  25. package/dist/native.js +143 -0
  26. package/dist/private-files.d.ts +9 -0
  27. package/dist/private-files.js +137 -0
  28. package/dist/protobuf-schema.d.ts +2 -0
  29. package/dist/protobuf-schema.js +1 -0
  30. package/dist/protobuf.d.ts +1 -0
  31. package/dist/protobuf.js +67 -0
  32. package/dist/redaction.d.ts +10 -0
  33. package/dist/redaction.js +50 -0
  34. package/dist/remote-session.d.ts +22 -0
  35. package/dist/remote-session.js +90 -0
  36. package/dist/route-policy.d.ts +1 -0
  37. package/dist/route-policy.js +19 -0
  38. package/dist/runner.d.ts +11 -0
  39. package/dist/runner.js +367 -0
  40. package/dist/workspace-tar.d.ts +6 -0
  41. package/dist/workspace-tar.js +109 -0
  42. package/docs/hooks.md +35 -0
  43. package/kubectl-pins.json +18 -0
  44. package/package.json +21 -4
  45. package/skills/kubernetes-axi/SKILL.md +32 -0
  46. package/third-party/kubectl-LICENSE.txt +202 -0
  47. package/vendor/kubectl-linux-amd64 +4 -0
  48. package/vendor/kubectl-windows-amd64.exe +4 -0
@@ -0,0 +1,526 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { request } from 'node:https';
4
+ import { readFile, rename } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { fstatSync, writeFileSync } from 'node:fs';
9
+ import { Ajv } from 'ajv';
10
+ import { configPath } from './credentials.js';
11
+ import { Failure } from './errors.js';
12
+ import { requireWrites } from './grammar.js';
13
+ import { privateDirectory, readPrivate, writePrivate } from './private-files.js';
14
+ import { remoteSession } from './remote-session.js';
15
+ import { routeEffect } from './route-policy.js';
16
+ import { executionLimit } from './metadata-pin.js';
17
+ const execute = promisify(execFile);
18
+ const validDocument = new Ajv().compile({ type: 'object', properties: {
19
+ kind: { type: 'string' }, apiVersion: { type: 'string' },
20
+ metadata: { type: 'object', properties: Object.fromEntries(['name', 'namespace', 'uid', 'resourceVersion', 'continue'].map(name => [name, { type: 'string' }])) },
21
+ items: { type: 'array', items: { type: 'object' } },
22
+ } });
23
+ const root = (config) => join(dirname(configPath(config)), 'requests');
24
+ const mac = (key, value) => createHmac('sha256', key).update(JSON.stringify(value)).digest('hex');
25
+ function prefix(invocation) {
26
+ const selectors = [['--config', invocation.config], ['--profile', invocation.profile], ['--native-config', invocation.nativeConfig]];
27
+ return 'kubernetes-axi' + selectors.filter(([, value]) => value !== undefined).map(([flag, value]) => ` ${flag} '${value.replaceAll("'", "'\\''")}'`).join('') + (invocation.trustExec ? ' --trust-exec' : '');
28
+ }
29
+ async function savedRequest(invocation) {
30
+ const workflow = invocation.workflow;
31
+ if (!workflow || !('key' in workflow))
32
+ throw new Failure('USAGE', 'Select a prepared request.', 2);
33
+ const directory = join(root(invocation.config), workflow.key);
34
+ const signingKey = await readPrivate(join(directory, 'key'), true);
35
+ const value = JSON.parse(await readPrivate(join(directory, 'request.json'), true));
36
+ const digest = mac(signingKey, value.plan);
37
+ if (value.plan.version !== 1 || value.plan.key !== workflow.key || typeof value.digest !== 'string' || value.digest.length !== digest.length || !timingSafeEqual(Buffer.from(value.digest), Buffer.from(digest)))
38
+ throw new Failure('PREVIEW_INVALID', 'The saved request was changed; prepare a new request.');
39
+ if (value.plan.execution?.terminal)
40
+ throw new Failure('LOCAL_TERMINAL_REQUIRED', executionLimit);
41
+ return { saved: value, directory, signingKey };
42
+ }
43
+ export async function preview(invocation) {
44
+ const { saved } = await savedRequest(invocation);
45
+ return { request: saved.plan.key, digest: saved.digest, state: saved.state, ...saved.plan.display,
46
+ help: [`${prefix(invocation)} confirm ${saved.plan.key} --digest ${saved.digest}`] };
47
+ }
48
+ function publicBody(value, secret = false) {
49
+ if (Array.isArray(value))
50
+ return value.map(item => publicBody(item, secret));
51
+ if (!value || typeof value !== 'object')
52
+ return value;
53
+ const body = value;
54
+ secret ||= body.kind === 'Secret';
55
+ const pointer = typeof body.path === 'string' ? body.path.split('/').slice(1).map(part => part.replaceAll('~1', '/').replaceAll('~0', '~')) : [];
56
+ const protectedValue = pointer.some(key => secret && ['data', 'binaryData', 'stringData', 'annotations'].includes(key) || key === 'kubectl.kubernetes.io/last-applied-configuration');
57
+ // Secret payloads and credential-bearing annotations never enter public previews.
58
+ return Object.fromEntries(Object.entries(body).map(([key, item]) => [key,
59
+ key === 'value' && protectedValue || secret && ['data', 'binaryData', 'stringData', 'annotations'].includes(key) || key === 'kubectl.kubernetes.io/last-applied-configuration' ? '[REDACTED]' : publicBody(item, secret)]));
60
+ }
61
+ function snapshot(path, reply) {
62
+ if (reply.status === 404)
63
+ return { path, objects: [], absent: true, collection: false };
64
+ if (reply.status >= 300)
65
+ throw new Failure('ACCESS_DENIED', 'The selected identity cannot read the exact mutation targets.', 1, { upstreamStatus: reply.status });
66
+ const collection = Array.isArray(reply.body.items);
67
+ const items = collection ? reply.body.items : [reply.body];
68
+ if (items.length > 100 || reply.body.metadata?.continue)
69
+ throw new Failure('SELECTION_LIMIT', 'Narrow the selection to at most 100 fully enumerated objects.');
70
+ const objects = items.map(item => {
71
+ if (!validDocument(item) || !item.metadata?.name || !item.metadata.uid || !item.metadata.resourceVersion)
72
+ throw new Failure('UPSTREAM_INVALID', 'Mutation targets require a name, UID and resourceVersion.');
73
+ const route = new URL(path, 'http://local').pathname;
74
+ const namespace = /\/namespaces\/([^/]+)\//.exec(route)?.[1];
75
+ const name = route.replace(/\/(?:scale|approval|ephemeralcontainers)$/, '').split('/').at(-1);
76
+ if (namespace && item.metadata.namespace !== namespace || !collection && item.metadata.name !== name)
77
+ throw new Failure('UPSTREAM_INVALID', 'The snapshot does not match the selected name and namespace.');
78
+ return { name: item.metadata.name, namespace: item.metadata.namespace ?? '', uid: item.metadata.uid, version: item.metadata.resourceVersion };
79
+ }).sort((a, b) => `${a.namespace}/${a.name}`.localeCompare(`${b.namespace}/${b.name}`));
80
+ return { path, objects, absent: false, collection };
81
+ }
82
+ function operation(method, path) {
83
+ path = path.split('?')[0];
84
+ if (/\/selfsubjectaccessreviews$|\/selfsubjectrulesreviews$|\/subjectaccessreviews$|\/localsubjectaccessreviews$/.test(path))
85
+ return 'OP-021';
86
+ if (/\/selfsubjectreviews$/.test(path))
87
+ return 'OP-028';
88
+ if (/\/scale$/.test(path))
89
+ return 'OP-024';
90
+ if (/\/approval$/.test(path))
91
+ return 'OP-022';
92
+ if (/\/eviction$/.test(path))
93
+ return 'OP-023';
94
+ return method === 'POST' ? 'OP-012' : method === 'PUT' ? 'OP-013' : method === 'PATCH' ? 'OP-014' : 'OP-015';
95
+ }
96
+ export async function mutationSession(configFile, invocation, namespace, redactor, signal, compatibleVersion, nativeBinding) {
97
+ const config = JSON.parse(await readFile(configFile, 'utf8'));
98
+ const cluster = config.clusters[0].cluster;
99
+ const user = config.users[0].user;
100
+ let count = 0;
101
+ let bytes = 0;
102
+ const deadline = Date.now() + 15_000;
103
+ let credentialIssuance = invocation.command.path === 'create token';
104
+ const upstream = async (method, path, body, contentType = 'application/json', wire) => {
105
+ if (++count > 256 || Date.now() >= deadline)
106
+ throw new Failure('UPSTREAM_TIMEOUT', 'The bounded operation exhausted its request or time budget.');
107
+ if (!/^\/(?:api(?:\/|\?|$)|apis(?:\/|\?|$)|version(?:\?|$)|openapi\/)/.test(path) || path.includes('..') || /\/\/|%2f|%2e/i.test(path))
108
+ throw new Failure('USAGE', 'Only discovered Kubernetes API routes are supported.', 2);
109
+ const effect = path.startsWith('/openapi/') ? 'read' : routeEffect(method, path);
110
+ if (['connection', 'execution'].includes(effect) || effect === 'credentials' && !credentialIssuance)
111
+ throw new Failure('EFFECT_UNCLASSIFIED', 'This route requires its explicit bounded execution or acquisition capability.');
112
+ let token = user.token;
113
+ let cert = user['client-certificate-data'] ? Buffer.from(user['client-certificate-data'], 'base64').toString() : undefined;
114
+ let key = user['client-key-data'] ? Buffer.from(user['client-key-data'], 'base64').toString() : undefined;
115
+ if (user.exec) {
116
+ // This is the existing private broker shim, never the configured helper itself.
117
+ const info = { apiVersion: user.exec.apiVersion, kind: 'ExecCredential', spec: { interactive: false,
118
+ ...(user.exec.provideClusterInfo ? { cluster: { server: cluster.server, 'certificate-authority-data': cluster['certificate-authority-data'] } } : {}) } };
119
+ const acquired = JSON.parse((await execute(user.exec.command, user.exec.args, { signal, timeout: 8000, maxBuffer: 1024 * 1024,
120
+ env: { KUBERNETES_EXEC_INFO: JSON.stringify(info) } })).stdout);
121
+ token = acquired.status.token;
122
+ cert = acquired.status.clientCertificateData;
123
+ key = acquired.status.clientKeyData;
124
+ }
125
+ redactor.register(token, cert, key);
126
+ const payload = wire ?? (body === undefined ? undefined : JSON.stringify(body));
127
+ return await new Promise((resolve, reject) => {
128
+ const call = request(new URL(cluster.server.replace(/\/$/, '') + path), { method, ca: Buffer.from(cluster['certificate-authority-data'], 'base64'),
129
+ ...(cluster['tls-server-name'] ? { servername: cluster['tls-server-name'] } : {}), ...(cert ? { cert } : {}), ...(key ? { key } : {}), signal,
130
+ headers: { accept: 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...(payload === undefined ? {} : { 'content-type': contentType, 'content-length': Buffer.byteLength(payload) }) },
131
+ }, response => {
132
+ const chunks = [];
133
+ response.on('data', (chunk) => {
134
+ bytes += chunk.length;
135
+ if (bytes > 4 * 1024 * 1024)
136
+ call.destroy(new Failure('OUTPUT_LIMIT', 'Narrow the operation to less than 4 MiB of API output.'));
137
+ else
138
+ chunks.push(chunk);
139
+ });
140
+ response.on('error', reject);
141
+ response.on('end', () => {
142
+ try {
143
+ const body = JSON.parse(Buffer.concat(chunks).toString());
144
+ if (!validDocument(body))
145
+ throw new Error('invalid');
146
+ resolve({ status: response.statusCode ?? 502, body });
147
+ }
148
+ catch {
149
+ reject(new Failure('UPSTREAM_INVALID', 'The API returned an invalid JSON envelope.'));
150
+ }
151
+ });
152
+ });
153
+ call.on('error', reject);
154
+ call.setTimeout(Math.min(5000, Math.max(1, deadline - Date.now())), () => call.destroy(new Failure('UPSTREAM_TIMEOUT', 'The bounded request timed out.')));
155
+ call.end(payload);
156
+ });
157
+ };
158
+ const workflow = invocation.workflow;
159
+ if (workflow?.kind === 'confirm') {
160
+ requireWrites();
161
+ const { saved, directory, signingKey } = await savedRequest(invocation);
162
+ credentialIssuance = saved.plan.changes.some(change => change.operation === 'OP-030');
163
+ if ((saved.plan.changes.some(change => change.operation === 'OP-016') || saved.plan.changes.filter(change => change.method === 'DELETE').length > 1) && !/^(true|1)$/i.test(process.env.KUBERNETES_AXI_COLLECTION_DELETE ?? ''))
164
+ throw new Failure('CAPABILITY_UNAVAILABLE', 'Bulk deletion also requires KUBERNETES_AXI_COLLECTION_DELETE=true.');
165
+ const binding = mac(signingKey, nativeBinding ?? config);
166
+ if (saved.digest !== workflow.digest || saved.plan.binding !== binding)
167
+ throw new Failure('PREVIEW_MISMATCH', 'Select the original cluster and identity and supply the exact preview digest.');
168
+ if (saved.plan.changes.some(change => change.operation === 'OP-030')) {
169
+ if (!/^(true|1)$/i.test(process.env.KUBERNETES_AXI_ACQUIRE_CREDENTIAL ?? ''))
170
+ throw new Failure('CAPABILITY_UNAVAILABLE', 'Token issuance requires KUBERNETES_AXI_ACQUIRE_CREDENTIAL=true.');
171
+ const fd = invocation.credentialFd;
172
+ if (fd === undefined)
173
+ throw new Failure('PRIVATE_DELIVERY_REQUIRED', 'Supply --credential-fd <inherited-private-pipe> on confirmation.');
174
+ try {
175
+ const sink = fstatSync(fd);
176
+ if (!sink.isFIFO() && !sink.isSocket())
177
+ throw new Error('not a pipe');
178
+ for (const publicFd of [1, 2]) {
179
+ const stream = fstatSync(publicFd);
180
+ if (sink.dev === stream.dev && sink.ino === stream.ino)
181
+ throw new Error('public stream');
182
+ }
183
+ }
184
+ catch {
185
+ throw new Failure('PRIVATE_DELIVERY_REQUIRED', 'The credential sink must be an inherited private pipe distinct from public output.');
186
+ }
187
+ }
188
+ const report = () => ({ request: saved.plan.key, state: saved.state, complete: saved.state === 'complete', results: saved.results });
189
+ const persist = async () => {
190
+ await writePrivate(join(directory, 'request.json'), JSON.stringify(saved));
191
+ await writePrivate(join(directory, 'outcome.json'), JSON.stringify(redactor.value(report())));
192
+ };
193
+ const reconcile = async (change) => snapshot(change.target, await upstream('GET', change.target)).objects;
194
+ if (saved.state === 'complete')
195
+ return { config, finish: async () => report(), close: async () => { } };
196
+ if (saved.state !== 'prepared') {
197
+ for (const change of saved.plan.changes)
198
+ saved.results.push({ operation: change.operation, method: 'GET', path: change.target, outcome: 'reconciled_without_retry', observed: await reconcile(change) });
199
+ process.exitCode = 1;
200
+ return { config, finish: async () => report(), close: async () => { } };
201
+ }
202
+ compatibleVersion((await upstream('GET', '/version')).body);
203
+ for (const selected of saved.plan.snapshots) {
204
+ const current = snapshot(selected.path, await upstream('GET', selected.path));
205
+ if (JSON.stringify(current) !== JSON.stringify(selected))
206
+ throw new Failure('PREVIEW_STALE', 'Targets or resourceVersions changed; prepare and preview a new request.');
207
+ }
208
+ // An atomic claim makes simultaneous or interrupted confirmations one-shot.
209
+ try {
210
+ await rename(join(directory, 'claim'), join(directory, 'claimed'));
211
+ }
212
+ catch (error) {
213
+ if (error.code !== 'ENOENT')
214
+ throw error;
215
+ throw new Failure('REQUEST_CLAIMED', 'This request was already claimed. Confirm again only to reconcile its recorded outcome.');
216
+ }
217
+ saved.state = 'running';
218
+ await persist();
219
+ if (saved.plan.execution) {
220
+ const execution = saved.plan.execution;
221
+ const session = await remoteSession(config, saved.plan.namespace, execution.target, execution.route, execution.query, signal);
222
+ return { ...session, argv: execution.argv, finish: async (success = true) => {
223
+ saved.state = success ? 'complete' : 'failed';
224
+ saved.results.push({ operation: 'OP-017', method: 'CONNECT', path: execution.route, outcome: success ? 'complete' : 'failed' });
225
+ await persist();
226
+ return report();
227
+ } };
228
+ }
229
+ let pending = false;
230
+ for (const change of saved.plan.changes) {
231
+ const result = { operation: change.operation, method: change.method, path: change.path, outcome: 'uncertain' };
232
+ saved.results.push(result);
233
+ await persist();
234
+ try {
235
+ const reply = await upstream(change.method, change.path, change.body, change.contentType, change.wire);
236
+ if (change.operation === 'OP-030') {
237
+ const status = reply.body.status;
238
+ const token = status?.token;
239
+ if (typeof token === 'string')
240
+ redactor.register(token);
241
+ if (reply.status >= 300 || typeof token !== 'string' || !token || typeof status?.expirationTimestamp !== 'string' || Date.parse(status.expirationTimestamp) <= Date.now())
242
+ throw new Failure('CREDENTIAL_ACQUISITION_FAILED', 'The API did not issue a valid bounded credential.');
243
+ if (signal.aborted)
244
+ throw new Failure('CANCELLED', 'Credential delivery was cancelled.');
245
+ writeFileSync(invocation.credentialFd, token + '\n');
246
+ result.upstreamStatus = reply.status;
247
+ result.outcome = 'delivered';
248
+ saved.state = 'complete';
249
+ await persist();
250
+ return { config, finish: async () => report(), close: async () => { } };
251
+ }
252
+ result.upstreamStatus = reply.status;
253
+ result.outcome = reply.status < 300 ? 'committed' : 'failed';
254
+ if (result.outcome === 'committed' && change.method !== 'DELETE' && !change.path.split('?')[0].endsWith('/eviction')) {
255
+ const named = change.target.replace(/\/(?:scale|approval|ephemeralcontainers)$/, '').split('/').at(-1);
256
+ const metadata = reply.body.metadata;
257
+ if (!metadata || metadata.name !== named || !metadata.uid || !metadata.resourceVersion)
258
+ result.outcome = 'uncertain';
259
+ }
260
+ }
261
+ catch {
262
+ result.outcome = 'uncertain';
263
+ }
264
+ if (result.outcome !== 'committed') {
265
+ saved.state = 'failed';
266
+ try {
267
+ result.observed = await reconcile(change);
268
+ }
269
+ catch {
270
+ result.outcome += '_reconciliation_unavailable';
271
+ }
272
+ await persist();
273
+ process.exitCode = 1;
274
+ return { config, finish: async () => report(), close: async () => { } };
275
+ }
276
+ if (saved.plan.awaitDeletion && (change.method === 'DELETE' || change.path.split('?')[0].endsWith('/eviction'))) {
277
+ try {
278
+ result.observed = await reconcile(change);
279
+ if (result.observed.length) {
280
+ result.outcome = 'accepted_pending';
281
+ pending = true;
282
+ }
283
+ }
284
+ catch {
285
+ result.outcome = 'accepted_reconciliation_unavailable';
286
+ pending = true;
287
+ }
288
+ }
289
+ await persist();
290
+ }
291
+ saved.state = pending ? 'failed' : 'complete';
292
+ if (pending)
293
+ process.exitCode = 1;
294
+ await persist();
295
+ return { config, finish: async () => report(), close: async () => { } };
296
+ }
297
+ const snapshots = new Map();
298
+ compatibleVersion((await upstream('GET', '/version')).body);
299
+ const changes = [];
300
+ const reviews = [];
301
+ let failure;
302
+ const capability = randomBytes(32).toString('hex');
303
+ redactor.register(capability);
304
+ const remember = async (path, reply) => {
305
+ const saved = snapshots.get(path);
306
+ if (saved)
307
+ return saved;
308
+ const selected = snapshot(path, reply ?? await upstream('GET', path));
309
+ snapshots.set(path, selected);
310
+ return selected;
311
+ };
312
+ let execution;
313
+ if (['exec', 'attach', 'port-forward'].includes(invocation.command.path)) {
314
+ if (user.exec)
315
+ throw new Failure('CAPABILITY_UNAVAILABLE', 'Helper-backed execution requires a reviewed containment boundary.');
316
+ const name = invocation.positionals[0]?.replace(/^pods?\//, '') ?? '';
317
+ if (!/^[a-z0-9][a-z0-9.-]*$/.test(name))
318
+ throw new Failure('USAGE', 'Bounded execution requires one named pod.', 2);
319
+ const target = `/api/v1/namespaces/${namespace}/pods/${name}`;
320
+ const pod = await upstream('GET', target);
321
+ await remember(target, pod);
322
+ if (invocation.command.path === 'port-forward') {
323
+ const ports = invocation.positionals.slice(1).map(port => {
324
+ if (!/^(?:0:)?[1-9][0-9]{0,4}$/.test(port))
325
+ throw new Failure('USAGE', 'Select numeric remote ports with optional zero local ports.', 2);
326
+ const value = Number(port.replace(/^0:/, ''));
327
+ if (value > 65535)
328
+ throw new Failure('USAGE', 'Remote ports must be between 1 and 65535.', 2);
329
+ return value;
330
+ });
331
+ if (!ports.length || ports.length > 8 || new Set(ports).size !== ports.length || invocation.flags.has('address') && invocation.flags.get('address') !== '127.0.0.1')
332
+ throw new Failure('USAGE', 'Select one to eight distinct ports bound to loopback.', 2);
333
+ execution = { argv: ['port-forward', name, ...ports.map(port => `0:${port}`), '--namespace', namespace, '--address=127.0.0.1'],
334
+ target, route: `${target}/portforward`, query: '' };
335
+ }
336
+ else {
337
+ const containers = pod.body.spec?.containers ?? [];
338
+ const container = invocation.flags.get('container') ?? containers[0]?.name;
339
+ if (!container || !containers.some(item => item.name === container))
340
+ throw new Failure('USAGE', 'Select a container in the named pod.', 2);
341
+ const query = new URLSearchParams({ container, stdout: 'true', stderr: 'true' });
342
+ if (!invocation.session)
343
+ query.set('timeout', '5s');
344
+ if (invocation.flags.get('stdin') === 'true')
345
+ query.set('stdin', 'true');
346
+ if (invocation.command.path === 'exec') {
347
+ const delimiter = invocation.argv.indexOf('--');
348
+ if (delimiter < 0 || delimiter === invocation.argv.length - 1)
349
+ throw new Failure('USAGE', 'Use exec <pod> -- <command> [arguments].', 2);
350
+ for (const arg of invocation.argv.slice(delimiter + 1))
351
+ query.append('command', arg);
352
+ }
353
+ execution = { argv: [...invocation.argv], target, route: `${target}/${invocation.command.path}`, query: query.toString() };
354
+ if (invocation.inputSha256)
355
+ execution.stdinSha256 = invocation.inputSha256;
356
+ // Container flags must precede the remote command delimiter.
357
+ execution.argv.splice(invocation.argv.indexOf('--') < 0 ? execution.argv.length : invocation.argv.indexOf('--'), 0, '--namespace', namespace, '--container', container);
358
+ }
359
+ }
360
+ const server = createServer((incoming, response) => {
361
+ void (async () => {
362
+ try {
363
+ // Upstream credentials stay in this process. The child gets a disposable capability.
364
+ if (!incoming.url?.startsWith(`/${capability}/`)) {
365
+ response.writeHead(403).end();
366
+ return;
367
+ }
368
+ let path = incoming.url.slice(capability.length + 1);
369
+ let method = incoming.method;
370
+ const chunks = [];
371
+ let size = 0;
372
+ for await (const chunk of incoming) {
373
+ size += Buffer.byteLength(chunk);
374
+ if (size > 1024 * 1024)
375
+ throw new Failure('OUTPUT_LIMIT', 'Mutation payload exceeds 1 MiB.');
376
+ chunks.push(Buffer.from(chunk));
377
+ }
378
+ const input = Buffer.concat(chunks);
379
+ const protobuf = String(incoming.headers['content-type']).includes('application/vnd.kubernetes.protobuf');
380
+ const body = !input.length ? undefined : protobuf ? (await import('./protobuf.js')).decodeKubernetes(input) : JSON.parse(input.toString());
381
+ let reply;
382
+ if (method === 'GET') {
383
+ reply = await upstream(method, path);
384
+ if (workflow?.kind === 'prepare' && reply.status < 300 && (reply.body.metadata?.uid || reply.body.items))
385
+ await remember(path, reply);
386
+ }
387
+ else if (invocation.command.effect === 'authorization') {
388
+ if (method !== 'POST' || !/^\/apis\/(?:authorization.k8s.io\/v1\/(?:selfsubjectaccessreviews|selfsubjectrulesreviews)|authentication.k8s.io\/v1(?:beta1|alpha1)?\/selfsubjectreviews)$/.test(path.split('?')[0]))
389
+ throw new Failure('EFFECT_UNCLASSIFIED', 'Only the selected non-persistent review API is allowed.');
390
+ reply = await upstream(method, path, body);
391
+ reviews.push({ operation: operation(method, path), upstreamStatus: reply.status, status: reply.body.status });
392
+ }
393
+ else {
394
+ requireWrites();
395
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method))
396
+ throw new Failure('EFFECT_UNCLASSIFIED', 'Unsupported mutation method.');
397
+ const url = new URL(path, 'http://local');
398
+ url.searchParams.delete('dryRun');
399
+ path = url.pathname + url.search;
400
+ const contentType = protobuf ? 'application/json' : String(incoming.headers['content-type'] ?? 'application/json');
401
+ let target = url.pathname;
402
+ if (target.endsWith('/approval'))
403
+ target = target.slice(0, -'/approval'.length);
404
+ if (target.endsWith('/ephemeralcontainers'))
405
+ target = target.slice(0, -'/ephemeralcontainers'.length);
406
+ let payload = body;
407
+ const acquisition = invocation.command.path === 'create token';
408
+ if (acquisition) {
409
+ if (method !== 'POST' || !new RegExp(`^/api/v1/namespaces/${namespace}/serviceaccounts/[a-z0-9][a-z0-9.-]*/token$`).test(url.pathname))
410
+ throw new Failure('EFFECT_UNCLASSIFIED', 'Only the selected TokenRequest endpoint is permitted.');
411
+ target = url.pathname.slice(0, -'/token'.length);
412
+ }
413
+ else if (method === 'POST') {
414
+ if (!validDocument(body) || !body.metadata?.name)
415
+ throw new Failure('USAGE', 'Preparation requires explicit resource names; generateName is not supported.', 2);
416
+ target = target.endsWith('/eviction') ? target.slice(0, -'/eviction'.length) : `${target}/${body.metadata.name}`;
417
+ }
418
+ if (workflow?.kind === 'server-dry-run') {
419
+ url.searchParams.set('dryRun', 'All');
420
+ changes.push({ operation: 'OP-027', method, path, target, contentType, body, wire: JSON.stringify(body), inputBase64: input.toString('base64') });
421
+ reply = await upstream(method, url.pathname + url.search, body, contentType);
422
+ response.writeHead(reply.status, { 'content-type': 'application/json' }).end(JSON.stringify(reply.body));
423
+ return;
424
+ }
425
+ const selected = await remember(target);
426
+ if (selected.collection)
427
+ throw new Failure('USAGE', 'Use delete <type> --all or a selector so preparation freezes individual targets.', 2);
428
+ const object = selected.objects[0];
429
+ if (!object && !(method === 'POST' && !url.pathname.endsWith('/eviction') || method === 'PATCH' && contentType.includes('apply-patch')))
430
+ throw new Failure('PREVIEW_TARGET_ABSENT', 'Destructive operations require an existing target with UID and resourceVersion; prepare a new request after selecting an existing object.');
431
+ if (acquisition) {
432
+ const spec = body.spec;
433
+ if (!spec || typeof spec.expirationSeconds !== 'number' || spec.expirationSeconds <= 0 || spec.expirationSeconds > 3600)
434
+ throw new Failure('USAGE', 'Request a positive token duration of at most one hour.', 2);
435
+ }
436
+ else if (method === 'DELETE' || target !== url.pathname && url.pathname.endsWith('/eviction')) {
437
+ const options = { ...(method === 'POST' ? body.deleteOptions : body), preconditions: { uid: object.uid, resourceVersion: object.version } };
438
+ delete options.dryRun;
439
+ payload = method === 'POST' ? { ...body, deleteOptions: options } : options;
440
+ }
441
+ else if (object && method === 'PATCH' && contentType.includes('json-patch')) {
442
+ if (!Array.isArray(body))
443
+ throw new Failure('UPSTREAM_INVALID', 'Invalid JSON patch.');
444
+ payload = [{ op: 'test', path: '/metadata/uid', value: object.uid }, { op: 'test', path: '/metadata/resourceVersion', value: object.version }, ...body];
445
+ }
446
+ else if (object && ['PUT', 'PATCH'].includes(method)) {
447
+ if (!validDocument(body))
448
+ throw new Failure('UPSTREAM_INVALID', 'Invalid mutation body.');
449
+ if (body.metadata?.resourceVersion && body.metadata.resourceVersion !== object.version)
450
+ throw new Failure('RESOURCE_CONFLICT', 'The supplied resourceVersion conflicts with the selected object.');
451
+ payload = { ...body, metadata: { ...body.metadata, resourceVersion: object.version, uid: object.uid } };
452
+ }
453
+ else if (selected.absent && method === 'PATCH' && contentType.includes('apply-patch')) {
454
+ if (!validDocument(body) || !body.metadata?.name || body.metadata.name !== target.split('/').at(-1))
455
+ throw new Failure('UPSTREAM_INVALID', 'Invalid apply creation body.');
456
+ method = 'POST';
457
+ url.pathname = target.slice(0, target.lastIndexOf('/'));
458
+ url.searchParams.delete('force');
459
+ path = url.pathname + url.search;
460
+ }
461
+ if (changes.length >= 100)
462
+ throw new Failure('SELECTION_LIMIT', 'Narrow the operation to at most 100 mutations.');
463
+ const bulk = invocation.command.path === 'delete' && (invocation.flags.get('all') === 'true' || invocation.flags.has('selector') || invocation.flags.has('field-selector') || invocation.positionals.length > 2 || invocation.positionals.some(value => value.includes(',')));
464
+ if (bulk) {
465
+ if (!/^(true|1)$/i.test(process.env.KUBERNETES_AXI_COLLECTION_DELETE ?? ''))
466
+ throw new Failure('CAPABILITY_UNAVAILABLE', 'Bulk deletion also requires KUBERNETES_AXI_COLLECTION_DELETE=true.');
467
+ const collection = new URL(target.slice(0, target.lastIndexOf('/')), 'http://local');
468
+ collection.searchParams.set('fieldSelector', `metadata.name=${object.name}`);
469
+ path = collection.pathname + collection.search;
470
+ }
471
+ const change = { operation: acquisition ? 'OP-030' : bulk ? 'OP-016' : operation(method, url.pathname), method, path, target, contentType: method === 'POST' ? 'application/json' : contentType, body: payload, wire: JSON.stringify(payload), inputBase64: input.toString('base64') };
472
+ changes.push(change);
473
+ {
474
+ // Preparation records intent only. It never sends a mutation upstream.
475
+ const before = object ? await upstream('GET', target) : undefined;
476
+ reply = { status: method === 'POST' ? 201 : 200, body: acquisition ? { kind: 'TokenRequest', apiVersion: 'authentication.k8s.io/v1', status: { token: 'preparation-only', expirationTimestamp: new Date(Date.now() + 60_000).toISOString() } } : method === 'DELETE' ? { kind: 'Status', apiVersion: 'v1', status: 'Success' } :
477
+ validDocument(body) && body.kind ? body : before?.body ?? { kind: 'Status', apiVersion: 'v1', status: 'Success' } };
478
+ }
479
+ }
480
+ response.writeHead(reply.status, { 'content-type': 'application/json' }).end(JSON.stringify(reply.body));
481
+ }
482
+ catch (error) {
483
+ failure = error;
484
+ response.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ kind: 'Status', apiVersion: 'v1', status: 'Failure', reason: 'InternalError', message: 'The guarded operation was refused.', code: 500 }));
485
+ }
486
+ })();
487
+ });
488
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
489
+ const address = server.address();
490
+ if (!address || typeof address === 'string')
491
+ throw new Failure('CONFIG_INVALID', 'Cannot establish the private operation channel.');
492
+ return {
493
+ ...(execution ? { prepared: true } : {}),
494
+ ...(invocation.flags.has('raw') ? { rawPath: `/${capability}${invocation.flags.get('raw')}` } : {}),
495
+ config: { apiVersion: 'v1', kind: 'Config', clusters: [{ name: 'selected', cluster: { server: `http://127.0.0.1:${address.port}/${capability}` } }], users: [{ name: 'selected', user: {} }], contexts: [{ name: config['current-context'] ?? 'selected', context: { cluster: 'selected', user: 'selected', namespace } }], 'current-context': config['current-context'] ?? 'selected' },
496
+ finish: async (success = true) => {
497
+ if (failure)
498
+ throw failure;
499
+ if (invocation.command.effect === 'authorization') {
500
+ const denied = reviews.find(review => review.upstreamStatus >= 300);
501
+ if (denied)
502
+ throw new Failure(denied.upstreamStatus === 401 ? 'AUTH_REQUIRED' : 'ACCESS_DENIED', 'The selected identity cannot create this review.', 1, { upstreamStatus: denied.upstreamStatus });
503
+ return { reviews, complete: reviews.every(review => !review.status?.incomplete) };
504
+ }
505
+ if (!success)
506
+ throw new Failure('UPSTREAM_FAILED', 'The command failed; no prepared request was issued. Check its arguments, targets and permissions.');
507
+ if (workflow?.kind !== 'prepare')
508
+ return { dryRun: 'server', operations: changes.map(change => ({ operation: 'OP-027', method: change.method, path: change.path })), persisted: false };
509
+ if (changes.filter(change => change.method === 'DELETE').length > 1 && !/^(true|1)$/i.test(process.env.KUBERNETES_AXI_COLLECTION_DELETE ?? ''))
510
+ throw new Failure('CAPABILITY_UNAVAILABLE', 'Bulk deletion also requires KUBERNETES_AXI_COLLECTION_DELETE=true.');
511
+ const key = randomUUID();
512
+ const directory = join(root(invocation.config), key);
513
+ await privateDirectory(directory);
514
+ const signingKey = randomBytes(32).toString('hex');
515
+ const display = redactor.value({ command: invocation.command.path, namespace, selected: [...snapshots.values()], changes: changes.map(change => ({ operation: change.operation, method: change.method, path: change.path, change: publicBody(change.body, /\/secrets(?:\/|\?|$)/.test(change.path)) })), ...(execution ? { execution } : {}) });
516
+ const plan = { version: 1, key, binding: mac(signingKey, nativeBinding ?? config), namespace, command: invocation.command.path,
517
+ awaitDeletion: invocation.command.path === 'drain' || invocation.command.path === 'delete' && invocation.flags.get('wait') !== 'false', snapshots: [...snapshots.values()], changes, display, ...(execution ? { execution } : {}) };
518
+ const saved = { plan, digest: mac(signingKey, plan), state: 'prepared', results: [] };
519
+ await writePrivate(join(directory, 'key'), signingKey);
520
+ await writePrivate(join(directory, 'request.json'), JSON.stringify(saved));
521
+ await writePrivate(join(directory, 'claim'), 'prepared');
522
+ return { request: key, state: 'prepared', mutations: changes.length, persisted: false, help: [`${prefix(invocation)} preview ${key}`] };
523
+ },
524
+ close: async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); },
525
+ };
526
+ }
@@ -0,0 +1,26 @@
1
+ import type { Redactor } from './redaction.js';
2
+ interface Entry {
3
+ name: string;
4
+ cluster?: Record<string, unknown>;
5
+ user?: Record<string, unknown>;
6
+ context?: {
7
+ cluster: string;
8
+ user: string;
9
+ namespace?: string;
10
+ };
11
+ }
12
+ interface Config {
13
+ clusters: Entry[];
14
+ users: Entry[];
15
+ contexts: Entry[];
16
+ 'current-context'?: string;
17
+ }
18
+ export declare function inspectNative(binary: string, snapshot: string, redactor: Redactor, signal: AbortSignal): Promise<Config>;
19
+ export declare function nativeConfig(binary: string, selection: string, context: string | undefined, directory: string, trustExec: boolean, redactor: Redactor, signal: AbortSignal, resourceMutation: boolean): Promise<{
20
+ config: object;
21
+ binding: string;
22
+ source: string;
23
+ namespace: string;
24
+ close: () => Promise<void>;
25
+ }>;
26
+ export {};