chati-dev 4.5.28 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readlinkSync, realpathSync, renameSync, unlinkSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
let binding;
|
|
9
|
+
|
|
10
|
+
function invalid(code, message) { throw Object.assign(new TypeError(message), { code }); }
|
|
11
|
+
|
|
12
|
+
function descriptor(value) {
|
|
13
|
+
if (!Number.isInteger(value) || value < 0 || value > 0x7fffffff) {
|
|
14
|
+
invalid('INVALID_STORAGE_ARGUMENT', 'storage descriptor must be a nonnegative signed integer');
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function component(value) {
|
|
20
|
+
if (typeof value !== 'string' || !value || value === '.' || value === '..'
|
|
21
|
+
|| /[/\\\0]/.test(value) || Buffer.byteLength(value) > 255) {
|
|
22
|
+
invalid('INVALID_STORAGE_NAME', 'storage name must be one bounded path component');
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function linuxReference(fd, name) { return join(`/proc/self/fd/${descriptor(fd)}`, component(name)); }
|
|
28
|
+
|
|
29
|
+
function native() {
|
|
30
|
+
assertDescriptorStorageSupported();
|
|
31
|
+
if (!binding) {
|
|
32
|
+
try { binding = require(`../native/prebuilds/${process.platform}-${process.arch}/descriptor-fs.node`); }
|
|
33
|
+
catch (cause) {
|
|
34
|
+
throw Object.assign(new Error('The native descriptor-storage binary is missing or incompatible; reinstall a package built for this platform'), {
|
|
35
|
+
code: 'NATIVE_STORAGE_UNAVAILABLE', cause,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return binding;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertDescriptorStorageSupported() {
|
|
43
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
44
|
+
throw Object.assign(new Error(`descriptor-relative storage is unsupported on ${process.platform}`), {
|
|
45
|
+
code: 'UNSUPPORTED_DESCRIPTOR_STORAGE_PLATFORM',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function openAtSync(fd, name, flags, mode = 0o600) {
|
|
51
|
+
descriptor(fd); component(name);
|
|
52
|
+
descriptor(flags);
|
|
53
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) invalid('INVALID_STORAGE_ARGUMENT', 'invalid file mode');
|
|
54
|
+
if (process.platform === 'linux') return openSync(linuxReference(fd, name), flags | constants.O_NOFOLLOW, mode);
|
|
55
|
+
return native().openAt(fd, name, flags, mode);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function tryExclusiveFileLockSync(fd) {
|
|
59
|
+
descriptor(fd);
|
|
60
|
+
const api = native();
|
|
61
|
+
if (typeof api.tryExclusiveFileLock !== 'function') {
|
|
62
|
+
throw Object.assign(new Error('native storage binary lacks descriptor locking; reinstall the matching package build'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
63
|
+
}
|
|
64
|
+
return api.tryExclusiveFileLock(fd);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function trySharedFileLockSync(fd) {
|
|
68
|
+
descriptor(fd);
|
|
69
|
+
const api = native();
|
|
70
|
+
if (typeof api.trySharedFileLock !== 'function') {
|
|
71
|
+
throw Object.assign(new Error('native storage binary lacks shared descriptor locking; reinstall the matching package build'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
72
|
+
}
|
|
73
|
+
return api.trySharedFileLock(fd);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createProcessInfoPipeSync() {
|
|
77
|
+
const api = native();
|
|
78
|
+
if (typeof api.processPipe !== 'function') {
|
|
79
|
+
throw Object.assign(new Error('matching native storage lacks private process pipes'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
80
|
+
}
|
|
81
|
+
return api.processPipe();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function darwinBrowserListenerApi() {
|
|
85
|
+
if (process.platform !== 'darwin') invalid('DARWIN_BROWSER_LISTENER_UNAVAILABLE', 'prebound Darwin listener requires macOS');
|
|
86
|
+
const api = native();
|
|
87
|
+
if (typeof api.createDarwinBrowserListener !== 'function' || typeof api.inspectDarwinBrowserListener !== 'function') {
|
|
88
|
+
invalid('NATIVE_STORAGE_UNAVAILABLE', 'matching native binary lacks prebound browser listeners');
|
|
89
|
+
}
|
|
90
|
+
return api;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function browserListenerPort(port) {
|
|
94
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) invalid('INVALID_STORAGE_ARGUMENT', 'browser listener port must be an unprivileged TCP port');
|
|
95
|
+
return port;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createDarwinBrowserListenerSync(port) {
|
|
99
|
+
browserListenerPort(port);
|
|
100
|
+
return darwinBrowserListenerApi().createDarwinBrowserListener(port);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function inspectDarwinBrowserListenerSync(fd, port) {
|
|
104
|
+
descriptor(fd); browserListenerPort(port);
|
|
105
|
+
return darwinBrowserListenerApi().inspectDarwinBrowserListener(fd, port);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One bounded message per channel, authenticated by exclusive descriptor
|
|
109
|
+
// inheritance at the caller. Partial data is not a message: EOF is required.
|
|
110
|
+
export function readProcessInfoPipeSync(fd, timeoutMs = 5000) {
|
|
111
|
+
descriptor(fd);
|
|
112
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30000) {
|
|
113
|
+
invalid('INVALID_STORAGE_ARGUMENT', 'process pipe deadline must be an integer between 0 and 30000 milliseconds');
|
|
114
|
+
}
|
|
115
|
+
const api = native();
|
|
116
|
+
if (typeof api.readProcessPipe !== 'function') {
|
|
117
|
+
throw Object.assign(new Error('matching native storage lacks private process pipe reading'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
118
|
+
}
|
|
119
|
+
return api.readProcessPipe(fd, timeoutMs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function duplicateDirectorySync(fd) {
|
|
123
|
+
descriptor(fd);
|
|
124
|
+
if (process.platform === 'linux') return openSync(`/proc/self/fd/${fd}`, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
125
|
+
return native().duplicateDirectory(fd);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function directoryPathSync(fd) {
|
|
129
|
+
descriptor(fd);
|
|
130
|
+
if (process.platform === 'linux') return realpathSync(`/proc/self/fd/${fd}`);
|
|
131
|
+
return native().directoryPath(fd);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function readDirectorySync(fd, { encoding = 'utf8' } = {}) {
|
|
135
|
+
descriptor(fd);
|
|
136
|
+
if (!['utf8', 'buffer'].includes(encoding)) invalid('INVALID_STORAGE_ARGUMENT', 'unsupported directory name encoding');
|
|
137
|
+
if (process.platform === 'linux') return readdirSync(`/proc/self/fd/${fd}`, { encoding });
|
|
138
|
+
const api = native();
|
|
139
|
+
if (encoding === 'utf8') return api.readDirectory(fd);
|
|
140
|
+
if (typeof api.readDirectoryBytes !== 'function') {
|
|
141
|
+
throw Object.assign(new Error('matching native storage lacks lossless directory enumeration'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
142
|
+
}
|
|
143
|
+
return api.readDirectoryBytes(fd);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readlinkAtSync(fd, name) {
|
|
147
|
+
descriptor(fd); component(name);
|
|
148
|
+
if (process.platform === 'linux') return readlinkSync(linuxReference(fd, name), { encoding: 'buffer' });
|
|
149
|
+
const api = native();
|
|
150
|
+
if (typeof api.readlinkAt !== 'function') {
|
|
151
|
+
throw Object.assign(new Error('matching native storage lacks descriptor-relative symlink capture'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
152
|
+
}
|
|
153
|
+
return api.readlinkAt(fd, name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function mkdirAtSync(fd, name, { mode = 0o700 } = {}) {
|
|
157
|
+
descriptor(fd); component(name);
|
|
158
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) invalid('INVALID_STORAGE_ARGUMENT', 'invalid directory mode');
|
|
159
|
+
if (process.platform === 'linux') return mkdirSync(linuxReference(fd, name), { mode });
|
|
160
|
+
return native().mkdirAt(fd, name, mode);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function renameAtSync(fromFd, from, toFd, to) {
|
|
164
|
+
descriptor(fromFd); component(from); descriptor(toFd); component(to);
|
|
165
|
+
if (process.platform === 'linux') return renameSync(linuxReference(fromFd, from), linuxReference(toFd, to));
|
|
166
|
+
return native().renameAt(fromFd, from, toFd, to);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function renameNoReplaceAtSync(fromFd, from, toFd, to) {
|
|
170
|
+
descriptor(fromFd); component(from); descriptor(toFd); component(to);
|
|
171
|
+
const api = native();
|
|
172
|
+
if (typeof api.renameNoReplaceAt !== 'function') {
|
|
173
|
+
throw Object.assign(new Error('matching native storage lacks atomic no-replace rename'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
174
|
+
}
|
|
175
|
+
return api.renameNoReplaceAt(fromFd, from, toFd, to);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function symlinkAtSync(target, fd, name) {
|
|
179
|
+
descriptor(fd); component(name);
|
|
180
|
+
if (!Buffer.isBuffer(target) || !target.length || target.includes(0)) invalid('INVALID_STORAGE_ARGUMENT', 'invalid raw symlink target');
|
|
181
|
+
const api = native();
|
|
182
|
+
if (typeof api.symlinkAt !== 'function') {
|
|
183
|
+
throw Object.assign(new Error('matching native storage lacks descriptor-relative symlink creation'), { code: 'NATIVE_STORAGE_UNAVAILABLE' });
|
|
184
|
+
}
|
|
185
|
+
return api.symlinkAt(target, fd, name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function linkAtSync(fromFd, from, toFd, to) {
|
|
189
|
+
descriptor(fromFd); component(from); descriptor(toFd); component(to);
|
|
190
|
+
if (process.platform === 'linux') return linkSync(linuxReference(fromFd, from), linuxReference(toFd, to));
|
|
191
|
+
return native().linkAt(fromFd, from, toFd, to);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function unlinkAtSync(fd, name, { force = false } = {}) {
|
|
195
|
+
descriptor(fd); component(name);
|
|
196
|
+
try {
|
|
197
|
+
if (process.platform === 'linux') return unlinkSync(linuxReference(fd, name));
|
|
198
|
+
return native().unlinkAt(fd, name);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (force && error.code === 'ENOENT') return undefined;
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function lstatAtSync(fd, name, { bigint = false } = {}) {
|
|
206
|
+
descriptor(fd); component(name);
|
|
207
|
+
if (process.platform === 'linux') return lstatSync(linuxReference(fd, name), { bigint });
|
|
208
|
+
const raw = native().statAt(fd, name);
|
|
209
|
+
const stat = Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, bigint ? value : Number(value)]));
|
|
210
|
+
for (const time of ['atime', 'mtime', 'ctime']) {
|
|
211
|
+
stat[`${time}Ms`] = bigint ? raw[`${time}Ns`] / 1_000_000n
|
|
212
|
+
: Number(raw[`${time}Ns`] / 1_000_000n) + Number(raw[`${time}Ns`] % 1_000_000n) / 1e6;
|
|
213
|
+
stat[time] = new Date(Number(raw[`${time}Ns`] / 1_000_000n));
|
|
214
|
+
}
|
|
215
|
+
const type = Number(raw.mode) & constants.S_IFMT;
|
|
216
|
+
for (const [method, kind] of Object.entries({
|
|
217
|
+
isFile: constants.S_IFREG, isDirectory: constants.S_IFDIR, isSymbolicLink: constants.S_IFLNK,
|
|
218
|
+
isFIFO: constants.S_IFIFO, isSocket: constants.S_IFSOCK,
|
|
219
|
+
isBlockDevice: constants.S_IFBLK, isCharacterDevice: constants.S_IFCHR,
|
|
220
|
+
})) stat[method] = () => type === kind;
|
|
221
|
+
return Object.freeze(stat);
|
|
222
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { assertDescriptorStorageSupported } from './descriptor-fs.js';
|
|
3
|
+
|
|
4
|
+
/** A packaged native boundary, never downloaded or compiled at user runtime. */
|
|
5
|
+
export function nativeExecutionGatePath() {
|
|
6
|
+
assertDescriptorStorageSupported();
|
|
7
|
+
return fileURLToPath(new URL(`../native/prebuilds/${process.platform}-${process.arch}/execution-gate`, import.meta.url));
|
|
8
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { captureDarwinHostBootIdentity } from './darwin-host-boot.js';
|
|
4
|
+
|
|
5
|
+
const LINUX_BOOT_ID = '/proc/sys/kernel/random/boot_id';
|
|
6
|
+
const MARKER_KEYS = ['schema_version', 'platform', 'boot_sha256'];
|
|
7
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
|
|
9
|
+
/** Cheap, unprivileged marker of the current kernel boot on this host, for
|
|
10
|
+
* lock-owner liveness only. A recycled PID after a reboot must not keep a lock
|
|
11
|
+
* busy forever. Returns null when the platform cannot supply one; callers then
|
|
12
|
+
* fall back to PID liveness alone. This is not an authority identity. */
|
|
13
|
+
export function captureHostBootMarker() {
|
|
14
|
+
try {
|
|
15
|
+
if (process.platform === 'darwin') {
|
|
16
|
+
const identity = captureDarwinHostBootIdentity();
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
schema_version: 1, platform: 'darwin',
|
|
19
|
+
boot_sha256: sha256(`chati-darwin-boot-v1\0${identity.host_id_sha256}\0${identity.boot_session_uuid}`),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (process.platform === 'linux') {
|
|
23
|
+
const bootId = readFileSync(LINUX_BOOT_ID, 'utf8').trim();
|
|
24
|
+
if (!/^[a-f0-9-]{36}$/.test(bootId)) return null;
|
|
25
|
+
return Object.freeze({ schema_version: 1, platform: 'linux', boot_sha256: sha256(`chati-linux-boot-v1\0${bootId}`) });
|
|
26
|
+
}
|
|
27
|
+
} catch { /* unavailable on this host */ }
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isHostBootMarker(value) {
|
|
32
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
&& Object.keys(value).every((key) => MARKER_KEYS.includes(key))
|
|
34
|
+
&& value.schema_version === 1
|
|
35
|
+
&& (value.platform === 'darwin' || value.platform === 'linux')
|
|
36
|
+
&& typeof value.boot_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.boot_sha256);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True only when both markers are well formed, describe the same platform,
|
|
40
|
+
* and name different boots. Malformed or missing markers never prove a change. */
|
|
41
|
+
export function hostBootMarkerChanged(previous, current) {
|
|
42
|
+
if (!isHostBootMarker(previous) || !isHostBootMarker(current)) return false;
|
|
43
|
+
if (previous.platform !== current.platform) return false;
|
|
44
|
+
return previous.boot_sha256 !== current.boot_sha256;
|
|
45
|
+
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
export { nativeExecutionGatePath } from './execution-gate.js';
|
|
4
|
+
export { assertDarwinHostBootIdentity, captureDarwinHostBootIdentity, compareDarwinHostBootIdentity, observeDarwinPreviousBoot } from './darwin-host-boot.js';
|
|
5
|
+
export { assertLinuxHostBootIdentity, captureLinuxHostBootIdentity, compareLinuxHostBootIdentity } from './linux-host-boot.js';
|
|
6
|
+
export { captureHostBootMarker, hostBootMarkerChanged, isHostBootMarker } from './host-boot-marker.js';
|
|
7
|
+
export { darwinSupervisorNonce, parseDarwinReadyMessage, parseDarwinCompletionMessage } from './darwin-process.js';
|
|
8
|
+
export { createDarwinSupervisorChannels } from './darwin-supervisor-channels.js';
|
|
9
|
+
export { createDarwinBrowserProofChannels } from './darwin-browser-proof.js';
|
|
10
|
+
export { PROVIDER_CONTROL_PLANE_DIRECTORIES, createDarwinTerminalLaunchBinding, assertDarwinTerminalLaunch, assertDarwinTerminalCompletion } from './darwin-terminal-receipt.js';
|
|
11
|
+
export { createLinuxProcessInfoPipe, readLinuxProcessInfoPipe, captureLinuxNamespaceIdentity,
|
|
12
|
+
assertLinuxNamespaceIdentity, observeLinuxNamespace } from './linux-process.js';
|
|
13
|
+
export {
|
|
14
|
+
assertDescriptorStorageSupported, directoryPathSync, duplicateDirectorySync,
|
|
15
|
+
createProcessInfoPipeSync, readProcessInfoPipeSync,
|
|
16
|
+
createDarwinBrowserListenerSync, inspectDarwinBrowserListenerSync,
|
|
17
|
+
linkAtSync, lstatAtSync, mkdirAtSync, openAtSync, readDirectorySync, readlinkAtSync, renameAtSync, renameNoReplaceAtSync, symlinkAtSync, unlinkAtSync,
|
|
18
|
+
tryExclusiveFileLockSync, trySharedFileLockSync,
|
|
19
|
+
} from './descriptor-fs.js';
|
|
20
|
+
|
|
3
21
|
export class ContractError extends Error {
|
|
4
22
|
constructor(code, message, details = {}) {
|
|
5
23
|
super(message);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync,
|
|
5
|
+
readSync, realpathSync, statfsSync,
|
|
6
|
+
} from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const SUDO = '/usr/bin/sudo';
|
|
9
|
+
const CAT = '/usr/bin/cat';
|
|
10
|
+
const HOST = '/sys/devices/virtual/dmi/id/product_uuid';
|
|
11
|
+
const BOOT = '/proc/sys/kernel/random/boot_id';
|
|
12
|
+
const METHOD = 'sysfs-dmi-via-fixed-sudo-cat-v1';
|
|
13
|
+
const SYSFS = 0x62656572n;
|
|
14
|
+
const PROCFS = 0x9fa0n;
|
|
15
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
17
|
+
const META = ['dev', 'ino', 'mode', 'uid', 'gid'];
|
|
18
|
+
const FILE_META = [...META, 'nlink', 'size', 'mtimeNs', 'ctimeNs'];
|
|
19
|
+
const DIRECTORIES = ['/', '/usr', '/usr/bin', '/sys', '/sys/devices', '/sys/devices/virtual',
|
|
20
|
+
'/sys/devices/virtual/dmi', '/sys/devices/virtual/dmi/id', '/proc', '/proc/sys',
|
|
21
|
+
'/proc/sys/kernel', '/proc/sys/kernel/random'];
|
|
22
|
+
const IDENTITY_KEYS = ['schema_version', 'platform', 'method', 'host_id_sha256', 'boot_id_sha256', 'provenance'];
|
|
23
|
+
const PROVENANCE_KEYS = ['sudo_sha256', 'cat_sha256', 'pid_namespace_inode', 'mount_namespace_inode', 'user_namespace_inode'];
|
|
24
|
+
|
|
25
|
+
function fail(code, message, stage) {
|
|
26
|
+
throw Object.assign(new Error(message), { code, ...(stage ? { stage } : {}) });
|
|
27
|
+
}
|
|
28
|
+
function check(condition) { if (!condition) throw new Error('Linux host observation check failed'); }
|
|
29
|
+
function same(a, b, fields) { return fields.every(key => a[key] === b[key]); }
|
|
30
|
+
function filesystem(path) {
|
|
31
|
+
if (path === '/sys' || path.startsWith('/sys/')) return SYSFS;
|
|
32
|
+
if (path === '/proc' || path.startsWith('/proc/')) return PROCFS;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function metadata(path, directory = false) {
|
|
36
|
+
check(realpathSync(path) === path);
|
|
37
|
+
const stat = lstatSync(path, { bigint: true });
|
|
38
|
+
check(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile()));
|
|
39
|
+
check(stat.uid === 0n && stat.gid === 0n && (stat.mode & 0o022n) === 0n);
|
|
40
|
+
if (!directory) check(stat.nlink === 1n);
|
|
41
|
+
const type = filesystem(path);
|
|
42
|
+
if (type !== null) check(statfsSync(path, { bigint: true }).type === type);
|
|
43
|
+
return stat;
|
|
44
|
+
}
|
|
45
|
+
function pin(path, pins, { directory = false, readable = true } = {}) {
|
|
46
|
+
const stat = metadata(path, directory);
|
|
47
|
+
const record = { path, stat, directory, fd: null };
|
|
48
|
+
pins.push(record);
|
|
49
|
+
if (readable) {
|
|
50
|
+
record.fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
|
|
51
|
+
| (directory ? constants.O_DIRECTORY : 0));
|
|
52
|
+
check(same(stat, fstatSync(record.fd, { bigint: true }), directory ? META : FILE_META));
|
|
53
|
+
const type = filesystem(path);
|
|
54
|
+
if (type !== null) check(statfsSync(`/proc/self/fd/${record.fd}`, { bigint: true }).type === type);
|
|
55
|
+
}
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
function assertPinned(record) {
|
|
59
|
+
const fields = record.directory ? META : FILE_META;
|
|
60
|
+
check(same(record.stat, metadata(record.path, record.directory), fields));
|
|
61
|
+
if (record.fd !== null) {
|
|
62
|
+
check(same(record.stat, fstatSync(record.fd, { bigint: true }), fields));
|
|
63
|
+
const type = filesystem(record.path);
|
|
64
|
+
if (type !== null) check(statfsSync(`/proc/self/fd/${record.fd}`, { bigint: true }).type === type);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function nativeImageHash(record) {
|
|
68
|
+
const { fd, stat, path } = record;
|
|
69
|
+
check(stat.size >= 20n && stat.size <= 16n * 1024n * 1024n);
|
|
70
|
+
check((stat.mode & 0o111n) !== 0n);
|
|
71
|
+
// Existing OS sudo may be setuid. Never copy it, run a package helper as
|
|
72
|
+
// root, or accept a setgid/sticky utility or a privileged cat wrapper.
|
|
73
|
+
check((stat.mode & (path === SUDO ? 0o3000n : 0o7000n)) === 0n);
|
|
74
|
+
const hash = createHash('sha256');
|
|
75
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
76
|
+
const header = Buffer.alloc(20);
|
|
77
|
+
let position = 0;
|
|
78
|
+
while (position < Number(stat.size)) {
|
|
79
|
+
const count = readSync(fd, buffer, 0, Math.min(buffer.length, Number(stat.size) - position), position);
|
|
80
|
+
check(count > 0);
|
|
81
|
+
if (position < header.length) buffer.copy(header, position, 0, Math.min(count, header.length - position));
|
|
82
|
+
hash.update(buffer.subarray(0, count));
|
|
83
|
+
position += count;
|
|
84
|
+
}
|
|
85
|
+
check(header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
|
86
|
+
&& header[4] === 2 && header[5] === 1 && header[6] === 1
|
|
87
|
+
&& [2, 3].includes(header.readUInt16LE(16))
|
|
88
|
+
&& header.readUInt16LE(18) === (process.arch === 'x64' ? 62 : 183));
|
|
89
|
+
assertPinned(record);
|
|
90
|
+
return hash.digest('hex');
|
|
91
|
+
}
|
|
92
|
+
function normalizedUuid(bytes) {
|
|
93
|
+
check(Buffer.isBuffer(bytes) && (bytes.length === 36 || (bytes.length === 37 && bytes[36] === 10)));
|
|
94
|
+
check(bytes.every(byte => byte < 128));
|
|
95
|
+
const value = bytes.subarray(0, 36).toString('utf8').toLowerCase();
|
|
96
|
+
check(UUID.test(value) && value !== '00000000-0000-0000-0000-000000000000'
|
|
97
|
+
&& value !== 'ffffffff-ffff-ffff-ffff-ffffffffffff');
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function bootHash(record) {
|
|
101
|
+
const bytes = Buffer.alloc(129);
|
|
102
|
+
try {
|
|
103
|
+
let length = 0;
|
|
104
|
+
while (length < bytes.length) {
|
|
105
|
+
const count = readSync(record.fd, bytes, length, bytes.length - length, length);
|
|
106
|
+
if (!count) break;
|
|
107
|
+
length += count;
|
|
108
|
+
}
|
|
109
|
+
const uuid = normalizedUuid(bytes.subarray(0, length));
|
|
110
|
+
assertPinned(record);
|
|
111
|
+
return createHash('sha256').update(`chati-linux-boot-v1\0${uuid}`).digest('hex');
|
|
112
|
+
} finally { bytes.fill(0); }
|
|
113
|
+
}
|
|
114
|
+
function namespaceContext() {
|
|
115
|
+
return Object.fromEntries([['pid', 'pid'], ['mnt', 'mount'], ['user', 'user']].map(([tag, name]) => {
|
|
116
|
+
const value = readlinkSync(`/proc/self/ns/${tag}`);
|
|
117
|
+
const match = new RegExp(`^${tag}:\\[([1-9][0-9]{0,19})\\]$`).exec(value);
|
|
118
|
+
check(match && BigInt(match[1]) <= 0xffffffffffffffffn);
|
|
119
|
+
return [`${name}_namespace_inode`, match[1]];
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
function closedValues(value, keys) {
|
|
123
|
+
check(value && typeof value === 'object' && !Array.isArray(value)
|
|
124
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value)));
|
|
125
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
126
|
+
check(Reflect.ownKeys(descriptors).length === keys.length
|
|
127
|
+
&& keys.every(key => Object.hasOwn(descriptors, key) && Object.hasOwn(descriptors[key], 'value')));
|
|
128
|
+
return Object.fromEntries(keys.map(key => [key, descriptors[key].value]));
|
|
129
|
+
}
|
|
130
|
+
function identityValues(value) {
|
|
131
|
+
try {
|
|
132
|
+
const identity = closedValues(value, IDENTITY_KEYS);
|
|
133
|
+
const provenance = closedValues(identity.provenance, PROVENANCE_KEYS);
|
|
134
|
+
check(identity.schema_version === 1 && identity.platform === 'linux' && identity.method === METHOD);
|
|
135
|
+
for (const hash of [identity.host_id_sha256, identity.boot_id_sha256, provenance.sudo_sha256, provenance.cat_sha256]) {
|
|
136
|
+
check(typeof hash === 'string' && SHA256.test(hash));
|
|
137
|
+
}
|
|
138
|
+
for (const key of PROVENANCE_KEYS.slice(2)) {
|
|
139
|
+
check(typeof provenance[key] === 'string' && /^[1-9][0-9]{0,19}$/.test(provenance[key])
|
|
140
|
+
&& BigInt(provenance[key]) <= 0xffffffffffffffffn);
|
|
141
|
+
}
|
|
142
|
+
return identity;
|
|
143
|
+
} catch {
|
|
144
|
+
return fail('INVALID_LINUX_HOST_BOOT_IDENTITY', 'A closed Linux host/boot observation is required');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Shape validation only. Neither hashes nor namespace inodes authenticate a
|
|
149
|
+
* host, rule out VM cloning, prove initial namespaces, or authorize recovery. */
|
|
150
|
+
export function assertLinuxHostBootIdentity(identity) {
|
|
151
|
+
identityValues(identity);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fixed existing OS privilege, not a privileged package helper. Never invoke
|
|
156
|
+
* from provider code or accept caller-supplied paths, observations or options.
|
|
157
|
+
* Success is identity metadata only, not a completion/quiescence receipt.
|
|
158
|
+
* A future barrier must separately authenticate and fence its execution domain. */
|
|
159
|
+
export function captureLinuxHostBootIdentity() {
|
|
160
|
+
const pins = [];
|
|
161
|
+
let stage = 'preflight';
|
|
162
|
+
let output;
|
|
163
|
+
let identity;
|
|
164
|
+
try {
|
|
165
|
+
check(arguments.length === 0 && process.platform === 'linux' && ['x64', 'arm64'].includes(process.arch)
|
|
166
|
+
&& typeof process.geteuid === 'function' && process.geteuid() !== 0);
|
|
167
|
+
for (const path of DIRECTORIES) pin(path, pins, { directory: true });
|
|
168
|
+
const sudo = pin(SUDO, pins), cat = pin(CAT, pins);
|
|
169
|
+
const sudoHash = nativeImageHash(sudo), catHash = nativeImageHash(cat);
|
|
170
|
+
const host = pin(HOST, pins, { readable: false });
|
|
171
|
+
const boot = pin(BOOT, pins);
|
|
172
|
+
check((host.stat.mode & 0o222n) === 0n && (boot.stat.mode & 0o222n) === 0n);
|
|
173
|
+
const context = namespaceContext();
|
|
174
|
+
const before = bootHash(boot);
|
|
175
|
+
stage = 'fixed-read';
|
|
176
|
+
const result = spawnSync(SUDO, ['-n', '--', CAT, HOST], {
|
|
177
|
+
shell: false, cwd: '/', env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
|
|
178
|
+
stdio: ['ignore', 'pipe', 'ignore'], encoding: null,
|
|
179
|
+
timeout: 2000, killSignal: 'SIGKILL', maxBuffer: 128,
|
|
180
|
+
});
|
|
181
|
+
output = result?.stdout;
|
|
182
|
+
check(result && result.status === 0 && result.signal === null && !result.error);
|
|
183
|
+
const hostUuid = normalizedUuid(output);
|
|
184
|
+
const hostHash = createHash('sha256').update(`chati-linux-host-v1\0${hostUuid}`).digest('hex');
|
|
185
|
+
stage = 'recheck';
|
|
186
|
+
check(before === bootHash(boot));
|
|
187
|
+
const after = namespaceContext();
|
|
188
|
+
check(Object.keys(context).every(key => context[key] === after[key]));
|
|
189
|
+
for (const record of pins) assertPinned(record);
|
|
190
|
+
check(nativeImageHash(sudo) === sudoHash && nativeImageHash(cat) === catHash);
|
|
191
|
+
identity = Object.freeze({ schema_version: 1, platform: 'linux', method: METHOD,
|
|
192
|
+
host_id_sha256: hostHash, boot_id_sha256: before,
|
|
193
|
+
provenance: Object.freeze({ sudo_sha256: sudoHash, cat_sha256: catHash, ...context }) });
|
|
194
|
+
assertLinuxHostBootIdentity(identity);
|
|
195
|
+
} catch {
|
|
196
|
+
// Child-process errors can retain stdout/stderr. Never expose the caught
|
|
197
|
+
// error, cause, result or raw UUID in a diagnostic or authority record.
|
|
198
|
+
identity = null;
|
|
199
|
+
} finally {
|
|
200
|
+
if (Buffer.isBuffer(output)) output.fill(0);
|
|
201
|
+
for (const record of pins.reverse()) if (record.fd !== null) {
|
|
202
|
+
try { closeSync(record.fd); }
|
|
203
|
+
catch { identity = null; stage = 'cleanup'; }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!identity) fail('LINUX_HOST_BOOT_UNAVAILABLE', 'Fixed native Linux host/boot observation is unavailable', stage);
|
|
207
|
+
return identity;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Pure comparison of already observed/authenticated inputs. In particular,
|
|
211
|
+
* this does not compare an invented launch epoch for a historical execution. */
|
|
212
|
+
export function compareLinuxHostBootIdentity(previous, current) {
|
|
213
|
+
const before = identityValues(previous), after = identityValues(current);
|
|
214
|
+
if (before.host_id_sha256 !== after.host_id_sha256) {
|
|
215
|
+
fail('LINUX_HOST_IDENTITY_MISMATCH', 'Host/boot observations belong to different machines');
|
|
216
|
+
}
|
|
217
|
+
return before.boot_id_sha256 === after.boot_id_sha256 ? 'same-boot' : 'previous-boot';
|
|
218
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { closeSync, constants, openSync, readFileSync, readlinkSync, readSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
let binding;
|
|
7
|
+
function fail(code, message, cause) { throw Object.assign(new Error(message), { code, ...(cause ? { cause } : {}) }); }
|
|
8
|
+
function native() {
|
|
9
|
+
if (process.platform !== 'linux') fail('UNSUPPORTED_PROCESS_IDENTITY_PLATFORM', 'PID-namespace identity requires Linux');
|
|
10
|
+
try { binding ??= require(`../native/prebuilds/linux-${process.arch}/descriptor-fs.node`); }
|
|
11
|
+
catch (error) { fail('NATIVE_PROCESS_CONTROL_UNAVAILABLE', 'matching packaged native process control is required', error); }
|
|
12
|
+
if (!['processPipe', 'readProcessPipe', 'openPidfd', 'waitPidfd', 'killPidfd'].every((key) => typeof binding[key] === 'function')) {
|
|
13
|
+
fail('NATIVE_PROCESS_CONTROL_UNAVAILABLE', 'the packaged native boundary lacks PID identity support');
|
|
14
|
+
}
|
|
15
|
+
return binding;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function integer(value, minimum = 0) {
|
|
19
|
+
if (!Number.isInteger(value) || value < minimum || value > 0x7fffffff) fail('INVALID_PROCESS_IDENTITY', 'invalid process descriptor or PID');
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function bootId() {
|
|
23
|
+
const value = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
24
|
+
if (!/^[a-f0-9-]{36}$/.test(value)) fail('PROCESS_IDENTITY_UNVERIFIABLE', 'kernel boot identity is unavailable');
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function namespaceInode(path) {
|
|
28
|
+
const match = /^pid:\[(\d+)\]$/.exec(readlinkSync(path));
|
|
29
|
+
if (!match) fail('PROCESS_IDENTITY_UNVERIFIABLE', 'kernel PID namespace identity is unavailable');
|
|
30
|
+
return match[1];
|
|
31
|
+
}
|
|
32
|
+
function boundedProcText(directory, name) {
|
|
33
|
+
const fd = openSync(`/proc/self/fd/${directory}/${name}`, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
34
|
+
try {
|
|
35
|
+
const buffer = Buffer.alloc(16385);
|
|
36
|
+
const count = readSync(fd, buffer, 0, buffer.length, 0);
|
|
37
|
+
if (count === buffer.length) fail('PROCESS_IDENTITY_UNVERIFIABLE', 'kernel process identity exceeds its bound');
|
|
38
|
+
return buffer.subarray(0, count).toString('utf8');
|
|
39
|
+
} finally { closeSync(fd); }
|
|
40
|
+
}
|
|
41
|
+
function snapshot(pid) {
|
|
42
|
+
const directory = openSync(`/proc/${integer(pid, 2)}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
43
|
+
try {
|
|
44
|
+
const stat = boundedProcText(directory, 'stat');
|
|
45
|
+
const fields = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/);
|
|
46
|
+
if (!stat.startsWith(`${pid} (`) || fields.length < 20 || !/^\d+$/.test(fields[19])) {
|
|
47
|
+
fail('PROCESS_IDENTITY_UNVERIFIABLE', 'kernel process start identity is malformed');
|
|
48
|
+
}
|
|
49
|
+
const status = boundedProcText(directory, 'status');
|
|
50
|
+
const namespacePids = /^NSpid:\s+([\d\t ]+)$/m.exec(status)?.[1].trim().split(/\s+/).map(Number);
|
|
51
|
+
if (!namespacePids?.length || namespacePids[0] !== pid) fail('PROCESS_IDENTITY_UNVERIFIABLE', 'kernel namespace PID is unavailable');
|
|
52
|
+
return { start_time_ticks: fields[19], parent_pid: Number(fields[1]), namespace_pid: namespacePids.at(-1),
|
|
53
|
+
pid_namespace_inode: namespaceInode(`/proc/self/fd/${directory}/ns/pid`) };
|
|
54
|
+
} finally { closeSync(directory); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createLinuxProcessInfoPipe() { return Object.freeze(native().processPipe()); }
|
|
58
|
+
export function readLinuxProcessInfoPipe(fd, timeoutMs = 5000) {
|
|
59
|
+
integer(fd); integer(timeoutMs);
|
|
60
|
+
return native().readProcessPipe(fd, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Must be called on the private information FD of the pinned bubblewrap. */
|
|
64
|
+
export function captureLinuxNamespaceIdentity(info, monitorPid) {
|
|
65
|
+
const api = native();
|
|
66
|
+
integer(monitorPid, 2);
|
|
67
|
+
const pid = integer(info?.['child-pid'], 2);
|
|
68
|
+
if (!Number.isSafeInteger(info['pid-namespace']) || info['pid-namespace'] <= 0) {
|
|
69
|
+
fail('PROCESS_IDENTITY_UNVERIFIABLE', 'trusted isolator did not report a PID namespace');
|
|
70
|
+
}
|
|
71
|
+
const fd = api.openPidfd(pid);
|
|
72
|
+
try {
|
|
73
|
+
if (api.waitPidfd(fd, 0)) fail('PROCESS_NAMESPACE_ALREADY_EXITED', 'namespace exited before dispatch');
|
|
74
|
+
const observed = snapshot(pid);
|
|
75
|
+
if (observed.parent_pid !== monitorPid || observed.namespace_pid !== 1
|
|
76
|
+
|| observed.pid_namespace_inode !== String(info['pid-namespace'])) {
|
|
77
|
+
fail('PROCESS_IDENTITY_UNVERIFIABLE', 'reported namespace does not match its actual kernel identity');
|
|
78
|
+
}
|
|
79
|
+
const observer = namespaceInode('/proc/self/ns/pid');
|
|
80
|
+
if (observer === observed.pid_namespace_inode) fail('PROCESS_IDENTITY_UNVERIFIABLE', 'provider must be in a distinct PID namespace');
|
|
81
|
+
if (api.waitPidfd(fd, 0)) fail('PROCESS_NAMESPACE_ALREADY_EXITED', 'namespace exited while its identity was captured');
|
|
82
|
+
return Object.freeze({ schema_version: 1, platform: 'linux', hostname: hostname(), boot_id: bootId(),
|
|
83
|
+
observer_pid_namespace_inode: observer, pid, namespace_pid: 1,
|
|
84
|
+
start_time_ticks: observed.start_time_ticks, pid_namespace_inode: observed.pid_namespace_inode });
|
|
85
|
+
} finally { closeSync(fd); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function assertLinuxNamespaceIdentity(identity) {
|
|
89
|
+
if (!identity || identity.schema_version !== 1 || identity.platform !== 'linux' || identity.namespace_pid !== 1
|
|
90
|
+
|| typeof identity.hostname !== 'string' || !identity.hostname
|
|
91
|
+
|| !/^[a-f0-9-]{36}$/.test(identity.boot_id ?? '')
|
|
92
|
+
|| !['start_time_ticks', 'pid_namespace_inode', 'observer_pid_namespace_inode'].every((key) =>
|
|
93
|
+
typeof identity[key] === 'string' && /^\d+$/.test(identity[key]))
|
|
94
|
+
|| identity.pid_namespace_inode === identity.observer_pid_namespace_inode) {
|
|
95
|
+
fail('INVALID_PROCESS_IDENTITY', 'an exact namespace-init identity is required');
|
|
96
|
+
}
|
|
97
|
+
integer(identity.pid, 2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Verify persisted identity before any signal. No numeric-PID kill fallback. */
|
|
101
|
+
export function observeLinuxNamespace(identity, { terminate = false, timeoutMs = 0 } = {}) {
|
|
102
|
+
assertLinuxNamespaceIdentity(identity);
|
|
103
|
+
const api = native();
|
|
104
|
+
integer(timeoutMs);
|
|
105
|
+
if (timeoutMs > 30000 || typeof terminate !== 'boolean') fail('INVALID_PROCESS_IDENTITY', 'invalid namespace observation options');
|
|
106
|
+
if (identity.hostname !== hostname()) fail('PROCESS_IDENTITY_REMOTE_HOST', 'namespace is on a different host');
|
|
107
|
+
if (identity.boot_id !== bootId()) return Object.freeze({ status: 'exited', reason: 'previous-boot', signal_sent: false });
|
|
108
|
+
if (identity.observer_pid_namespace_inode !== namespaceInode('/proc/self/ns/pid')) {
|
|
109
|
+
fail('PROCESS_IDENTITY_UNVERIFIABLE', 'observer PID namespace differs from the dispatch observer');
|
|
110
|
+
}
|
|
111
|
+
let fd;
|
|
112
|
+
try { fd = api.openPidfd(identity.pid); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code === 'ESRCH') return Object.freeze({ status: 'exited', reason: 'pid-absent', signal_sent: false });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
if (api.waitPidfd(fd, 0)) return Object.freeze({ status: 'exited', reason: 'pidfd-exited', signal_sent: false });
|
|
119
|
+
let current;
|
|
120
|
+
try { current = snapshot(identity.pid); }
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (api.waitPidfd(fd, 0)) return Object.freeze({ status: 'exited', reason: 'pidfd-exited', signal_sent: false });
|
|
123
|
+
fail('PROCESS_IDENTITY_UNVERIFIABLE', 'could not verify the live namespace identity', error);
|
|
124
|
+
}
|
|
125
|
+
if (current.start_time_ticks !== identity.start_time_ticks || current.pid_namespace_inode !== identity.pid_namespace_inode
|
|
126
|
+
|| current.namespace_pid !== identity.namespace_pid) {
|
|
127
|
+
return Object.freeze({ status: 'exited', reason: 'identity-replaced', signal_sent: false });
|
|
128
|
+
}
|
|
129
|
+
const sent = terminate ? api.killPidfd(fd) : false;
|
|
130
|
+
const exited = api.waitPidfd(fd, timeoutMs);
|
|
131
|
+
return Object.freeze({ status: exited ? 'exited' : 'running', reason: exited ? 'pidfd-exited' : 'same-identity', signal_sent: sent });
|
|
132
|
+
} finally { closeSync(fd); }
|
|
133
|
+
}
|