@gajae-code/natives 0.18.5 → 0.18.7
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/native/diagnostic-artifact.json +12 -0
- package/native/diagnostic-loader.d.ts +143 -0
- package/native/diagnostic-loader.js +815 -0
- package/native/index.d.ts +39 -3
- package/native/index.js +3 -1
- package/package.json +14 -6
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "gjc.diagnostic-artifact",
|
|
3
|
+
"version": "0.18.7",
|
|
4
|
+
"artifacts": {
|
|
5
|
+
"pi_natives.darwin-arm64.node": "3d074ffc8128980c3a8cbe1d62c99cc1f3a6dc1e4c86dfee3a8fd721b00a0717",
|
|
6
|
+
"pi_natives.darwin-x64-baseline.node": "68eda6a629816cf9ddfedb26afb3e105b7fb1455bfde52e47b41adeda17b007a",
|
|
7
|
+
"pi_natives.linux-arm64.node": "260992679d66e4f3de25ed77a0b99a494fe577bf2e2dabf3fd74178782bfebb4",
|
|
8
|
+
"pi_natives.linux-x64-baseline.node": "d1be8531cffa10bc77660e955cb889c5e95e68951bf966f79a878c7b32245743",
|
|
9
|
+
"pi_natives.linux-x64-modern.node": "259f0d4b1e99e6766924c823dfd246d8a5df4382b3167a24da2ecf1f37fd09c2",
|
|
10
|
+
"pi_natives.win32-x64-baseline.node": "9aa8311f1457009fdfc9b3098aedb0fa0c3e770119a6eae9caf58b57d0186357"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Bytes copied out of one read-only broker publication observation. */
|
|
2
|
+
export interface DiagnosticSnapshotRead {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
reason: string | null;
|
|
5
|
+
bytes: Uint8Array | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Outcome of one read-only publication revalidation. */
|
|
9
|
+
export interface DiagnosticSnapshotStatus {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
reason: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Exclusive read-only lease over one already published broker document. It
|
|
16
|
+
* exposes no descriptor, path, token or write method; `close` is idempotent and
|
|
17
|
+
* every use after close reports `unsafe_discovery`.
|
|
18
|
+
*/
|
|
19
|
+
export interface DiagnosticSnapshotLease {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly reason: string | null;
|
|
22
|
+
read(): DiagnosticSnapshotRead;
|
|
23
|
+
revalidate(): DiagnosticSnapshotStatus;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DiagnosticNativeEntry {
|
|
28
|
+
openDiagnosticSnapshot(agentDir: string, budgetMs: number): DiagnosticSnapshotLease;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DiagnosticNativeLoad =
|
|
32
|
+
| { ok: true; value: DiagnosticNativeEntry }
|
|
33
|
+
| { ok: false; reason: "unsupported" };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the read-only diagnostic entry point from the fixed package-owned
|
|
37
|
+
* artifact. Never extracts, repairs, scans candidates or initializes the
|
|
38
|
+
* ordinary native loader; any unsupported runtime, missing or mismatched
|
|
39
|
+
* artifact resolves to `unsupported`.
|
|
40
|
+
*/
|
|
41
|
+
export interface DiagnosticRuntimeTuple {
|
|
42
|
+
platform?: string;
|
|
43
|
+
arch?: string;
|
|
44
|
+
bunVersion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True only for the single admitted tuple: darwin/arm64 on Bun 1.4.0. */
|
|
48
|
+
export declare function supportedRuntime(runtime: DiagnosticRuntimeTuple): boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An opaque, single-use verification authority. It carries no descriptor and no
|
|
52
|
+
* path: either `activateVerifiedArtifact` or `closeVerifiedArtifact` consumes it,
|
|
53
|
+
* after which the token holds no authority at all.
|
|
54
|
+
*/
|
|
55
|
+
export interface VerifiedDiagnosticArtifact {
|
|
56
|
+
ok: true;
|
|
57
|
+
readonly token: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type DiagnosticArtifactVerification = VerifiedDiagnosticArtifact | { ok: false; reason: "unsupported" };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify one ACL probe. Pure and platform-independent: absence requires a null
|
|
64
|
+
* handle with the platform's "no such ACL" errno, a first entry means an ACE exists,
|
|
65
|
+
* and everything else -- including `acl_get_entry` returning -1 -- is unproven.
|
|
66
|
+
*/
|
|
67
|
+
export declare function classifyAclProbe(probe: {
|
|
68
|
+
handle: number;
|
|
69
|
+
errno: number | null;
|
|
70
|
+
firstEntry: number | null;
|
|
71
|
+
}): "absent" | "present" | "unproven";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Classify the extended ACL of an open descriptor through the platform's read-only
|
|
75
|
+
* ACL API, bound from the absolute system library path. Refuses before `dlopen` when
|
|
76
|
+
* a dyld override is in effect, and reports `unproven` whenever the platform cannot
|
|
77
|
+
* answer.
|
|
78
|
+
*/
|
|
79
|
+
export declare function inspectAclOnDescriptor(descriptor: number): "absent" | "present" | "unproven";
|
|
80
|
+
|
|
81
|
+
/** One parsed entry of the bounded external ACL representation. */
|
|
82
|
+
export interface ParsedAclEntry {
|
|
83
|
+
guid: Uint8Array;
|
|
84
|
+
flags: number;
|
|
85
|
+
rights: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ParsedAclRepresentation =
|
|
89
|
+
| { ok: true; count: number; headerFlags: number; entries: ParsedAclEntry[] }
|
|
90
|
+
| { ok: false };
|
|
91
|
+
|
|
92
|
+
/** Parse and structurally validate the bounded external ACL representation. */
|
|
93
|
+
export declare function parseAclRepresentation(bytes: Uint8Array): ParsedAclRepresentation;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decide a parsed representation by full-field equality: exactly one entry, ACL header
|
|
97
|
+
* flags entirely zero, entry flags exactly the deny tag, rights exactly DELETE, and the
|
|
98
|
+
* everyone GUID.
|
|
99
|
+
*/
|
|
100
|
+
export declare function classifyAclRepresentation(
|
|
101
|
+
parsed: unknown,
|
|
102
|
+
): "approved-single-deny-delete" | "not-allowlisted" | "unproven";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Classify a trusted ancestor's ACL above the artifact namespace anchor: absent, the
|
|
106
|
+
* approved single non-inheriting everyone DENY DELETE entry, understood-but-refused, or
|
|
107
|
+
* unproven.
|
|
108
|
+
*/
|
|
109
|
+
export declare function inspectAncestorAclOnDescriptor(
|
|
110
|
+
descriptor: number,
|
|
111
|
+
): "absent" | "approved-single-deny-delete" | "not-allowlisted" | "unproven";
|
|
112
|
+
|
|
113
|
+
/** Admission for one component of the artifact path: owner, mode, shape. */
|
|
114
|
+
export declare function namespaceComponentAdmitted(
|
|
115
|
+
component: { uid: number; gid: number; mode: number; isSymbolicLink: boolean; isDirectory: boolean },
|
|
116
|
+
euid: number,
|
|
117
|
+
): boolean;
|
|
118
|
+
|
|
119
|
+
/** The single applicable package-owned artifact path, or null when none applies. */
|
|
120
|
+
export declare function selectArtifactLayout(require: NodeRequire): string | null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Prove the namespace and the bytes of one artifact and retain the descriptor they
|
|
124
|
+
* were read from. The handle owns an open descriptor: pass it to
|
|
125
|
+
* `activateVerifiedArtifact` or release it with `closeVerifiedArtifact`.
|
|
126
|
+
*/
|
|
127
|
+
export declare function verifyDiagnosticArtifact(file: string): DiagnosticArtifactVerification;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Release a verified handle without activating it. A fabricated, mutated or already
|
|
131
|
+
* consumed handle is ignored rather than closing an unrelated descriptor.
|
|
132
|
+
*/
|
|
133
|
+
export declare function closeVerifiedArtifact(handle: unknown): void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Activate the verified bytes through their retained descriptor, so a pathname
|
|
137
|
+
* replaced after verification cannot redirect the load. Always closes the handle.
|
|
138
|
+
*/
|
|
139
|
+
export declare function activateVerifiedArtifact(handle: unknown): DiagnosticNativeLoad;
|
|
140
|
+
|
|
141
|
+
export declare function validateDiagnosticBinding(binding: unknown, expectedVersion: string): boolean;
|
|
142
|
+
|
|
143
|
+
export declare function loadDiagnosticNativeReadOnly(runtimeOverride?: DiagnosticRuntimeTuple): DiagnosticNativeLoad;
|
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only diagnostic addon loader (private subpath).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT the ordinary loader: importing the package entry runs
|
|
5
|
+
* `loadNative()` plus crash-diagnostics initialization, and the ordinary loader
|
|
6
|
+
* may consider embedded payloads, cache directories and self-healing steps. A
|
|
7
|
+
* read-only broker observation must not do any of that, so this module:
|
|
8
|
+
*
|
|
9
|
+
* - admits exactly one runtime tuple (darwin/arm64 on Bun 1.4.0) and reports a
|
|
10
|
+
* fixed `unsupported` outcome for everything else, before touching the
|
|
11
|
+
* filesystem;
|
|
12
|
+
* - selects exactly ONE package-owned layout — the versioned cache slot when
|
|
13
|
+
* running from a compiled binary, otherwise the addon beside this file (build
|
|
14
|
+
* checkout) or, when that is absent, the `os`/`cpu`-matched optional platform
|
|
15
|
+
* package that ships it in a published install. There is no candidate walk: a
|
|
16
|
+
* selected layout that fails verification is `unsupported`, never a reason to
|
|
17
|
+
* try another location. No cwd, environment, agent directory or search path is
|
|
18
|
+
* ever consulted;
|
|
19
|
+
* - proves the trusted namespace before reading: every component is owned by the
|
|
20
|
+
* current user or by root, is not a symlink, and grants no group/other write,
|
|
21
|
+
* so an account that owns none of the path cannot swap the artifact out;
|
|
22
|
+
* - proves the bytes on a retained descriptor: SHA-256 equal to the digest this
|
|
23
|
+
* package records in `diagnostic-artifact.json`, which is part of the module
|
|
24
|
+
* graph so a compiled binary carries it too. An addon's own
|
|
25
|
+
* `nativeBuildInfo()` is self-reported by code that has already executed, so
|
|
26
|
+
* it is only a consistency check afterwards, never the trust anchor;
|
|
27
|
+
* - activates the verified bytes through that same retained descriptor
|
|
28
|
+
* (`process.dlopen` on `/dev/fd/<fd>`), so a pathname replaced between
|
|
29
|
+
* verification and activation cannot change what is loaded. On this runtime the
|
|
30
|
+
* descriptor path reopens the descriptor's own vnode; that was verified
|
|
31
|
+
* experimentally, and the loader refuses to activate at all if that route is
|
|
32
|
+
* unavailable;
|
|
33
|
+
* - creates nothing and changes nothing on disk: no embedded-payload unpacking,
|
|
34
|
+
* no staging, no directory creation, no permission change, no lock, and no
|
|
35
|
+
* crash-diagnostics initialization.
|
|
36
|
+
*
|
|
37
|
+
* Boundaries stated exactly, because the loader must not claim what it cannot prove:
|
|
38
|
+
*
|
|
39
|
+
* - ACL authority IS inspected, through the platform's own read-only ACL API
|
|
40
|
+
* (`acl_get_fd_np`/`acl_get_entry`/`acl_free`), bound from the ABSOLUTE system
|
|
41
|
+
* library path with Bun's existing FFI. The absolute path matters: a basename
|
|
42
|
+
* bootstrap loads and runs the initializer of a same-named library found through
|
|
43
|
+
* the dynamic loader's search, which was reproduced on this runtime. Apple's own
|
|
44
|
+
* `acl_get_fd` is a wrapper for `acl_get_fd_np(fd, ACL_TYPE_EXTENDED)`; the `_np`
|
|
45
|
+
* form is used here only to state the ACL type explicitly.
|
|
46
|
+
* - A dyld override environment (`DYLD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`,
|
|
47
|
+
* `DYLD_FRAMEWORK_PATH`, the `DYLD_FALLBACK_*` variants) cannot be proven safe
|
|
48
|
+
* from inside the process, so the loader refuses BEFORE it calls `dlopen`,
|
|
49
|
+
* without deleting or rewriting anyone's environment.
|
|
50
|
+
* - The addon under verification is never imported for this, no helper process is
|
|
51
|
+
* spawned and nothing is installed. The artifact file and its own directory -- the
|
|
52
|
+
* artifact namespace anchor, which is NOT `agentDir` -- must have NO extended ACL
|
|
53
|
+
* entry. A trusted ancestor STRICTLY ABOVE that anchor may instead carry exactly one
|
|
54
|
+
* fully validated, non-inheriting `everyone DENY DELETE` entry (the user-approved
|
|
55
|
+
* exception); anything else, including ALLOW, mixed, inherited, extra rights, a
|
|
56
|
+
* different principal or more than one entry, fails closed. An ACL the platform
|
|
57
|
+
* cannot answer for counts as
|
|
58
|
+
* unproven and is refused: `st_mode` alone cannot show an ACE that grants another
|
|
59
|
+
* account write access. An `acl_get_entry` result of -1 is end-of-iteration OR a
|
|
60
|
+
* failure (an invalid ACL reports EINVAL the same way), so it is never read as
|
|
61
|
+
* "this file has no ACL"; only a null handle with the platform's "no such ACL"
|
|
62
|
+
* errno proves absence. Where the API is unavailable the loader reports
|
|
63
|
+
* `unsupported` instead of guessing.
|
|
64
|
+
* - A rewrite of the same inode by the owner or root stays an explicit non-goal.
|
|
65
|
+
* What the loader does enforce is that any write which lands after verification
|
|
66
|
+
* — by anyone, including a byte-identical rewrite — invalidates the handle,
|
|
67
|
+
* because activation re-proves the descriptor's metadata before `dlopen`.
|
|
68
|
+
*
|
|
69
|
+
* The lease returned by `openDiagnosticSnapshot` exposes no descriptor, path or
|
|
70
|
+
* publication authority: only bounded bytes, revalidation and close.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
74
|
+
import * as fs from "node:fs";
|
|
75
|
+
import { createRequire } from "node:module";
|
|
76
|
+
import * as os from "node:os";
|
|
77
|
+
import * as nodePath from "node:path";
|
|
78
|
+
import trustedArtifacts from "./diagnostic-artifact.json" with { type: "json" };
|
|
79
|
+
import packageManifest from "../package.json" with { type: "json" };
|
|
80
|
+
|
|
81
|
+
/** SPEC AC-B3: the first supported runtime is exactly Darwin arm64 on Bun 1.4.0. */
|
|
82
|
+
const SUPPORTED_BUN_VERSION = "1.4.0";
|
|
83
|
+
const ARTIFACT_BASENAME = "pi_natives.darwin-arm64.node";
|
|
84
|
+
const PLATFORM_PACKAGE = "@gajae-code/natives-darwin-arm64";
|
|
85
|
+
const DIAGNOSTIC_EXPORT = "diagnosticSnapshotOpen";
|
|
86
|
+
const READ_CHUNK_BYTES = 1 << 20;
|
|
87
|
+
|
|
88
|
+
const unsupported = { ok: false, reason: "unsupported" };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one admitted runtime tuple. Node, another Bun build or another OS/arch is
|
|
92
|
+
* unsupported: the observation contract was only established for this tuple.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ platform?: string; arch?: string; bunVersion?: string }} runtime
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function supportedRuntime(runtime) {
|
|
98
|
+
if (runtime.platform !== "darwin") return false;
|
|
99
|
+
if (runtime.arch !== "arm64") return false;
|
|
100
|
+
return runtime.bunVersion === SUPPORTED_BUN_VERSION;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A compiled binary has no package directory to read from, so the artifact can
|
|
105
|
+
* only come from the existing versioned cache location.
|
|
106
|
+
*
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function runningFromCompiledBinary() {
|
|
110
|
+
if (process.env.PI_COMPILED) return true;
|
|
111
|
+
const url = import.meta.url;
|
|
112
|
+
return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The existing fixed trusted cache location for this package version.
|
|
117
|
+
*
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function cachedArtifactPath() {
|
|
121
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
122
|
+
const base =
|
|
123
|
+
dataHome && fs.existsSync(nodePath.join(dataHome, "gjc"))
|
|
124
|
+
? nodePath.join(dataHome, "gjc", "natives")
|
|
125
|
+
: nodePath.join(os.homedir(), ".gjc", "natives");
|
|
126
|
+
return nodePath.join(base, packageManifest.version, ARTIFACT_BASENAME);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The package-owned expected digest for this artifact, or null when this package
|
|
131
|
+
* ships no trusted record for it.
|
|
132
|
+
*
|
|
133
|
+
* @returns {string | null}
|
|
134
|
+
*/
|
|
135
|
+
function trustedDigest() {
|
|
136
|
+
if (trustedArtifacts?.version !== packageManifest.version) return null;
|
|
137
|
+
const digest = trustedArtifacts?.artifacts?.[ARTIFACT_BASENAME];
|
|
138
|
+
return typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest) ? digest : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Select the single applicable layout. Exactly one distribution shape applies per
|
|
143
|
+
* install, and a failure of the selected layout is never a reason to try another.
|
|
144
|
+
*
|
|
145
|
+
* @param {NodeRequire} require
|
|
146
|
+
* @returns {string | null}
|
|
147
|
+
*/
|
|
148
|
+
export function selectArtifactLayout(require) {
|
|
149
|
+
if (runningFromCompiledBinary()) return cachedArtifactPath();
|
|
150
|
+
const besideLoader = nodePath.join(
|
|
151
|
+
nodePath.dirname(require.resolve("./diagnostic-loader.js")),
|
|
152
|
+
ARTIFACT_BASENAME,
|
|
153
|
+
);
|
|
154
|
+
if (fs.existsSync(besideLoader)) return besideLoader;
|
|
155
|
+
try {
|
|
156
|
+
// Published installs ship the addon in the os/cpu-matched optional package,
|
|
157
|
+
// resolved through package metadata only.
|
|
158
|
+
const platformManifest = require.resolve(`${PLATFORM_PACKAGE}/package.json`);
|
|
159
|
+
return nodePath.join(nodePath.dirname(platformManifest), "native", ARTIFACT_BASENAME);
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Lazily bound read-only ACL symbols from the platform's own system library. */
|
|
166
|
+
let aclBinding;
|
|
167
|
+
|
|
168
|
+
/** The absolute system library that owns the platform ACL API. */
|
|
169
|
+
const SYSTEM_LIBRARY_PATH = "/usr/lib/libSystem.B.dylib";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Dynamic-loader overrides that can introduce another image into this process. They
|
|
173
|
+
* are applied before any of this code runs, so their presence cannot be undone here;
|
|
174
|
+
* the loader refuses instead, and never edits the environment.
|
|
175
|
+
*/
|
|
176
|
+
const DYLD_OVERRIDE_VARIABLES = [
|
|
177
|
+
"DYLD_INSERT_LIBRARIES",
|
|
178
|
+
"DYLD_LIBRARY_PATH",
|
|
179
|
+
"DYLD_FRAMEWORK_PATH",
|
|
180
|
+
"DYLD_FALLBACK_LIBRARY_PATH",
|
|
181
|
+
"DYLD_FALLBACK_FRAMEWORK_PATH",
|
|
182
|
+
"DYLD_VERSIONED_LIBRARY_PATH",
|
|
183
|
+
"DYLD_VERSIONED_FRAMEWORK_PATH",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether this process runs with a dynamic-loader override in effect.
|
|
188
|
+
*
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
function dyldOverridePresent() {
|
|
192
|
+
return DYLD_OVERRIDE_VARIABLES.some(name => {
|
|
193
|
+
const value = process.env[name];
|
|
194
|
+
return typeof value === "string" && value !== "";
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bind the read-only macOS ACL API.
|
|
200
|
+
*
|
|
201
|
+
* Only `acl_get_fd`, `acl_get_entry` and `acl_free` are bound, all of them
|
|
202
|
+
* read-only: nothing here can set or change an ACL. The library is the system's own
|
|
203
|
+
* `libSystem.B.dylib`, resolved by the dynamic loader, which is the same trust
|
|
204
|
+
* anchor the ordinary native loader already relies on for platform calls.
|
|
205
|
+
*
|
|
206
|
+
* @returns {{ aclGetFd: (fd: number) => number | bigint, aclGetEntry: (acl: number | bigint, id: number, out: unknown) => number, aclFree: (acl: number | bigint) => number } | null}
|
|
207
|
+
*/
|
|
208
|
+
function aclSymbols() {
|
|
209
|
+
if (aclBinding !== undefined) return aclBinding;
|
|
210
|
+
aclBinding = null;
|
|
211
|
+
if (process.platform !== "darwin" || typeof Bun === "undefined") return aclBinding;
|
|
212
|
+
// Refuse before any dlopen: an override could have introduced another image, and
|
|
213
|
+
// inspecting the library after loading it cannot undo an initializer.
|
|
214
|
+
if (dyldOverridePresent()) return aclBinding;
|
|
215
|
+
try {
|
|
216
|
+
const require = createRequire(import.meta.url);
|
|
217
|
+
const { dlopen, FFIType, ptr, read, toArrayBuffer } = require("bun:ffi");
|
|
218
|
+
const library = dlopen(SYSTEM_LIBRARY_PATH, {
|
|
219
|
+
// `acl_get_fd` is Apple's wrapper for this call; the `_np` form is used so the
|
|
220
|
+
// ACL type is explicit at the call site.
|
|
221
|
+
acl_get_fd_np: { args: [FFIType.i32, FFIType.i32], returns: FFIType.ptr },
|
|
222
|
+
acl_get_entry: { args: [FFIType.ptr, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
223
|
+
acl_free: { args: [FFIType.ptr], returns: FFIType.i32 },
|
|
224
|
+
// Read-only entry introspection for the approved single-ACE exception.
|
|
225
|
+
acl_valid: { args: [FFIType.ptr], returns: FFIType.i32 },
|
|
226
|
+
// Full permission mask and the bounded external representation: these are what
|
|
227
|
+
// make "nothing but DELETE" and "exactly one entry" complete statements rather
|
|
228
|
+
// than a walk over the bits we happened to enumerate.
|
|
229
|
+
acl_get_permset_mask_np: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
230
|
+
acl_size: { args: [FFIType.ptr], returns: FFIType.i64 },
|
|
231
|
+
acl_copy_ext_native: { args: [FFIType.ptr, FFIType.ptr, FFIType.i64], returns: FFIType.i64 },
|
|
232
|
+
__error: { args: [], returns: FFIType.ptr },
|
|
233
|
+
});
|
|
234
|
+
aclBinding = {
|
|
235
|
+
aclGetFd: fd => library.symbols.acl_get_fd_np(fd, ACL_TYPE_EXTENDED),
|
|
236
|
+
aclGetEntry: (acl, id, out) => library.symbols.acl_get_entry(acl, id, ptr(out)),
|
|
237
|
+
aclFree: acl => library.symbols.acl_free(acl),
|
|
238
|
+
aclValid: acl => library.symbols.acl_valid(acl),
|
|
239
|
+
readPointer: view => Number(view[0]),
|
|
240
|
+
aclGetPermsetMask: (entry, out) => library.symbols.acl_get_permset_mask_np(entry, ptr(out)),
|
|
241
|
+
aclSize: acl => Number(library.symbols.acl_size(acl)),
|
|
242
|
+
aclCopyExtNative: (buffer, acl, size) =>
|
|
243
|
+
Number(library.symbols.acl_copy_ext_native(ptr(buffer), acl, size)),
|
|
244
|
+
errno: () => {
|
|
245
|
+
const pointer = library.symbols.__error();
|
|
246
|
+
return pointer ? read.i32(pointer, 0) : null;
|
|
247
|
+
},
|
|
248
|
+
resetErrno: () => {
|
|
249
|
+
const pointer = library.symbols.__error();
|
|
250
|
+
if (!pointer) return false;
|
|
251
|
+
// Clearing errno at the call boundary keeps a stale value from a previous,
|
|
252
|
+
// unrelated call out of the classification below.
|
|
253
|
+
new DataView(toArrayBuffer(pointer, 0, 4)).setInt32(0, 0, true);
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
} catch {
|
|
258
|
+
aclBinding = null;
|
|
259
|
+
}
|
|
260
|
+
return aclBinding;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** First-entry selector of the platform ACL iteration API. */
|
|
264
|
+
const ACL_FIRST_ENTRY = 0;
|
|
265
|
+
|
|
266
|
+
/** macOS ACL type selector for the extended (NFSv4-style) ACL. */
|
|
267
|
+
const ACL_TYPE_EXTENDED = 0x00000100;
|
|
268
|
+
|
|
269
|
+
/** `errno` value the platform reports when a file simply has no extended ACL. */
|
|
270
|
+
const ACL_ERRNO_NO_ACL = 2;
|
|
271
|
+
|
|
272
|
+
/** Selector for the entry after the first one. */
|
|
273
|
+
const ACL_NEXT_ENTRY = -1;
|
|
274
|
+
|
|
275
|
+
/** Selector for the last entry. */
|
|
276
|
+
const ACL_LAST_ENTRY = -2;
|
|
277
|
+
|
|
278
|
+
/** `errno` the platform reports when iteration has reached the end. */
|
|
279
|
+
const ACL_ERRNO_END_OF_ITERATION = 22;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Bounded external ACL representation (`kauth_filesec`, magic `0x012cc16d`).
|
|
283
|
+
*
|
|
284
|
+
* Layout from the platform SDK (`sys/kauth.h`): `u32 magic`, `guid_t owner`,
|
|
285
|
+
* `guid_t group`, then `struct kauth_acl { u32 entrycount; u32 flags; kauth_ace ace[] }`
|
|
286
|
+
* with `struct kauth_ace { guid_t applicable; u32 flags; u32 rights }`. A one-entry ACL
|
|
287
|
+
* is therefore exactly 68 bytes, which was confirmed against a real ACL on this runtime
|
|
288
|
+
* before this parser was relied on.
|
|
289
|
+
*/
|
|
290
|
+
const KAUTH_FILESEC_MAGIC = 0x012cc16d;
|
|
291
|
+
const KAUTH_FILESEC_HEADER_BYTES = 44;
|
|
292
|
+
const KAUTH_ACE_BYTES = 24;
|
|
293
|
+
const KAUTH_ACL_MAX_ENTRIES = 128;
|
|
294
|
+
const KAUTH_ACE_DENY_ONLY = 2;
|
|
295
|
+
const KAUTH_VNODE_DELETE_ONLY = 1 << 4;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse the bounded external representation. Every structural fact is validated: total
|
|
299
|
+
* length, magic, entry count against the byte length and the platform maximum. Nothing
|
|
300
|
+
* is reinterpreted beyond that documented layout.
|
|
301
|
+
*
|
|
302
|
+
* @param {Uint8Array} bytes
|
|
303
|
+
* @returns {{ ok: true, count: number, headerFlags: number, entries: { guid: Uint8Array, flags: number, rights: number }[] } | { ok: false }}
|
|
304
|
+
*/
|
|
305
|
+
export function parseAclRepresentation(bytes) {
|
|
306
|
+
if (!(bytes instanceof Uint8Array)) return { ok: false };
|
|
307
|
+
if (bytes.byteLength < KAUTH_FILESEC_HEADER_BYTES) return { ok: false };
|
|
308
|
+
const payload = bytes.byteLength - KAUTH_FILESEC_HEADER_BYTES;
|
|
309
|
+
if (payload % KAUTH_ACE_BYTES !== 0) return { ok: false };
|
|
310
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
311
|
+
if (view.getUint32(0, true) !== KAUTH_FILESEC_MAGIC) return { ok: false };
|
|
312
|
+
const count = view.getUint32(36, true);
|
|
313
|
+
if (count !== payload / KAUTH_ACE_BYTES) return { ok: false };
|
|
314
|
+
if (count > KAUTH_ACL_MAX_ENTRIES) return { ok: false };
|
|
315
|
+
const entries = [];
|
|
316
|
+
for (let index = 0; index < count; index += 1) {
|
|
317
|
+
const base = KAUTH_FILESEC_HEADER_BYTES + index * KAUTH_ACE_BYTES;
|
|
318
|
+
entries.push({
|
|
319
|
+
guid: bytes.slice(base, base + 16),
|
|
320
|
+
flags: view.getUint32(base + 16, true),
|
|
321
|
+
rights: view.getUint32(base + 20, true),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, count, headerFlags: view.getUint32(40, true), entries };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Decide a parsed representation against the approved exception. Full-field equality,
|
|
329
|
+
* never a walk over known bits: the ACL header flags must be entirely zero, the entry's
|
|
330
|
+
* flags must be exactly the deny tag, and its rights must be exactly DELETE.
|
|
331
|
+
*
|
|
332
|
+
* @param {unknown} parsed
|
|
333
|
+
* @returns {"approved-single-deny-delete" | "not-allowlisted" | "unproven"}
|
|
334
|
+
*/
|
|
335
|
+
export function classifyAclRepresentation(parsed) {
|
|
336
|
+
if (typeof parsed !== "object" || parsed === null) return "unproven";
|
|
337
|
+
const record = /** @type {{ ok?: unknown; count?: unknown; headerFlags?: unknown; entries?: unknown }} */ (parsed);
|
|
338
|
+
if (record.ok !== true) return "unproven";
|
|
339
|
+
if (typeof record.count !== "number" || !Array.isArray(record.entries)) return "unproven";
|
|
340
|
+
if (record.count !== record.entries.length) return "unproven";
|
|
341
|
+
if (record.count !== 1) return "not-allowlisted";
|
|
342
|
+
if (record.headerFlags !== 0) return "not-allowlisted";
|
|
343
|
+
const entry = record.entries[0];
|
|
344
|
+
if (entry.flags !== KAUTH_ACE_DENY_ONLY) return "not-allowlisted";
|
|
345
|
+
if (entry.rights !== KAUTH_VNODE_DELETE_ONLY) return "not-allowlisted";
|
|
346
|
+
if (entry.guid.length !== EVERYONE_GUID.length) return "unproven";
|
|
347
|
+
return entry.guid.every((byte, index) => byte === EVERYONE_GUID[index])
|
|
348
|
+
? "approved-single-deny-delete"
|
|
349
|
+
: "not-allowlisted";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Raw well-known everyone GUID (`ABCDEFAB-CDEF-ABCD-EFAB-CDEF0000000C`). */
|
|
353
|
+
const EVERYONE_GUID = Uint8Array.from([
|
|
354
|
+
0xab, 0xcd, 0xef, 0xab, 0xcd, 0xef, 0xab, 0xcd, 0xef, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x0c,
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Classify one ACL probe. Pure, so it behaves identically on every platform and can
|
|
359
|
+
* be tested without the platform API.
|
|
360
|
+
*
|
|
361
|
+
* Absence must be proven positively: a null handle counts only with the platform's
|
|
362
|
+
* "no such ACL" errno. A first entry means an ACE exists. `acl_get_entry` returning
|
|
363
|
+
* -1 is end-of-iteration OR a failure -- an invalid ACL reports EINVAL the same way
|
|
364
|
+
* -- so it is `unproven`, never "empty".
|
|
365
|
+
*
|
|
366
|
+
* @param {{ handle: number; errno: number | null; firstEntry: number | null }} probe
|
|
367
|
+
* @returns {"absent" | "present" | "unproven"}
|
|
368
|
+
*/
|
|
369
|
+
export function classifyAclProbe(probe) {
|
|
370
|
+
if (!probe.handle) return probe.errno === ACL_ERRNO_NO_ACL ? "absent" : "unproven";
|
|
371
|
+
if (probe.firstEntry === 0) return "present";
|
|
372
|
+
return "unproven";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Classify the extended ACL of one open descriptor.
|
|
377
|
+
*
|
|
378
|
+
* `absent` requires a positive observation that the ACL holds no entry; a first
|
|
379
|
+
* entry means an ACE exists and the artifact is refused; anything the API cannot
|
|
380
|
+
* answer is `unproven`, never silently treated as clean.
|
|
381
|
+
*
|
|
382
|
+
* @param {number} fd
|
|
383
|
+
* @returns {"absent" | "present" | "unproven"}
|
|
384
|
+
*/
|
|
385
|
+
export function inspectAclOnDescriptor(fd) {
|
|
386
|
+
const symbols = aclSymbols();
|
|
387
|
+
if (symbols === null) return "unproven";
|
|
388
|
+
let acl;
|
|
389
|
+
try {
|
|
390
|
+
// Reset at the call boundary, then capture immediately after the call, so the
|
|
391
|
+
// value classified below belongs to this call and not to an earlier one.
|
|
392
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
393
|
+
acl = symbols.aclGetFd(fd);
|
|
394
|
+
} catch {
|
|
395
|
+
return "unproven";
|
|
396
|
+
}
|
|
397
|
+
const handle = typeof acl === "bigint" ? Number(acl) : acl;
|
|
398
|
+
if (!handle) {
|
|
399
|
+
return classifyAclProbe({ handle: 0, errno: symbols.errno(), firstEntry: null });
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const entry = new BigUint64Array(1);
|
|
403
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
404
|
+
const result = symbols.aclGetEntry(acl, ACL_FIRST_ENTRY, entry);
|
|
405
|
+
return classifyAclProbe({ handle, errno: symbols.errno(), firstEntry: result });
|
|
406
|
+
} catch {
|
|
407
|
+
return "unproven";
|
|
408
|
+
} finally {
|
|
409
|
+
try {
|
|
410
|
+
symbols.aclFree(acl);
|
|
411
|
+
} catch {
|
|
412
|
+
// Nothing to recover; the allocation is owned by the platform call above.
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Classify a trusted ancestor's ACL: absent, the single approved deny-delete ACE, or
|
|
419
|
+
* fail closed.
|
|
420
|
+
*
|
|
421
|
+
* @param {number} fd
|
|
422
|
+
* @returns {"absent" | "approved-single-deny-delete" | "not-allowlisted" | "unproven"}
|
|
423
|
+
*/
|
|
424
|
+
export function inspectAncestorAclOnDescriptor(fd) {
|
|
425
|
+
const classification = inspectAclOnDescriptor(fd);
|
|
426
|
+
if (classification === "absent") return "absent";
|
|
427
|
+
if (classification === "unproven") return "unproven";
|
|
428
|
+
const symbols = aclSymbols();
|
|
429
|
+
if (symbols === null) return "unproven";
|
|
430
|
+
let acl;
|
|
431
|
+
try {
|
|
432
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
433
|
+
acl = symbols.aclGetFd(fd);
|
|
434
|
+
} catch {
|
|
435
|
+
return "unproven";
|
|
436
|
+
}
|
|
437
|
+
const handle = typeof acl === "bigint" ? Number(acl) : acl;
|
|
438
|
+
if (!handle) return "unproven";
|
|
439
|
+
try {
|
|
440
|
+
if (symbols.aclValid(acl) !== 0) return "unproven";
|
|
441
|
+
|
|
442
|
+
// 1. Authoritative completeness: the bounded external representation carries the
|
|
443
|
+
// exact entry count, the whole ACL header flag field and each entry's whole
|
|
444
|
+
// flag and rights fields.
|
|
445
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
446
|
+
const size = symbols.aclSize(acl);
|
|
447
|
+
if (!Number.isInteger(size) || size <= 0 || size > 1 << 20) return "unproven";
|
|
448
|
+
const buffer = new Uint8Array(size);
|
|
449
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
450
|
+
const copied = symbols.aclCopyExtNative(buffer, acl, size);
|
|
451
|
+
if (copied !== size) return "unproven";
|
|
452
|
+
const decision = classifyAclRepresentation(parseAclRepresentation(buffer));
|
|
453
|
+
if (decision !== "approved-single-deny-delete") return decision;
|
|
454
|
+
|
|
455
|
+
// 2. Independent cardinality cross-check on the live ACL. Each failable call
|
|
456
|
+
// resets errno immediately before and captures it immediately after, before
|
|
457
|
+
// any other FFI call or free, so a stale value can never stand in for a real
|
|
458
|
+
// one. `-1` alone proves nothing: the terminator must also report the expected
|
|
459
|
+
// errno, and FIRST and LAST must be the very same entry.
|
|
460
|
+
const firstOut = new BigUint64Array(1);
|
|
461
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
462
|
+
const first = symbols.aclGetEntry(acl, ACL_FIRST_ENTRY, firstOut);
|
|
463
|
+
const firstErrno = symbols.errno();
|
|
464
|
+
if (first !== 0 || firstErrno === null) return "unproven";
|
|
465
|
+
const firstEntry = symbols.readPointer(firstOut);
|
|
466
|
+
if (!firstEntry) return "unproven";
|
|
467
|
+
|
|
468
|
+
const lastOut = new BigUint64Array(1);
|
|
469
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
470
|
+
const last = symbols.aclGetEntry(acl, ACL_LAST_ENTRY, lastOut);
|
|
471
|
+
const lastErrno = symbols.errno();
|
|
472
|
+
if (last !== 0 || lastErrno === null) return "unproven";
|
|
473
|
+
const lastEntry = symbols.readPointer(lastOut);
|
|
474
|
+
if (!lastEntry || lastEntry !== firstEntry) return "unproven";
|
|
475
|
+
|
|
476
|
+
const nextOut = new BigUint64Array(1);
|
|
477
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
478
|
+
const next = symbols.aclGetEntry(acl, ACL_NEXT_ENTRY, nextOut);
|
|
479
|
+
const nextErrno = symbols.errno();
|
|
480
|
+
if (next === 0) return "not-allowlisted";
|
|
481
|
+
if (next !== -1 || nextErrno !== ACL_ERRNO_END_OF_ITERATION) return "unproven";
|
|
482
|
+
|
|
483
|
+
// 3. Full permission mask from the platform's own mask getter, as a second,
|
|
484
|
+
// independent statement that nothing but DELETE is granted or denied.
|
|
485
|
+
const maskOut = new BigUint64Array(1);
|
|
486
|
+
if (!symbols.resetErrno()) return "unproven";
|
|
487
|
+
const maskResult = symbols.aclGetPermsetMask(firstEntry, maskOut);
|
|
488
|
+
const maskErrno = symbols.errno();
|
|
489
|
+
if (maskResult !== 0 || maskErrno === null) return "unproven";
|
|
490
|
+
if (maskOut[0] !== BigInt(KAUTH_VNODE_DELETE_ONLY)) return "not-allowlisted";
|
|
491
|
+
|
|
492
|
+
return "approved-single-deny-delete";
|
|
493
|
+
} catch {
|
|
494
|
+
return "unproven";
|
|
495
|
+
} finally {
|
|
496
|
+
try {
|
|
497
|
+
symbols.aclFree(acl);
|
|
498
|
+
} catch {
|
|
499
|
+
// The allocation belongs to the call above; nothing else to release.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Classify the extended ACL of a path component without following symlinks.
|
|
506
|
+
*
|
|
507
|
+
* @param {string} target
|
|
508
|
+
* @param {boolean} isDirectory
|
|
509
|
+
* @returns {"absent" | "present" | "unproven"}
|
|
510
|
+
*/
|
|
511
|
+
function inspectAclOnPath(target, isDirectory) {
|
|
512
|
+
let fd = -1;
|
|
513
|
+
try {
|
|
514
|
+
const flags =
|
|
515
|
+
fs.constants.O_RDONLY |
|
|
516
|
+
(fs.constants.O_NOFOLLOW ?? 0) |
|
|
517
|
+
(isDirectory ? (fs.constants.O_DIRECTORY ?? 0) : 0);
|
|
518
|
+
fd = fs.openSync(target, flags);
|
|
519
|
+
return inspectAclOnDescriptor(fd);
|
|
520
|
+
} catch {
|
|
521
|
+
return "unproven";
|
|
522
|
+
} finally {
|
|
523
|
+
if (fd >= 0) closeDescriptor(fd);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Admission for one component of the artifact path.
|
|
529
|
+
*
|
|
530
|
+
* Ownership matters as much as the permission bits: a directory owned by another
|
|
531
|
+
* account can be rewritten by that account whatever its mode says, so only the
|
|
532
|
+
* current user or root may own any component. Group/other write is refused, and a
|
|
533
|
+
* symlink anywhere on the path is refused because it can be repointed.
|
|
534
|
+
*
|
|
535
|
+
* @param {{ uid: number; gid: number; mode: number; isSymbolicLink: boolean; isDirectory: boolean }} component
|
|
536
|
+
* @param {number} euid
|
|
537
|
+
* @returns {boolean}
|
|
538
|
+
*/
|
|
539
|
+
export function namespaceComponentAdmitted(component, euid) {
|
|
540
|
+
if (component.isSymbolicLink) return false;
|
|
541
|
+
if (component.uid !== euid && component.uid !== 0) return false;
|
|
542
|
+
if ((component.mode & 0o022) !== 0) return false;
|
|
543
|
+
return component.isDirectory;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Prove the namespace that leads to the artifact: a regular-file leaf, and every
|
|
548
|
+
* ancestor owned by the current user or root with no ambient write and no symlink.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} file
|
|
551
|
+
* @returns {boolean}
|
|
552
|
+
*/
|
|
553
|
+
function trustedNamespace(file) {
|
|
554
|
+
// Read-only inspection through the OS stat and ACL interfaces only.
|
|
555
|
+
//
|
|
556
|
+
// The artifact file and its own directory -- the artifact namespace ANCHOR, which is
|
|
557
|
+
// a different path from `agentDir` -- stay strict. Only trusted ancestors strictly
|
|
558
|
+
// above the anchor may carry the approved single everyone DENY DELETE entry.
|
|
559
|
+
const euid = typeof process.geteuid === "function" ? process.geteuid() : -1;
|
|
560
|
+
if (euid < 0) return false;
|
|
561
|
+
const anchor = nodePath.dirname(file);
|
|
562
|
+
let current = file;
|
|
563
|
+
for (;;) {
|
|
564
|
+
const stat = fs.lstatSync(current);
|
|
565
|
+
const component = {
|
|
566
|
+
uid: stat.uid,
|
|
567
|
+
gid: stat.gid,
|
|
568
|
+
mode: stat.mode,
|
|
569
|
+
isSymbolicLink: stat.isSymbolicLink(),
|
|
570
|
+
isDirectory: current === file ? true : stat.isDirectory(),
|
|
571
|
+
};
|
|
572
|
+
if (current === file && !stat.isFile()) return false;
|
|
573
|
+
if (!namespaceComponentAdmitted(component, euid)) return false;
|
|
574
|
+
// An ACE can grant another account write access that `st_mode` never shows.
|
|
575
|
+
const strict = current === file || current === anchor;
|
|
576
|
+
if (strict) {
|
|
577
|
+
if (inspectAclOnPath(current, current !== file) !== "absent") return false;
|
|
578
|
+
} else if (!ancestorAclAdmitted(current)) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const parent = nodePath.dirname(current);
|
|
582
|
+
if (parent === current) return true;
|
|
583
|
+
current = parent;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Whether a trusted ancestor above the anchor is admissible: no ACL, or exactly one
|
|
589
|
+
* validated non-inheriting everyone DENY DELETE entry.
|
|
590
|
+
*
|
|
591
|
+
* @param {string} directory
|
|
592
|
+
* @returns {boolean}
|
|
593
|
+
*/
|
|
594
|
+
function ancestorAclAdmitted(directory) {
|
|
595
|
+
let fd = -1;
|
|
596
|
+
try {
|
|
597
|
+
fd = fs.openSync(
|
|
598
|
+
directory,
|
|
599
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_DIRECTORY ?? 0),
|
|
600
|
+
);
|
|
601
|
+
const decision = inspectAncestorAclOnDescriptor(fd);
|
|
602
|
+
return decision === "absent" || decision === "approved-single-deny-delete";
|
|
603
|
+
} catch {
|
|
604
|
+
return false;
|
|
605
|
+
} finally {
|
|
606
|
+
if (fd >= 0) closeDescriptor(fd);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* @param {number} fd
|
|
612
|
+
* @returns {string}
|
|
613
|
+
*/
|
|
614
|
+
function digestOfDescriptor(fd) {
|
|
615
|
+
const hash = createHash("sha256");
|
|
616
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
617
|
+
let position = 0;
|
|
618
|
+
for (;;) {
|
|
619
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
620
|
+
if (read <= 0) break;
|
|
621
|
+
hash.update(buffer.subarray(0, read));
|
|
622
|
+
position += read;
|
|
623
|
+
}
|
|
624
|
+
return hash.digest("hex");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Live verification authority, keyed by an unguessable token. The descriptor never
|
|
629
|
+
* leaves this module, and consuming an entry removes the authority: a handle is
|
|
630
|
+
* single use, so a stale token can neither activate a recycled descriptor number
|
|
631
|
+
* nor close a descriptor that now belongs to something else.
|
|
632
|
+
*
|
|
633
|
+
* @type {Map<string, { fd: number; file: string; identity: string }>}
|
|
634
|
+
*/
|
|
635
|
+
const verifiedArtifacts = new Map();
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @param {import("node:fs").Stats} stat
|
|
639
|
+
* @returns {string}
|
|
640
|
+
*/
|
|
641
|
+
function identityOfStat(stat) {
|
|
642
|
+
// Content- and authority-affecting fields only. `nlink` and `ctime` are
|
|
643
|
+
// deliberately excluded: unlinking or renaming the PATH away leaves the verified
|
|
644
|
+
// bytes untouched, and refusing that would break the pinned-descriptor guarantee
|
|
645
|
+
// this loader exists to provide. A write to the bytes bumps `mtime`, and a
|
|
646
|
+
// permission or ownership change shows up in `mode`/`uid`/`gid`.
|
|
647
|
+
return JSON.stringify([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.uid, stat.gid, stat.mode]);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Take the authority for a token, if any. The entry is removed, so every handle is
|
|
652
|
+
* consumed exactly once.
|
|
653
|
+
*
|
|
654
|
+
* @param {unknown} handle
|
|
655
|
+
* @returns {{ fd: number; file: string; identity: string } | null}
|
|
656
|
+
*/
|
|
657
|
+
function consumeHandle(handle) {
|
|
658
|
+
if (typeof handle !== "object" || handle === null) return null;
|
|
659
|
+
const token = /** @type {{ token?: unknown }} */ (handle).token;
|
|
660
|
+
if (typeof token !== "string") return null;
|
|
661
|
+
const entry = verifiedArtifacts.get(token);
|
|
662
|
+
if (entry === undefined) return null;
|
|
663
|
+
verifiedArtifacts.delete(token);
|
|
664
|
+
return entry;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Verify one artifact and retain the descriptor the verified bytes came from.
|
|
669
|
+
*
|
|
670
|
+
* The returned handle is opaque and single use: it carries no descriptor, and
|
|
671
|
+
* either `activateVerifiedArtifact` or `closeVerifiedArtifact` consumes it.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} file
|
|
674
|
+
* @returns {{ ok: true, token: string } | { ok: false, reason: "unsupported" }}
|
|
675
|
+
*/
|
|
676
|
+
export function verifyDiagnosticArtifact(file) {
|
|
677
|
+
const expectedDigest = trustedDigest();
|
|
678
|
+
if (expectedDigest === null) return unsupported;
|
|
679
|
+
let fd = -1;
|
|
680
|
+
try {
|
|
681
|
+
if (!trustedNamespace(file)) return unsupported;
|
|
682
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
683
|
+
const stat = fs.fstatSync(fd);
|
|
684
|
+
if (!stat.isFile() || stat.size === 0) return unsupported;
|
|
685
|
+
if ((stat.mode & 0o022) !== 0) return unsupported;
|
|
686
|
+
if (stat.uid !== process.geteuid?.() && stat.uid !== 0) return unsupported;
|
|
687
|
+
if (inspectAclOnDescriptor(fd) !== "absent") return unsupported;
|
|
688
|
+
if (digestOfDescriptor(fd) !== expectedDigest) return unsupported;
|
|
689
|
+
const token = createHash("sha256")
|
|
690
|
+
.update(`${file}:${randomUUID()}:${stat.ino}`)
|
|
691
|
+
.digest("hex");
|
|
692
|
+
verifiedArtifacts.set(token, { fd, file, identity: identityOfStat(fs.fstatSync(fd)) });
|
|
693
|
+
fd = -1;
|
|
694
|
+
return { ok: true, token };
|
|
695
|
+
} catch {
|
|
696
|
+
return unsupported;
|
|
697
|
+
} finally {
|
|
698
|
+
if (fd >= 0) closeDescriptor(fd);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* @param {number} fd
|
|
704
|
+
* @returns {void}
|
|
705
|
+
*/
|
|
706
|
+
function closeDescriptor(fd) {
|
|
707
|
+
try {
|
|
708
|
+
fs.closeSync(fd);
|
|
709
|
+
} catch {
|
|
710
|
+
// Nothing to recover: the descriptor is already gone.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Release a verified handle without activating it. Unknown, fabricated or already
|
|
716
|
+
* consumed handles are ignored: they hold no authority and must not be able to
|
|
717
|
+
* close a descriptor number that now belongs to something else.
|
|
718
|
+
*
|
|
719
|
+
* @param {unknown} handle
|
|
720
|
+
* @returns {void}
|
|
721
|
+
*/
|
|
722
|
+
export function closeVerifiedArtifact(handle) {
|
|
723
|
+
const entry = consumeHandle(handle);
|
|
724
|
+
if (entry === null) return;
|
|
725
|
+
closeDescriptor(entry.fd);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Activate the verified bytes through their retained descriptor.
|
|
730
|
+
*
|
|
731
|
+
* The handle's authority is consumed first, so it cannot be replayed. Before the
|
|
732
|
+
* descriptor is handed to the runtime loader its metadata is re-proved against the
|
|
733
|
+
* snapshot taken during verification: any write that landed in between — including
|
|
734
|
+
* a byte-identical rewrite, which leaves the digest intact — invalidates it.
|
|
735
|
+
*
|
|
736
|
+
* `process.dlopen` is given `/dev/fd/<fd>`, which on this runtime reopens the
|
|
737
|
+
* descriptor's own vnode, so replacing the original pathname cannot redirect the
|
|
738
|
+
* load. The descriptor is always closed before returning.
|
|
739
|
+
*
|
|
740
|
+
* @param {unknown} handle
|
|
741
|
+
* @returns {{ ok: true, value: { openDiagnosticSnapshot: (agentDir: string, budgetMs: number) => unknown } } | { ok: false, reason: "unsupported" }}
|
|
742
|
+
*/
|
|
743
|
+
export function activateVerifiedArtifact(handle) {
|
|
744
|
+
const entry = consumeHandle(handle);
|
|
745
|
+
if (entry === null) return unsupported;
|
|
746
|
+
try {
|
|
747
|
+
if (typeof process.dlopen !== "function") return unsupported;
|
|
748
|
+
if (identityOfStat(fs.fstatSync(entry.fd)) !== entry.identity) return unsupported;
|
|
749
|
+
const module = { exports: {} };
|
|
750
|
+
process.dlopen(module, `/dev/fd/${entry.fd}`);
|
|
751
|
+
const binding = module.exports;
|
|
752
|
+
if (!validateDiagnosticBinding(binding, packageManifest.version)) return unsupported;
|
|
753
|
+
return {
|
|
754
|
+
ok: true,
|
|
755
|
+
value: {
|
|
756
|
+
openDiagnosticSnapshot: (agentDir, budgetMs) => binding[DIAGNOSTIC_EXPORT](agentDir, budgetMs),
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
} catch {
|
|
760
|
+
return unsupported;
|
|
761
|
+
} finally {
|
|
762
|
+
closeDescriptor(entry.fd);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Accept a loaded binding only when it carries the diagnostic entry point AND
|
|
768
|
+
* reports this package's exact build version. This is a consistency check on top
|
|
769
|
+
* of the trusted-digest proof, never the trust anchor itself.
|
|
770
|
+
*
|
|
771
|
+
* @param {unknown} binding
|
|
772
|
+
* @param {string} expectedVersion
|
|
773
|
+
* @returns {boolean}
|
|
774
|
+
*/
|
|
775
|
+
export function validateDiagnosticBinding(binding, expectedVersion) {
|
|
776
|
+
if (typeof binding !== "object" || binding === null) return false;
|
|
777
|
+
const candidate = /** @type {Record<string, unknown>} */ (binding);
|
|
778
|
+
if (typeof candidate[DIAGNOSTIC_EXPORT] !== "function") return false;
|
|
779
|
+
if (typeof candidate.nativeBuildInfo !== "function") return false;
|
|
780
|
+
try {
|
|
781
|
+
const info = /** @type {() => { version?: unknown }} */ (candidate.nativeBuildInfo)();
|
|
782
|
+
return info?.version === expectedVersion;
|
|
783
|
+
} catch {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Load the read-only diagnostic native entry point.
|
|
790
|
+
*
|
|
791
|
+
* @param {{ platform?: string; arch?: string; bunVersion?: string }} [runtimeOverride] optional
|
|
792
|
+
* additional runtime assertion; it can only narrow admission.
|
|
793
|
+
* @returns {{ ok: true, value: { openDiagnosticSnapshot: (agentDir: string, budgetMs: number) => unknown } } | { ok: false, reason: "unsupported" }}
|
|
794
|
+
*/
|
|
795
|
+
export function loadDiagnosticNativeReadOnly(runtimeOverride) {
|
|
796
|
+
const actualRuntime = {
|
|
797
|
+
platform: process.platform,
|
|
798
|
+
arch: process.arch,
|
|
799
|
+
bunVersion: typeof Bun === "undefined" ? undefined : Bun.version,
|
|
800
|
+
};
|
|
801
|
+
// The decision always uses the ACTUAL runtime; an optional descriptor can only
|
|
802
|
+
// narrow admission further (tests inject unsupported tuples), never widen it.
|
|
803
|
+
if (!supportedRuntime(actualRuntime)) return unsupported;
|
|
804
|
+
if (runtimeOverride !== undefined && !supportedRuntime(runtimeOverride)) return unsupported;
|
|
805
|
+
try {
|
|
806
|
+
const require = createRequire(import.meta.url);
|
|
807
|
+
const layout = selectArtifactLayout(require);
|
|
808
|
+
if (layout === null) return unsupported;
|
|
809
|
+
const verified = verifyDiagnosticArtifact(layout);
|
|
810
|
+
if (!verified.ok) return unsupported;
|
|
811
|
+
return activateVerifiedArtifact(verified);
|
|
812
|
+
} catch {
|
|
813
|
+
return unsupported;
|
|
814
|
+
}
|
|
815
|
+
}
|
package/native/index.d.ts
CHANGED
|
@@ -113,6 +113,22 @@ export declare class MacAppearanceObserver {
|
|
|
113
113
|
stop(): void
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Exclusive read-only lease over one already published broker document.
|
|
118
|
+
*
|
|
119
|
+
* The lease exposes no descriptor, no path, no write method and no publication
|
|
120
|
+
* authority: it can only copy bounded bytes, re-prove the retained descriptor
|
|
121
|
+
* and name edges, and close. `close` is idempotent and never throws; every use
|
|
122
|
+
* after close reports `unsafe_discovery`.
|
|
123
|
+
*/
|
|
124
|
+
export declare class NativeDiagnosticSnapshot {
|
|
125
|
+
get ok(): boolean
|
|
126
|
+
get reason(): string | null
|
|
127
|
+
read(): NativeDiagnosticSnapshotRead
|
|
128
|
+
revalidate(): NativeDiagnosticSnapshotStatus
|
|
129
|
+
close(): void
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
/** Retained no-follow authority for the SDK publication namespace. */
|
|
117
133
|
export declare class NativeRetainedBrokerPublication {
|
|
118
134
|
/**
|
|
@@ -615,8 +631,7 @@ export declare class TtyWriter {
|
|
|
615
631
|
* Enqueue terminal output; never blocks. Returns the total bytes now
|
|
616
632
|
* pending (including this chunk).
|
|
617
633
|
*
|
|
618
|
-
* Reads the JS string as UTF-
|
|
619
|
-
* the shared back buffer.
|
|
634
|
+
* Reads the JS string as UTF-8 directly into the shared back buffer.
|
|
620
635
|
*/
|
|
621
636
|
write(data: string): number
|
|
622
637
|
/** Bytes accepted but not yet written to the terminal. */
|
|
@@ -672,7 +687,7 @@ export declare function __piNativesPublishOutcomeV1(): void
|
|
|
672
687
|
* `packages/natives/native/index.js` (which derives the name from
|
|
673
688
|
* `package.json#version`).
|
|
674
689
|
*/
|
|
675
|
-
export declare function
|
|
690
|
+
export declare function __piNativesV0_18_7(): void
|
|
676
691
|
|
|
677
692
|
/**
|
|
678
693
|
* Apply conservative pre-execution rewrites to a bash command.
|
|
@@ -990,6 +1005,14 @@ export interface DependentIdleDeliveryResult {
|
|
|
990
1005
|
*/
|
|
991
1006
|
export declare function detectMacOSAppearance(): MacOSAppearance | null
|
|
992
1007
|
|
|
1008
|
+
/**
|
|
1009
|
+
* Open a read-only lease over the fixed broker publication under `agentDir`.
|
|
1010
|
+
*
|
|
1011
|
+
* The caller supplies only the agent directory: the publication name, the
|
|
1012
|
+
* ancestor policy and the read budget are fixed by this adapter.
|
|
1013
|
+
*/
|
|
1014
|
+
export declare function diagnosticSnapshotOpen(agentDir: string, budgetMs: number): NativeDiagnosticSnapshot
|
|
1015
|
+
|
|
993
1016
|
/** One jsdiff change object: a run of added, removed, or common tokens. */
|
|
994
1017
|
export interface DiffChange {
|
|
995
1018
|
/** Joined token text for this run (lines keep their `
|
|
@@ -2079,6 +2102,19 @@ export type NativeCanonicalDirectoryIdentity =
|
|
|
2079
2102
|
code: "not_found" | "not_directory" | "not_utf8" | "network_unsupported" | "identity_unavailable" | "io_error";
|
|
2080
2103
|
}
|
|
2081
2104
|
|
|
2105
|
+
/** Bytes copied out of one read-only broker publication observation. */
|
|
2106
|
+
export interface NativeDiagnosticSnapshotRead {
|
|
2107
|
+
ok: boolean
|
|
2108
|
+
reason?: string
|
|
2109
|
+
bytes?: Uint8Array
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Outcome of a read-only publication revalidation. */
|
|
2113
|
+
export interface NativeDiagnosticSnapshotStatus {
|
|
2114
|
+
ok: boolean
|
|
2115
|
+
reason?: string
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2082
2118
|
export interface NativeDirectoryParentIdentity {
|
|
2083
2119
|
dev: bigint
|
|
2084
2120
|
ino: bigint
|
package/native/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export const ComputerController = nativeBindings.ComputerController;
|
|
|
21
21
|
export const DiffStream = nativeBindings.DiffStream;
|
|
22
22
|
export const DoctorJournalAuthority = nativeBindings.DoctorJournalAuthority;
|
|
23
23
|
export const MacAppearanceObserver = nativeBindings.MacAppearanceObserver;
|
|
24
|
+
export const NativeDiagnosticSnapshot = nativeBindings.NativeDiagnosticSnapshot;
|
|
24
25
|
export const NativeRetainedBrokerPublication = nativeBindings.NativeRetainedBrokerPublication;
|
|
25
26
|
export const NotificationServer = nativeBindings.NotificationServer;
|
|
26
27
|
export const PowerAssertion = nativeBindings.PowerAssertion;
|
|
@@ -34,7 +35,7 @@ export const TtyWriter = nativeBindings.TtyWriter;
|
|
|
34
35
|
// functions
|
|
35
36
|
export const __gjcInstallTokioRuntime = nativeBindings.__gjcInstallTokioRuntime;
|
|
36
37
|
export const __piNativesPublishOutcomeV1 = nativeBindings.__piNativesPublishOutcomeV1;
|
|
37
|
-
export const
|
|
38
|
+
export const __piNativesV0_18_7 = nativeBindings.__piNativesV0_18_7;
|
|
38
39
|
export const applyBashFixups = nativeBindings.applyBashFixups;
|
|
39
40
|
export const applyOwnerOnlyFdSecurity = nativeBindings.applyOwnerOnlyFdSecurity;
|
|
40
41
|
export const applyOwnerOnlyPathSecurity = nativeBindings.applyOwnerOnlyPathSecurity;
|
|
@@ -45,6 +46,7 @@ export const computerScreenshot = nativeBindings.computerScreenshot;
|
|
|
45
46
|
export const copyToClipboard = nativeBindings.copyToClipboard;
|
|
46
47
|
export const currentExecutablePath = nativeBindings.currentExecutablePath;
|
|
47
48
|
export const detectMacOSAppearance = nativeBindings.detectMacOSAppearance;
|
|
49
|
+
export const diagnosticSnapshotOpen = nativeBindings.diagnosticSnapshotOpen;
|
|
48
50
|
export const diffLineRuns = nativeBindings.diffLineRuns;
|
|
49
51
|
export const diffLines = nativeBindings.diffLines;
|
|
50
52
|
export const diffWords = nativeBindings.diffWords;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gajae-code/natives",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.7",
|
|
4
4
|
"description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
@@ -58,21 +58,29 @@
|
|
|
58
58
|
"native/index.d.ts",
|
|
59
59
|
"native/loader-state.js",
|
|
60
60
|
"native/loader-state.d.ts",
|
|
61
|
+
"native/diagnostic-loader.d.ts",
|
|
62
|
+
"native/diagnostic-loader.js",
|
|
63
|
+
"native/diagnostic-artifact.json",
|
|
61
64
|
"native/embedded-addon.js",
|
|
62
65
|
"README.md"
|
|
63
66
|
],
|
|
64
67
|
"optionalDependencies": {
|
|
65
|
-
"@gajae-code/natives-darwin-arm64": "0.18.
|
|
66
|
-
"@gajae-code/natives-darwin-x64": "0.18.
|
|
67
|
-
"@gajae-code/natives-linux-arm64": "0.18.
|
|
68
|
-
"@gajae-code/natives-linux-x64": "0.18.
|
|
69
|
-
"@gajae-code/natives-win32-x64": "0.18.
|
|
68
|
+
"@gajae-code/natives-darwin-arm64": "0.18.7",
|
|
69
|
+
"@gajae-code/natives-darwin-x64": "0.18.7",
|
|
70
|
+
"@gajae-code/natives-linux-arm64": "0.18.7",
|
|
71
|
+
"@gajae-code/natives-linux-x64": "0.18.7",
|
|
72
|
+
"@gajae-code/natives-win32-x64": "0.18.7"
|
|
70
73
|
},
|
|
71
74
|
"exports": {
|
|
72
75
|
".": {
|
|
73
76
|
"types": "./native/index.d.ts",
|
|
74
77
|
"import": "./native/index.js",
|
|
75
78
|
"default": "./native/index.js"
|
|
79
|
+
},
|
|
80
|
+
"./diagnostic-loader": {
|
|
81
|
+
"types": "./native/diagnostic-loader.d.ts",
|
|
82
|
+
"import": "./native/diagnostic-loader.js",
|
|
83
|
+
"default": "./native/diagnostic-loader.js"
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
}
|