@starci/hfs 4.0.9 → 4.0.10

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/lint/run.mjs +80 -7
  3. package/package.json +3 -2
  4. package/runtime/engine/admission.mjs +308 -0
  5. package/runtime/engine/canonical-json.mjs +10 -0
  6. package/runtime/engine/config.mjs +36 -44
  7. package/runtime/engine/db/blob.mjs +315 -0
  8. package/runtime/engine/db/ledger-paths.mjs +83 -0
  9. package/runtime/engine/db/ledger.mjs +1118 -0
  10. package/runtime/engine/db/machine-connection.mjs +135 -0
  11. package/runtime/engine/db/machine-schema.mjs +84 -0
  12. package/runtime/engine/db/machine.mjs +1367 -0
  13. package/runtime/engine/db/migrations/machine/0001-init.sql +924 -0
  14. package/runtime/engine/db/migrations/runtime/0001-init.sql +1108 -0
  15. package/runtime/engine/db/provider-reservations.mjs +101 -0
  16. package/runtime/engine/digest.mjs +16 -0
  17. package/runtime/engine/refuse.mjs +11 -0
  18. package/runtime/engine/secrets.mjs +130 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  20. package/runtime/knowledge/hfs/rules.yaml +8 -8
  21. package/runtime/modules/kernel/failure-codes.yaml +40 -0
  22. package/runtime/modules/models/registry.yaml +1 -39
  23. package/runtime/modules/models/runtimes.yaml +0 -6
  24. package/runtime/modules/ops/_labels.yaml +53 -0
  25. package/runtime/scripts/api/fs/lib.mjs +6 -0
  26. package/runtime/scripts/api/git/lib.mjs +2 -0
  27. package/runtime/scripts/api/node/lib.mjs +14 -0
  28. package/runtime/scripts/api/node/spawn-node.mjs +6 -0
  29. package/runtime/scripts/api/process/lib.mjs +111 -0
  30. package/runtime/scripts/api/process/owned-process.mjs +78 -0
  31. package/runtime/scripts/api/process/resolve-real-tool.mjs +46 -0
  32. package/runtime/scripts/api/process/run-program.mjs +7 -0
  33. package/runtime/scripts/api/process/stop-owned-process.mjs +9 -0
  34. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  35. package/runtime/scripts/api/sops/lib.mjs +230 -10
  36. package/runtime/scripts/api/sops/seal.mjs +8 -4
  37. package/runtime/scripts/connectors/lib.mjs +488 -0
  38. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  39. package/runtime/scripts/hfs/secret.mjs +45 -11
  40. package/runtime/scripts/lib/clip.mjs +19 -0
  41. package/runtime/scripts/lib/display-names.mjs +259 -0
  42. package/runtime/scripts/lib/example-refs.mjs +158 -0
  43. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  44. package/runtime/scripts/lib/json-schema.mjs +52 -0
  45. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  46. package/runtime/scripts/lib/path-key.mjs +4 -4
  47. package/runtime/scripts/lib/process-identity.mjs +6 -0
  48. package/runtime/scripts/lib/read-yaml.mjs +18 -0
  49. package/runtime/scripts/lib/redact.mjs +161 -0
  50. package/runtime/scripts/lib/sleep.mjs +7 -0
  51. package/runtime/scripts/lib/sops-envelope.mjs +54 -0
  52. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  53. package/runtime/scripts/lib/sqlite.mjs +21 -0
@@ -12,3 +12,9 @@ export function unlinkOnly(p) {
12
12
  try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { /* verified below */ } }
13
13
  try { fs.lstatSync(p); return false; } catch (error) { return error?.code === 'ENOENT'; }
14
14
  }
15
+
16
+ /** Construct an archive-reader refusal with its existing error code. */
17
+ export const zipRefuse = (message, code = 'zip-corrupt') => Object.assign(new Error(message), { code });
18
+
19
+ /** Relative ZIP entry names exclude traversal, drive roots and control characters. */
20
+ export const validZipName=name=>Boolean(name)&&!name.startsWith('/')&&!/^[A-Za-z]:/.test(name)&&!/[\0\r\n\\]/.test(name)&&!name.split('/').includes('..');
@@ -6,6 +6,7 @@
6
6
  // caller's runner into {ok, stdout, stderr}. Only the call files beside this one import it: each names one git verb
7
7
  // (diff.mjs, ls-files.mjs, worktree-*.mjs, ...), and a caller folds the spawn result it gets back with the pure
8
8
  // helpers of scripts/lib/git.mjs (gitResultOf, gitOutputOf).
9
+ import { assertMutationFence } from '../../lib/mutation-fence.mjs';
9
10
  import { spawnSync } from 'node:child_process';
10
11
  import { withoutGitLocalEnv } from '../../lib/git.mjs';
11
12
 
@@ -16,6 +17,7 @@ import { withoutGitLocalEnv } from '../../lib/git.mjs';
16
17
  * ERR_UNKNOWN_ENCODING, so it reaches spawnSync as encoding:null (Buffer output either way).
17
18
  */
18
19
  export const gitSpawn = (file, args, options = {}) => {
20
+ assertMutationFence({ kind: 'git-effect', args });
19
21
  const spawn = { encoding: 'utf8', windowsHide: true, ...options };
20
22
  if (spawn.encoding === 'buffer') spawn.encoding = null;
21
23
  return spawnSync(file, args, spawn);
@@ -0,0 +1,14 @@
1
+ // scripts/api/node/lib.mjs — the runner of the runtime's own scripts as child processes: the node binary that runs this
2
+ // process (process.execPath), a hidden window, utf8 text. The call files beside it (run-script.mjs, syntax-check.mjs,
3
+ // run-node.mjs, spawn-node.mjs, exec-node.mjs) each name one use; nothing outside scripts/api/node imports this runner.
4
+ import { assertMutationFence } from '../../lib/mutation-fence.mjs';
5
+ import { execFile, spawn, spawnSync } from 'node:child_process';
6
+
7
+ /** `node <args>` with this process's node binary, waited for; options pass through last (cwd, stdio, env, timeout, maxBuffer, input). */
8
+ export const nodeSpawn = (args, options = {}) => { assertMutationFence({ kind: 'node-effect', args }); return spawnSync(process.execPath, args, { encoding: 'utf8', windowsHide: true, ...options }); };
9
+
10
+ /** `node <args>` with this process's node binary, started and not waited for: the ChildProcess (detached, stdio, env pass through). */
11
+ export const nodeStart = (args, options = {}) => { assertMutationFence({ kind: 'node-effect', args }); return spawn(process.execPath, args, { windowsHide: true, ...options }); };
12
+
13
+ /** `node <args>` with this process's node binary, waited for without blocking the thread: callback(error, stdout, stderr). */
14
+ export const nodeExecFile = (args, options, callback) => { assertMutationFence({ kind: 'node-effect', args }); return execFile(process.execPath, args, { encoding: 'utf8', windowsHide: true, ...options }, callback); };
@@ -0,0 +1,6 @@
1
+ // spawn-node.mjs — `node <args>` with this process's node binary, started and not waited for: a long-running runtime
2
+ // process (a server, an engine, a detached helper). Returns the ChildProcess.
3
+ import { nodeStart } from './lib.mjs';
4
+
5
+ /** Starts `node <args>`; options (cwd, env, stdio, detached) pass through. */
6
+ export const spawnNode = (args, options = {}) => nodeStart(args, options);
@@ -0,0 +1,111 @@
1
+ // scripts/api/process/lib.mjs — what the process call files beside it share: the spawn options every child of the
2
+ // runtime carries. The call files (process-list.mjs, kill-tree.mjs, run-shell.mjs, spawn-detached.mjs, set-priority.mjs,
3
+ // hide-child-windows.mjs, ...) each name one use of the host's process table or a child process.
4
+
5
+ /**
6
+ * `list` with windowsHide: true added to its options argument. `hasArgv`:
7
+ * the call's second argument may be an argv array (spawn, execFile) rather
8
+ * than options (exec). An explicit windowsHide (true or false) is kept.
9
+ */
10
+ export function withWindowsHide(list, hasArgv) {
11
+ const out = [...list];
12
+ let slot = 1;
13
+ if (hasArgv && (Array.isArray(out[1]) || (out[1] == null && out.length > 2))) slot = 2;
14
+ const options = out[slot];
15
+ if (options == null) out.splice(slot, out.length > slot ? 1 : 0, { windowsHide: true });
16
+ else if (typeof options === 'function') out.splice(slot, 0, { windowsHide: true });
17
+ else if (typeof options === 'object' && !('windowsHide' in options)) out[slot] = { ...options, windowsHide: true };
18
+ return out;
19
+ }
20
+
21
+ // ---- the host process table (process-list.mjs, process-list-async.mjs) ------------------------------------------
22
+
23
+ const psQuote = (s) => String(s).replace(/'/g, "''");
24
+
25
+ /** The PowerShell script of one process-table read: Win32_Process rows (optionally filtered), with CPU when asked. */
26
+ export function processListScript({ where = null, cmdMax = 4000, cpu = false } = {}) {
27
+ const filter = where ? ` -Filter '${psQuote(where)}'` : '';
28
+ return [
29
+ ...(cpu ? ['$perf = @{}', 'Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | ForEach-Object { if ($_.IDProcess -gt 0) { $perf[[int]$_.IDProcess] = [double]$_.PercentProcessorTime } }'] : []),
30
+ `Get-CimInstance Win32_Process${filter} | ForEach-Object { $c = [string]$_.CommandLine; [pscustomobject]@{ pid = [int]$_.ProcessId; ppid = [int]$_.ParentProcessId; name = [string]$_.Name; exe = [string]$_.ExecutablePath;`,
31
+ ` cmd = $c.Substring(0, [Math]::Min(${Number(cmdMax) || 4000}, $c.Length)); ws = [int64]$_.WorkingSetSize;`,
32
+ ` created = $(if ($_.CreationDate) { ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() } else { 0 })${cpu ? '; cpu = $perf[[int]$_.ProcessId]' : ''} } } | ConvertTo-Json -Compress`,
33
+ ].join('\n');
34
+ }
35
+
36
+ /** The rows of the PowerShell read's JSON (one object or an array). */
37
+ export const processRowsOfJson = (text) => {
38
+ const t = String(text ?? '').trim();
39
+ const v = t ? JSON.parse(t) : [];
40
+ return (Array.isArray(v) ? v : [v]).map((p) => ({ ...p, pid: Number(p.pid), ppid: Number(p.ppid), cmd: String(p.cmd ?? '') }));
41
+ };
42
+
43
+ /** The rows of `ps -eo pid=,ppid=,comm=,args=` (exe/created/ws are null there). */
44
+ export const processRowsOfPs = (text, cmdMax = 4000) => String(text ?? '').split(/\r?\n/).map((line) => /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)).filter(Boolean)
45
+ .map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), name: m[3].split('/').pop(), exe: null, cmd: m[4].slice(0, cmdMax), created: null, ws: null }));
46
+
47
+ // ---- the environment of other processes (process-env.mjs) -------------------------------------------------------
48
+
49
+ // Windows keeps a process's environment block in its PEB: PEB.ProcessParameters (+0x20) -> Environment (+0x80) and
50
+ // EnvironmentSize (+0x3F0), x64 only. A same-user process opens with PROCESS_QUERY_INFORMATION | PROCESS_VM_READ (0x0410).
51
+ export const PROCESS_ENV_NATIVE = `
52
+ using System;
53
+ using System.Collections.Generic;
54
+ using System.Runtime.InteropServices;
55
+ using System.Text;
56
+ public static class StarciProcessEnv {
57
+ [StructLayout(LayoutKind.Sequential)] struct PBI { public IntPtr Reserved1; public IntPtr PebBaseAddress; public IntPtr Reserved2a; public IntPtr Reserved2b; public IntPtr UniqueProcessId; public IntPtr Reserved3; }
58
+ [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr h, int cls, ref PBI pbi, int len, out int ret);
59
+ [DllImport("kernel32.dll")] static extern bool ReadProcessMemory(IntPtr h, IntPtr addr, byte[] buf, IntPtr size, out IntPtr read);
60
+ [DllImport("kernel32.dll")] static extern IntPtr OpenProcess(int access, bool inherit, int pid);
61
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
62
+ [DllImport("kernel32.dll")] static extern bool IsWow64Process(IntPtr h, out bool wow);
63
+ static byte[] Read(IntPtr h, IntPtr addr, int size) {
64
+ var buf = new byte[size]; IntPtr n;
65
+ if (!ReadProcessMemory(h, addr, buf, (IntPtr)size, out n) || (int)n != size) return null;
66
+ return buf;
67
+ }
68
+ public static Dictionary<string, string> Env(int pid) {
69
+ var h = OpenProcess(0x0410, false, pid);
70
+ if (h == IntPtr.Zero) return null;
71
+ try { return EnvHandle(h); } finally { CloseHandle(h); }
72
+ }
73
+ public static Dictionary<string, string> EnvHandle(IntPtr h) {
74
+ bool wow; if (IsWow64Process(h, out wow) && wow) return null;
75
+ var pbi = new PBI(); int ret;
76
+ if (NtQueryInformationProcess(h, 0, ref pbi, Marshal.SizeOf(pbi), out ret) != 0) return null;
77
+ var peb = Read(h, pbi.PebBaseAddress + 0x20, 8); if (peb == null) return null;
78
+ var parameters = (IntPtr)BitConverter.ToInt64(peb, 0);
79
+ var envPtr = Read(h, parameters + 0x80, 8); if (envPtr == null) return null;
80
+ var sizeBuf = Read(h, parameters + 0x3F0, 8); if (sizeBuf == null) return null;
81
+ long size = BitConverter.ToInt64(sizeBuf, 0);
82
+ if (size <= 0 || size > 4 * 1024 * 1024) return null;
83
+ var block = Read(h, (IntPtr)BitConverter.ToInt64(envPtr, 0), (int)size); if (block == null) return null;
84
+ var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
85
+ foreach (var entry in Encoding.Unicode.GetString(block).Split('\\0')) {
86
+ if (entry.Length == 0) continue;
87
+ int i = entry.IndexOf('=', 1); if (i < 0) continue;
88
+ d[entry.Substring(0, i)] = entry.Substring(i + 1);
89
+ }
90
+ return d;
91
+ }
92
+ }`;
93
+
94
+ /** The PowerShell script reading `names` from the environment of `pids` (every process when null): JSON [{pid, readable, values}]. */
95
+ export function processEnvScript({ names, pids = null }) {
96
+ const wanted = JSON.stringify([...names].map(String));
97
+ const ids = pids ? `@(${[...pids].map((p) => Number(p)).filter(Number.isInteger).join(',')})` : '(Get-Process).Id';
98
+ return [
99
+ `Add-Type -TypeDefinition @'${PROCESS_ENV_NATIVE}\n'@ -Language CSharp`,
100
+ `$names = '${psQuote(wanted)}' | ConvertFrom-Json`,
101
+ `$rows = foreach ($id in ${ids}) { $e = [StarciProcessEnv]::Env([int]$id); $v = [ordered]@{}; foreach ($n in $names) { $v[$n] = if ($null -ne $e -and $e.ContainsKey($n)) { $e[$n] } else { $null } }; [pscustomobject]@{ pid = [int]$id; readable = ($null -ne $e); values = $v } }`,
102
+ '@($rows) | ConvertTo-Json -Compress -Depth 4',
103
+ ].join('\n');
104
+ }
105
+
106
+ /** The rows of that script's JSON. */
107
+ export const processEnvRowsOfJson = (text) => {
108
+ const t = String(text ?? '').trim();
109
+ const v = t ? JSON.parse(t) : [];
110
+ return (Array.isArray(v) ? v : [v]).map((r) => ({ pid: Number(r.pid), readable: r.readable === true, values: r.values ?? {} }));
111
+ };
@@ -0,0 +1,78 @@
1
+ // Process-object identity and bounded termination; callers supply custody captured at their own launch.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { PROCESS_ENV_NATIVE } from './lib.mjs';
4
+ import { OWNED_PROCESS_SCHEMA, validProcessIdentity as validIdentity } from '../../lib/process-identity.mjs';
5
+ import { allocationMs } from '../../../engine/config.mjs';
6
+
7
+ const NATIVE = `
8
+ public static class StarciOwnedProcess {
9
+ [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
10
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetProcessTimes(IntPtr h, out long created, out long exited, out long kernel, out long user);
11
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool QueryFullProcessImageName(IntPtr h, uint flags, StringBuilder exe, ref int size);
12
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr h, uint code);
13
+ [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr h, uint ms);
14
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
15
+ public static object[] Call(int pid, bool stop, string birth, string image, string ownershipKey, string ownershipValue, uint waitMs) {
16
+ var h = OpenProcess(0x00101000u | (stop ? 1u : 0u) | (ownershipKey != null ? 0x0410u : 0u), false, pid);
17
+ if (h == IntPtr.Zero) return new object[] {false, "unknown", "process-open-unverified", null, null, Marshal.GetLastWin32Error()};
18
+ try {
19
+ long created, exited, kernel, user; var exe = new StringBuilder(32768); int size = exe.Capacity;
20
+ if (!GetProcessTimes(h, out created, out exited, out kernel, out user) || !QueryFullProcessImageName(h, 0, exe, ref size))
21
+ return new object[] {false, "unknown", "process-identity-unreadable", null, null, Marshal.GetLastWin32Error()};
22
+ var actualBirth = created.ToString(System.Globalization.CultureInfo.InvariantCulture); var actualImage = exe.ToString();
23
+ if (stop && (actualBirth != birth || !String.Equals(actualImage, image, StringComparison.OrdinalIgnoreCase)))
24
+ return new object[] {false, "refused", "process-identity-conflict", actualBirth, actualImage, 0};
25
+ if (!stop && ownershipKey != null) {
26
+ var environment = StarciProcessEnv.EnvHandle(h);
27
+ if (environment == null || !environment.ContainsKey(ownershipKey) || environment[ownershipKey] != ownershipValue)
28
+ return new object[] {false, "unknown", "process-launch-custody-unverified", actualBirth, actualImage, 0};
29
+ }
30
+ var first = WaitForSingleObject(h, 0);
31
+ if (!stop) return first == 258u ? new object[] {true, "captured", "process-handle-live", actualBirth, actualImage, 0}
32
+ : new object[] {false, "unknown", "process-not-live-at-capture", actualBirth, actualImage, 0};
33
+ if (first == 0u) return new object[] {true, "gone", "process-handle-signaled", actualBirth, actualImage, 0};
34
+ if (first != 258u || !TerminateProcess(h, 1))
35
+ return new object[] {false, "unknown", "process-termination-unverified", actualBirth, actualImage, Marshal.GetLastWin32Error()};
36
+ return WaitForSingleObject(h, waitMs) == 0u
37
+ ? new object[] {true, "stopped", "process-handle-signaled", actualBirth, actualImage, 0}
38
+ : new object[] {false, "unknown", "process-closure-unverified", actualBirth, actualImage, 0};
39
+ } finally { CloseHandle(h); }
40
+ }
41
+ }`;
42
+ const quote = (value) => value == null ? '$null' : `'${String(value).replaceAll("'", "''")}'`;
43
+
44
+ /**
45
+ * Capture a live process, or stop the exact captured identity on one verified native handle.
46
+ * Only Windows is qualified; custody, platform, child completion or receipt failures remain refused or unknown.
47
+ * The capture and stop call files constrain their modes; this engine preserves the supplied identity and raw outcome.
48
+ */
49
+ export function ownedProcess(pid, { identity = null, ownership = null, waitMs = allocationMs('workerClose.stopVerifyMs'), run = spawnSync, platform = process.platform } = {}) {
50
+ const base = { schema: OWNED_PROCESS_SCHEMA, pid, ok: false, outcome: 'unknown', identity: identity ?? null };
51
+ if (platform !== 'win32') return { ...base, reason: 'process-platform-unverified' };
52
+ if (!Number.isInteger(pid) || pid <= 0 || identity && !validIdentity(identity)
53
+ || ownership && (typeof ownership.key !== 'string' || !ownership.key || typeof ownership.value !== 'string' || !ownership.value))
54
+ return { ...base, outcome: 'refused', reason: 'process-custody-required' };
55
+ if (!Number.isInteger(waitMs) || waitMs <= 0 || waitMs > 60000) return { ...base, outcome: 'refused', reason: 'process-wait-invalid' };
56
+ const script = [`Add-Type -TypeDefinition @'\n${PROCESS_ENV_NATIVE}\n${NATIVE}\n'@ -Language CSharp`,
57
+ `$r = [StarciOwnedProcess]::Call(${pid}, $${identity ? 'true' : 'false'}, ${quote(identity?.birth)}, ${quote(identity?.exe)}, ${quote(ownership?.key)}, ${quote(ownership?.value)}, ${waitMs})`,
58
+ `@{ schema = '${OWNED_PROCESS_SCHEMA}'; pid = ${pid}; ok = $r[0]; outcome = $r[1]; proof = $r[2]; birth = $r[3]; exe = $r[4]; nativeError = $r[5] } | ConvertTo-Json -Compress`].join('\n');
59
+ let result;
60
+ try { result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script],
61
+ { encoding: 'utf8', windowsHide: true, timeout: waitMs + 15000, maxBuffer: 1024 * 1024 }); }
62
+ catch (error) { return { ...base, reason: 'process-call-incomplete', error: String(error?.message ?? error) }; }
63
+ if (result?.status !== 0 || result.error || result.signal)
64
+ return { ...base, reason: 'process-call-incomplete', status: result?.status ?? null, signal: result?.signal ?? null, error: result?.error?.message ?? null };
65
+ let data;
66
+ try { data = JSON.parse(String(result.stdout ?? '').trim()); } catch { return { ...base, reason: 'process-receipt-incomplete' }; }
67
+ const actual = { pid, birth: data?.birth, exe: data?.exe };
68
+ if (data?.schema !== OWNED_PROCESS_SCHEMA || data.pid !== pid || typeof data.ok !== 'boolean'
69
+ || !['unknown', 'refused', 'captured', 'stopped', 'gone'].includes(data.outcome) || typeof data.proof !== 'string'
70
+ || data.ok && (!validIdentity(actual) || identity && (actual.birth !== identity.birth || actual.exe.toLowerCase() !== identity.exe.toLowerCase())
71
+ || data.proof !== (identity ? 'process-handle-signaled' : 'process-handle-live')
72
+ || !(identity ? ['gone', 'stopped'] : ['captured']).includes(data.outcome)))
73
+ return { ...base, reason: 'process-receipt-incomplete' };
74
+ return { schema: OWNED_PROCESS_SCHEMA, pid, ok: data.ok, outcome: data.outcome, proof: data.proof,
75
+ identity: identity ?? (validIdentity(actual) ? actual : null), nativeError: data.nativeError ?? null,
76
+ ...(identity && validIdentity(actual) && (actual.birth !== identity.birth || actual.exe.toLowerCase() !== identity.exe.toLowerCase()) ? { observedIdentity: actual } : {}),
77
+ ...(data.ok ? {} : { reason: data.proof }) };
78
+ }
@@ -0,0 +1,46 @@
1
+ // resolve-real-tool.mjs - find the host executable behind a StarCi PATH wrapper without ever selecting that wrapper again.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const pathValue = (env) => {
7
+ const key = Object.keys(env ?? {}).find((name) => name.toLowerCase() === 'path');
8
+ return key ? String(env[key] ?? '') : '';
9
+ };
10
+
11
+ const canonical = (value, { platform, realpath }) => {
12
+ let resolved = path.resolve(value);
13
+ try { resolved = realpath(resolved); } catch { /* a missing PATH entry stays comparable by its resolved spelling */ }
14
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
15
+ };
16
+
17
+ const executableNames = (program, { env, platform }) => {
18
+ if (platform !== 'win32' || path.extname(program)) return [program];
19
+ const extensions = String(env?.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
20
+ const names = [program];
21
+ for (const extension of extensions) names.push(`${program}${extension}`, `${program}${extension.toLowerCase()}`);
22
+ return [...new Set(names)];
23
+ };
24
+
25
+ /** Resolve `program` from PATH while excluding <home>/.starci/bin by canonical directory identity. */
26
+ export function resolveRealTool(program, { env = process.env, home = os.homedir(), platform = process.platform,
27
+ stat = fs.statSync, realpath = fs.realpathSync.native } = {}) {
28
+ const name = String(program ?? '');
29
+ if (!name || path.basename(name) !== name) return null;
30
+ const delimiter = platform === 'win32' ? ';' : ':';
31
+ const shimKey = canonical(path.join(home, '.starci', 'bin'), { platform, realpath });
32
+ const names = executableNames(name, { env, platform });
33
+ for (const rawEntry of pathValue(env).split(delimiter).filter(Boolean)) {
34
+ const entry = rawEntry.replace(/^"(.*)"$/, '$1');
35
+ if (canonical(entry, { platform, realpath }) === shimKey) continue;
36
+ for (const executable of names) {
37
+ const candidate = path.join(entry, executable);
38
+ try {
39
+ if (stat(candidate).isFile()) {
40
+ try { return realpath(candidate); } catch { return path.resolve(candidate); }
41
+ }
42
+ } catch { /* try the next executable spelling or PATH entry */ }
43
+ }
44
+ }
45
+ return null;
46
+ }
@@ -0,0 +1,7 @@
1
+ // run-program.mjs — run one declared program argv (`file args`, never a shell), waited for: a UAT policy command of an
2
+ // assisted run (scripts/uat/assisted-runner.mjs), resolved to its file by scripts/uat/launch.mjs launchFor. Returns the
3
+ // spawnSync result {status, stdout, stderr, error, signal} (utf8 text, a hidden window).
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /** Runs `file args`; options (cwd, env, timeout, stdio, input) pass through. */
7
+ export const runProgram = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', windowsHide: true, ...options });
@@ -0,0 +1,9 @@
1
+ // The exact-identity stop call; never infer custody from a PID alone.
2
+ import { ownedProcess as call } from './owned-process.mjs';
3
+ import { OWNED_PROCESS_SCHEMA, validProcessIdentity as validIdentity } from '../../lib/process-identity.mjs';
4
+
5
+ /** Stop only the process object whose immutable birth and executable were captured by its owner; a signaled handle proves closure, not descendant closure. */
6
+ export function stopOwnedProcess(identity, options = {}) {
7
+ if (!validIdentity(identity)) return { schema: OWNED_PROCESS_SCHEMA, pid: identity?.pid ?? null, ok: false, outcome: 'refused', reason: 'process-custody-required', identity: identity ?? null };
8
+ return call(identity.pid, { ...options, identity });
9
+ }
@@ -1,20 +1,13 @@
1
- // decrypt.mjs — one `sops` decryption: the custody reads (scripts/api/sops/exec-env.mjs execEnv) and the test
2
- // secrets (scripts/uat/test-secret.mjs testSecret). The plaintext stays in this process's memory (stdout over a pipe).
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
6
- import { resolveSops } from './lib.mjs';
1
+ // decrypt.mjs — the public SOPS file-decrypt call.
2
+ import { runSopsFile } from './lib.mjs';
7
3
 
8
4
  /**
9
- * Run `<bin> <args>` (bin null: the sops resolveSops finds) with SOPS_AGE_KEY_FILE defaulted to ~/.starci/master.identity.
10
- * {status, stdout, stderr, error}; a missing sops is {status: null, error: SOPS_MISSING}.
5
+ * Decrypt one custody file from caller-supplied SOPS arguments; the shared file owner requires the caller's original identity selection.
6
+ * @param {string|null} bin Explicit SOPS executable, or null to use the selected environment's resolver.
7
+ * @param {string[]} args SOPS arguments forwarded unchanged to the file operation.
8
+ * @param {object} options Identity, invocation, environment and capture options owned by runSopsFile.
9
+ * @returns {{status: number|null, stdout: string|null, stderr: string|null, error: Error|null}} The observed child result or typed identity refusal.
11
10
  */
12
- export function decrypt(bin, args, { env = process.env, cwd = undefined, maxBuffer = 16 * 1024 * 1024, timeout = undefined } = {}) {
13
- const exe = bin ?? resolveSops(env);
14
- if (!exe) return { status: null, stdout: '', stderr: '', error: Object.assign(new Error('sops is not installed (Windows: winget install Mozilla.SOPS)'), { code: 'SOPS_MISSING' }) };
15
- const r = spawnSync(exe, args, {
16
- cwd, encoding: 'utf8', windowsHide: true, maxBuffer, timeout,
17
- env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
18
- });
19
- return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
11
+ export function decrypt(bin, args, options = {}) {
12
+ return runSopsFile(bin, args, 'decrypt', options);
20
13
  }
@@ -1,9 +1,12 @@
1
- // scripts/api/sops/lib.mjs — what the sops call files beside it share: the input type a custody file's name states.
1
+ // scripts/api/sops/lib.mjs — shared file formats, identity admission and SOPS child capture.
2
2
  // Custody members are committed as `<name>.<fmt>.enc`; sops infers a format from the LAST extension, so `.enc` reads as
3
3
  // binary and every read states the format (--input-type). The call files (decrypt.mjs, encrypt.mjs, exec-env.mjs) each
4
4
  // name one sops use.
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readSecretBytes, CREDENTIAL_FILE_MAX_BYTES, sopsIdentityEnv } from '../../../engine/secrets.mjs';
9
+ import { selectedAgeEnvelope } from '../../lib/sops-envelope.mjs';
7
10
 
8
11
  const FORMATS = { yaml: 'yaml', yml: 'yaml', json: 'json', env: 'dotenv', dotenv: 'dotenv' };
9
12
 
@@ -19,17 +22,234 @@ export function custodyInputType(file, override) {
19
22
  return type;
20
23
  }
21
24
 
22
- /** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
23
- export function resolveSops(env = process.env) {
24
- const win = process.platform === 'win32';
25
- const names = win ? ['sops.exe', 'sops'] : ['sops'];
25
+ /** Resolve SOPS from PATH and WinGet. Consumers select PATHEXT names and the full package tree for their launcher. */
26
+ export function resolveSops(env = process.env, { platform = process.platform, pathext = false, wingetPackageTree = false, filesystem = fs } = {}) {
27
+ const win = platform === 'win32';
28
+ const paths = win ? path.win32 : path.posix;
29
+ const names = win
30
+ ? (pathext ? String(env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean).map((ext) => `sops${ext}`) : ['sops.exe', 'sops'])
31
+ : ['sops'];
26
32
  const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
27
33
  if (win && env.LOCALAPPDATA) {
28
- const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
29
- dirs.push(path.join(winget, 'Links'));
30
- const packages = path.join(winget, 'Packages');
31
- try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
34
+ const winget = paths.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
35
+ dirs.push(paths.join(winget, 'Links'));
36
+ const packages = paths.join(winget, 'Packages');
37
+ try {
38
+ for (const entry of filesystem.readdirSync(packages)) {
39
+ if (!wingetPackageTree && !/sops/i.test(entry)) continue;
40
+ const directory = paths.join(packages, entry);
41
+ dirs.push(directory);
42
+ if (wingetPackageTree) {
43
+ try {
44
+ for (const nested of filesystem.readdirSync(directory, { withFileTypes: true })) {
45
+ if (nested.isDirectory()) dirs.push(paths.join(directory, nested.name));
46
+ }
47
+ } catch { /* unreadable package */ }
48
+ }
49
+ }
50
+ } catch { /* no WinGet packages */ }
51
+ }
52
+ for (const directory of dirs) for (const name of names) {
53
+ const file = paths.join(directory, name);
54
+ try { if (filesystem.statSync(file).isFile()) return file; } catch { /* next candidate */ }
32
55
  }
33
- for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
34
56
  return null;
35
57
  }
58
+
59
+ /**
60
+ * Select the caller's identity once, then run the encrypt/decrypt file action through its admitted SOPS path.
61
+ * Inline selection delegates the unchanged request to runSelectedSops; original FILE and recipient-only modes retain their existing child transport.
62
+ * @param {string|null} bin Explicit SOPS executable, or null to resolve it from the selected environment.
63
+ * @param {string[]} args The caller's SOPS argument array; no private identity is added to it.
64
+ * @param {'encrypt'|'decrypt'} operation The public file action, which owns identity requirements and capture defaults.
65
+ * @param {object} options Original identity, selected invocation ports, environment, cwd, timeout and maxBuffer.
66
+ * @returns {{status: number|null, stdout: string|null, stderr: string|null, error: Error|null}} The unchanged child result or typed prelaunch refusal.
67
+ */
68
+ export function runSopsFile(bin, args, operation, options = {}) {
69
+ const { identity = null, invocation = null, env = process.env, cwd = undefined } = options;
70
+ let maxBuffer, timeout;
71
+ if (operation === 'decrypt') ({ maxBuffer = 16 * 1024 * 1024, timeout = undefined } = options);
72
+ else ({ timeout = undefined, maxBuffer = 1024 * 1024 } = options);
73
+ const selected = sopsIdentityEnv(env, operation === 'decrypt' ? { identity, required: true } : { identity });
74
+ if (selected.error?.identityRefusal === 'inline-context-unqualified') return runSelectedSops(bin, { operation, args }, { selection: selected, invocation, env, cwd, maxBuffer, timeout });
75
+ if (selected.error) return { status: null, stdout: '', stderr: '', error: selected.error };
76
+ const exe = bin ?? resolveSops(selected.env);
77
+ if (!exe) return { status: null, stdout: '', stderr: '', error: Object.assign(new Error('sops is not installed (Windows: winget install Mozilla.SOPS)'), { code: 'SOPS_MISSING' }) };
78
+ const r = spawnSync(exe, args, {
79
+ encoding: 'utf8', windowsHide: true, maxBuffer, timeout, env: selected.env,
80
+ ...(operation === 'decrypt' ? { cwd } : { stdio: ['ignore', 'pipe', 'pipe'] }),
81
+ });
82
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
83
+ }
84
+
85
+ const SELECTED_SOPS_VERSIONS = new Set(['3.13.2', '3.13.3']);
86
+ const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
87
+ const IDENTITY_TIMEOUT_MS = 20_000;
88
+ const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
89
+
90
+ /** Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths. */
91
+ export function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
92
+ const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
93
+ out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
94
+ if (inlineName !== null) out.SOPS_AGE_KEY = env[inlineName];
95
+ return out;
96
+ }
97
+
98
+ const held = reason => ({ status: null, stdout: '', stderr: '', error: Object.assign(new Error(`SOPS selected identity [${reason}]: the selected invocation was not qualified`), { name: 'SopsIdentityRefusal', identityRefusal: reason }) });
99
+ const nativeFile = file => {
100
+ if (typeof file !== 'string' || !path.isAbsolute(file) || /\.(?:[cm]?js|cmd|bat|ps1|sh)$/i.test(file)) return false;
101
+ let fd;
102
+ try {
103
+ const before = fs.lstatSync(file);
104
+ if (!before.isFile() || before.isSymbolicLink()) return false;
105
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
106
+ const stat = fs.fstatSync(fd), head = Buffer.alloc(64);
107
+ if (!stat.isFile() || before.dev !== stat.dev || before.ino !== stat.ino || fs.readSync(fd, head, 0, head.length, 0) < 20) return false;
108
+ if (process.platform === 'win32' && head.toString('ascii', 0, 2) === 'MZ') {
109
+ const offset = head.readUInt32LE(60), pe = Buffer.alloc(6);
110
+ return offset >= 64 && offset + pe.length <= stat.size && fs.readSync(fd, pe, 0, pe.length, offset) === pe.length && pe.toString('ascii', 0, 4) === 'PE\0\0';
111
+ }
112
+ if (process.platform === 'linux') return head.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
113
+ if (process.platform === 'darwin') return new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca]).has(head.readUInt32BE(0));
114
+ return false;
115
+ } catch { return false; }
116
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* no private result or success credit */ } } }
117
+ };
118
+ const auditAbsent = cwd => {
119
+ try { fs.lstatSync(path.resolve(cwd ?? process.cwd(), '/etc/sops/audit.yaml')); return false; }
120
+ catch (error) { return error.code === 'ENOENT'; }
121
+ };
122
+ const captured = r => r && r.status === 0 && r.signal == null && !r.error && Buffer.isBuffer(r.stdout) && Buffer.isBuffer(r.stderr);
123
+ const decoded = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
124
+ const ownedRequest = request => {
125
+ if (request.operation === 'seal') {
126
+ const p = request.params;
127
+ if (!p || !Array.isArray(p.recipients) || p.recipients.length !== 1 || typeof p.filenameOverride !== 'string' || !p.filenameOverride || (typeof p.plaintext !== 'string' && !Buffer.isBuffer(p.plaintext)) || Buffer.byteLength(p.plaintext) > CREDENTIAL_FILE_MAX_BYTES) return null;
128
+ return { inputType: p.inputType, outputType: p.inputType, file: p.filenameOverride, recipient: p.recipients[0], plaintext: p.plaintext };
129
+ }
130
+ const args = request.args;
131
+ if (!Array.isArray(args) || !['decrypt', '--decrypt', 'encrypt', '--encrypt'].includes(args[0]) || args.some(value => typeof value !== 'string')) return null;
132
+ if (!args.at(-1) || args.at(-1).startsWith('-')) return null;
133
+ const flags = {};
134
+ for (let i = 1; i < args.length - 1; i += 2) {
135
+ const flag = args[i];
136
+ if (!['--input-type', '--output-type', '--age'].includes(flag) || Object.hasOwn(flags, flag) || i + 1 >= args.length - 1) return null;
137
+ flags[flag] = args[i + 1];
138
+ }
139
+ if (request.operation === 'decrypt' && (args[0] !== 'decrypt' && args[0] !== '--decrypt' || Object.hasOwn(flags, '--age'))) return null;
140
+ if (request.operation === 'encrypt' && (args[0] !== 'encrypt' && args[0] !== '--encrypt' || !flags['--age'] || flags['--age'].includes(','))) return null;
141
+ return { inputType: flags['--input-type'], outputType: flags['--output-type'], file: args.at(-1), recipient: flags['--age'] };
142
+ };
143
+
144
+ /** The domain composes the existing process owner: invocation={runProgram(file,args,options),resolveRealTool(program,{env})}. No API imports another system. */
145
+ export function runSelectedSops(bin, request, { selection, invocation, env, cwd, maxBuffer, timeout } = {}) {
146
+ if (selection?.error?.identityRefusal !== 'inline-context-unqualified' || typeof selection.inlineName !== 'string' || !env || typeof env !== 'object') return held('selected-context-missing');
147
+ const spec = ownedRequest(request);
148
+ if (!spec || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.inputType) || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.outputType)) return { status: null, stdout: '', stderr: '', error: selection.error };
149
+ if (!invocation || typeof invocation.runProgram !== 'function' || typeof invocation.resolveRealTool !== 'function') return { status: null, stdout: '', stderr: '', error: selection.error };
150
+ if (!['win32', 'linux', 'darwin'].includes(process.platform)) return held('unsupported-platform');
151
+ const key = env[selection.inlineName];
152
+ if (typeof key !== 'string' || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(key.trim())) return { status: null, stdout: '', stderr: '', error: selection.error };
153
+ let input, identity, selectedEnv;
154
+ const captures = [];
155
+ try {
156
+ const file = path.resolve(cwd ?? process.cwd(), spec.file);
157
+ try { input = request.operation === 'seal' ? Buffer.from(spec.plaintext) : readSecretBytes(file); } catch { return held('actual-document-unavailable'); }
158
+ if (input.length > CREDENTIAL_FILE_MAX_BYTES) return held('document-over-budget');
159
+ const exe = bin ?? resolveSops(env), age = invocation.resolveRealTool('age-keygen', { env });
160
+ if (!nativeFile(exe) || !nativeFile(age)) return held('native-tool-unavailable');
161
+ if (!auditAbsent(cwd)) return held('unsupported-audit-context');
162
+ const deadline = timeout ?? IDENTITY_TIMEOUT_MS;
163
+ if (!Number.isInteger(deadline) || deadline < 1 || deadline > 2_147_483_647 || !Number.isInteger(maxBuffer) || maxBuffer < 1) return held('invalid-capture-budget');
164
+ selectedEnv = isolatedSopsEnv(env, selection.inlineName);
165
+ const publicEnv = isolatedSopsEnv(env, null);
166
+ const options = { cwd, env: selectedEnv, shell: false, encoding: 'buffer', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: deadline, maxBuffer };
167
+ const run = (program, args, bytes, childEnv = selectedEnv) => {
168
+ const result = invocation.runProgram(program, args, { ...options, env: childEnv, input: bytes });
169
+ captures.push(result); return result;
170
+ };
171
+ const version = run(exe, ['--disable-version-check', '--version'], undefined, publicEnv);
172
+ const ageVersion = run(age, ['--version'], undefined, publicEnv);
173
+ if (!captured(version) || !captured(ageVersion) || !SELECTED_SOPS_VERSIONS.has(/^sops\s+([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(version.stdout))?.[1]) || !SELECTED_AGE_VERSIONS.has(/^v?([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(ageVersion.stdout))?.[1])) return held('unsupported-tool-profile');
174
+ identity = Buffer.from(key.trim(), 'utf8');
175
+ const derived = run(age, ['-y'], identity, publicEnv);
176
+ if (!captured(derived)) return held('recipient-unproven');
177
+ const recipients = decoded(derived.stdout).trim().split(/\r?\n/);
178
+ if (recipients.length !== 1 || !/^age1[ac-hj-np-z02-9]+$/.test(recipients[0])) return held('recipient-unproven');
179
+ const recipient = recipients[0];
180
+ const decryptArgs = (inputType, outputType) => ['decrypt', '--input-type', inputType, '--output-type', outputType, '--filename-override', file, '--enable-local-keyservice=true', '--decryption-order', 'age'];
181
+ if (request.operation === 'decrypt') {
182
+ const envelope = selectedAgeEnvelope(decoded(input), spec.inputType, recipient);
183
+ if (!envelope.ok) return held(envelope.reason);
184
+ const result = run(exe, decryptArgs(spec.inputType, spec.outputType), input);
185
+ if (!captured(result)) return held('selected-decryption-failed');
186
+ return { status: 0, stdout: decoded(result.stdout), stderr: '', error: null };
187
+ }
188
+ if (spec.recipient !== recipient) return held('recipient-mismatch');
189
+ const args = ['encrypt', '--input-type', spec.inputType, '--output-type', spec.outputType, '--filename-override', file, '--enable-local-keyservice=true', '--age', recipient];
190
+ const result = run(exe, args, input);
191
+ if (!captured(result)) return held('selected-encryption-failed');
192
+ if (result.stdout.length > CREDENTIAL_FILE_MAX_BYTES) return held('document-over-budget');
193
+ const text = decoded(result.stdout);
194
+ const envelope = selectedAgeEnvelope(text, spec.outputType, recipient);
195
+ if (!envelope.ok) return held(envelope.reason);
196
+ const readback = run(exe, decryptArgs(spec.outputType, spec.inputType), result.stdout);
197
+ if (!captured(readback)) return held('selected-readback-failed');
198
+ return { status: 0, stdout: text, stderr: '', error: null };
199
+ } catch { return held('selected-native-refused'); }
200
+ finally {
201
+ identity?.fill(0); input?.fill(0);
202
+ for (const result of captures) { if (Buffer.isBuffer(result?.stdout)) result.stdout.fill(0); if (Buffer.isBuffer(result?.stderr)) result.stderr.fill(0); }
203
+ if (selectedEnv) delete selectedEnv.SOPS_AGE_KEY;
204
+ }
205
+ }
206
+
207
+ /** One real private capture for an admitted initial request; the installer owns its durable attempt. */
208
+ export function withGeneratedAgeIdentity({ env, cwd, invocation, assertLease, consume } = {}) {
209
+ const failure = (reason, generated) => ({ ok: false, reason, captureState: generated ? 'unknown' : 'none', effectState: generated ? 'unknown' : 'none' });
210
+ if (!env || typeof env !== 'object' || !path.isAbsolute(cwd ?? '') || typeof consume !== 'function'
211
+ || typeof assertLease !== 'function' || typeof invocation?.runProgram !== 'function'
212
+ || typeof invocation?.resolveRealTool !== 'function') return failure('invalid-request', false);
213
+ const captures = [];
214
+ let generated = false, identity;
215
+ try {
216
+ const age = invocation.resolveRealTool('age-keygen', { env });
217
+ if (!nativeFile(age)) return failure('native-tool-unavailable', false);
218
+ const options = { cwd, env: isolatedSopsEnv(env, null), shell: false, encoding: 'buffer', windowsHide: true,
219
+ stdio: ['pipe', 'pipe', 'pipe'], timeout: IDENTITY_TIMEOUT_MS, maxBuffer: CREDENTIAL_FILE_MAX_BYTES };
220
+ const run = (args, input) => {
221
+ if (assertLease() !== true) throw new Error('initial age lease lost');
222
+ const result = invocation.runProgram(age, args, { ...options, input });
223
+ captures.push(result);
224
+ if (!captured(result) || result.stdout.length > options.maxBuffer || result.stderr.length > options.maxBuffer) throw new Error('initial age capture incomplete');
225
+ return result;
226
+ };
227
+ const version = run(['--version']);
228
+ if (!SELECTED_AGE_VERSIONS.has(/^v?([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(version.stdout))?.[1])) return failure('unsupported-tool-profile', false);
229
+ if (assertLease() !== true) return failure('lease-lost', false);
230
+ generated = true;
231
+ const made = run([]), lines = decoded(made.stdout).trim().split(/\r?\n/);
232
+ const identities = lines.filter(line => line && !line.startsWith('#'));
233
+ if (identities.length !== 1 || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(identities[0])
234
+ || lines.some(line => line.startsWith('#') && !/^# (?:created:|public key:)/.test(line))) return failure('capture-incomplete', true);
235
+ identity = Buffer.from(identities[0], 'utf8');
236
+ const derived = run(['-y'], identity), recipients = decoded(derived.stdout).trim().split(/\r?\n/);
237
+ if (recipients.length !== 1 || !/^age1[ac-hj-np-z02-9]+$/.test(recipients[0])) return failure('recipient-unproven', true);
238
+ const recipient = recipients[0], declared = lines.filter(line => line.startsWith('# public key:'));
239
+ if (declared.length !== 1 || declared[0].slice('# public key:'.length).trim() !== recipient || assertLease() !== true) return failure('recipient-unproven', true);
240
+ const publication = consume(identity, recipient);
241
+ if (!publication || typeof publication.ok !== 'boolean' || !['none', 'unknown', 'complete'].includes(publication.effectState)) return failure('publication-unknown', true);
242
+ if (!publication.ok || publication.effectState !== 'complete') return { ok: false, captureState: 'generated', effectState: publication.effectState,
243
+ reason: 'publication-held' };
244
+ if (![true, false].includes(publication.created) || !['file-fsync', 'file-and-parent-fsync', 'file-fsync-namespace-unqualified'].includes(publication.durability)) return failure('publication-unknown', true);
245
+ return { ok: true, captureState: 'generated', effectState: 'complete', created: publication.created,
246
+ durability: publication.durability, publicRecipient: recipient };
247
+ } catch { return failure('capture-or-publication-unknown', generated); }
248
+ finally {
249
+ identity?.fill(0);
250
+ for (const result of captures) {
251
+ if (Buffer.isBuffer(result?.stdout)) result.stdout.fill(0);
252
+ if (Buffer.isBuffer(result?.stderr)) result.stderr.fill(0);
253
+ }
254
+ }
255
+ }