@gmickel/gno 1.34.6 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +12 -1
  2. package/assets/skill/SKILL.md +26 -3
  3. package/assets/skill/cli-reference.md +9 -0
  4. package/assets/skill/mcp-reference.md +3 -2
  5. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.36.0.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v1.36.0.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +111 -1
  10. package/spec/mcp.md +60 -1
  11. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  12. package/spec/output-schemas/peek.schema.json +212 -0
  13. package/spec/output-schemas/search-results.schema.json +1 -1
  14. package/src/cli/commands/peek.ts +66 -0
  15. package/src/cli/options.ts +2 -0
  16. package/src/cli/program.ts +20 -0
  17. package/src/config/index.ts +4 -0
  18. package/src/config/types.ts +14 -0
  19. package/src/core/path-rules.ts +34 -0
  20. package/src/core/peek.ts +202 -0
  21. package/src/ingestion/index.ts +21 -0
  22. package/src/ingestion/record-container.ts +23 -1
  23. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  24. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  25. package/src/ingestion/source-availability/directory.ts +402 -0
  26. package/src/ingestion/source-availability/index.ts +74 -0
  27. package/src/ingestion/source-availability/readers.ts +360 -0
  28. package/src/ingestion/source-availability/resolve.ts +28 -0
  29. package/src/ingestion/source-availability/types.ts +170 -0
  30. package/src/ingestion/sync.ts +197 -24
  31. package/src/ingestion/types.ts +45 -3
  32. package/src/ingestion/walker.ts +263 -5
  33. package/src/mcp/http-egress.ts +1 -0
  34. package/src/mcp/tools/index.ts +14 -0
  35. package/src/mcp/tools/peek.ts +78 -0
  36. package/src/serve/public/globals.built.css +1 -1
  37. package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
  38. package/src/serve/watch-reconciliation-fallback.ts +35 -5
  39. package/src/serve/watch-reconciliation-shared.ts +8 -3
  40. package/src/serve/watch-reconciliation.ts +7 -0
  41. package/src/serve/watch-service-flush.ts +10 -0
  42. package/src/serve/watch-service-lifecycle.ts +2 -0
  43. package/src/serve/watch-service-snapshot.ts +27 -3
  44. package/src/serve/watch-service.ts +1 -0
  45. package/src/serve/watch-snapshot-availability.ts +51 -0
  46. package/src/serve/watch-snapshot-handles.ts +117 -37
  47. package/src/serve/watch-snapshot-libc.ts +141 -22
  48. package/src/serve/watch-snapshot-ops.ts +151 -9
  49. package/src/serve/watch-snapshot-scan.ts +3 -0
  50. package/src/serve/watch-snapshot-types.ts +45 -3
  51. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +0 -1
@@ -68,6 +68,7 @@ export const CMD = {
68
68
  multiGet: "multi-get",
69
69
  ls: "ls",
70
70
  status: "status",
71
+ peek: "peek",
71
72
  publishExport: "publish.export",
72
73
  collectionList: "collection.list",
73
74
  contextList: "context.list",
@@ -101,6 +102,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
101
102
  [CMD.multiGet]: ["terminal", "json", "files", "md"],
102
103
  [CMD.ls]: ["terminal", "json", "files", "md"],
103
104
  [CMD.status]: ["terminal", "json"],
105
+ [CMD.peek]: ["terminal", "json"],
104
106
  [CMD.publishExport]: ["terminal", "json"],
105
107
  [CMD.collectionList]: ["terminal", "json", "md"],
106
108
  [CMD.contextList]: ["terminal", "json", "md"],
@@ -1676,6 +1676,26 @@ function wireOnboardingCommands(program: Command): void {
1676
1676
  );
1677
1677
  });
1678
1678
 
1679
+ // peek - Cheap metadata snapshot for integrations
1680
+ program
1681
+ .command("peek")
1682
+ .description("Show a cheap index snapshot for integrations")
1683
+ .option("--json", "JSON output")
1684
+ .action(async (cmdOpts: Record<string, unknown>) => {
1685
+ const format = getFormat(cmdOpts);
1686
+ assertFormatSupported(CMD.peek, format);
1687
+
1688
+ const { peek, formatPeek } = await import("./commands/peek");
1689
+ const globals = getGlobals();
1690
+ const snapshot = await peek({
1691
+ configPath: globals.config,
1692
+ indexName: globals.index,
1693
+ });
1694
+ process.stdout.write(
1695
+ `${formatPeek(snapshot, { json: format === "json" })}\n`
1696
+ );
1697
+ });
1698
+
1679
1699
  // doctor - Diagnose configuration issues
1680
1700
  program
1681
1701
  .command("doctor")
@@ -60,6 +60,7 @@ export {
60
60
  type Config,
61
61
  ConfigSchema,
62
62
  DEFAULT_EGRESS_POLICY,
63
+ DEFAULT_SOURCE_AVAILABILITY,
63
64
  HttpGatewayConfigSchema,
64
65
  HttpGatewayLimitsSchema,
65
66
  CONTENT_TYPE_GRAPH_HINTS,
@@ -84,6 +85,9 @@ export {
84
85
  isValidLanguageHint,
85
86
  parseScope,
86
87
  resolveConfiguredEgressPolicy,
88
+ SOURCE_AVAILABILITY_MODES,
89
+ type SourceAvailabilityMode,
90
+ SourceAvailabilitySchema,
87
91
  type ProjectProfileBinding,
88
92
  ProjectProfileBindingSchema,
89
93
  type ScopeType,
@@ -56,6 +56,12 @@ export type EgressPolicy = z.infer<typeof EgressPolicySchema>;
56
56
  /** Missing policy is always interpreted as the fail-closed local-only default. */
57
57
  export const DEFAULT_EGRESS_POLICY: EgressPolicy = "local_only";
58
58
 
59
+ /** Source byte materialization boundary for collection indexing. */
60
+ export const SOURCE_AVAILABILITY_MODES = ["any", "local"] as const;
61
+ export const SourceAvailabilitySchema = z.enum(SOURCE_AVAILABILITY_MODES);
62
+ export type SourceAvailabilityMode = z.infer<typeof SourceAvailabilitySchema>;
63
+ export const DEFAULT_SOURCE_AVAILABILITY: SourceAvailabilityMode = "any";
64
+
59
65
  /** Provenance for an effective collection egress policy. */
60
66
  export const EGRESS_POLICY_SOURCES = [
61
67
  "explicit",
@@ -138,6 +144,14 @@ export const CollectionSchema = z.object({
138
144
  */
139
145
  egressPolicyRevision: z.number().int().nonnegative().optional(),
140
146
 
147
+ /**
148
+ * Source content availability policy for indexing. Distinct from egress:
149
+ * controls whether cloud placeholders may be materialized, not where data
150
+ * may leave the machine. Omitted / unset means `any` (legacy read behavior);
151
+ * `local` is opt-in and fails closed where the platform guard is unavailable.
152
+ */
153
+ sourceAvailability: SourceAvailabilitySchema.optional(),
154
+
141
155
  /** Optional per-collection model overrides */
142
156
  models: z
143
157
  .object({
@@ -51,3 +51,37 @@ export function matchesCollectionExclusion(
51
51
  }
52
52
  return false;
53
53
  }
54
+
55
+ /**
56
+ * Whether a known directory is the root of a fully excluded subtree.
57
+ * A trailing `/**` excludes every descendant even though Bun.Glob does not
58
+ * match the directory root itself. Other partial glob shapes must still be
59
+ * traversed because they may leave eligible descendants.
60
+ */
61
+ export function matchesCollectionSubtreeExclusion(
62
+ relPath: string,
63
+ excludes: readonly string[]
64
+ ): boolean {
65
+ const normalizedPath = relPath.replaceAll("\\", "/");
66
+ if (matchesCollectionExclusion(normalizedPath, excludes)) {
67
+ return true;
68
+ }
69
+
70
+ for (const rawPattern of excludes) {
71
+ const pattern = rawPattern.replaceAll("\\", "/");
72
+ if (pattern === "**") {
73
+ return true;
74
+ }
75
+ if (!pattern.endsWith("/**")) {
76
+ continue;
77
+ }
78
+ const subtreeRootPattern = pattern.slice(0, -3);
79
+ if (
80
+ subtreeRootPattern.length > 0 &&
81
+ new Bun.Glob(subtreeRootPattern).match(normalizedPath)
82
+ ) {
83
+ return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Shared peek snapshot builder for CLI and later MCP.
3
+ * Metadata-only: never opens the model cache, resolves model URIs, or activates.
4
+ *
5
+ * @module src/core/peek
6
+ */
7
+
8
+ // node:path — no Bun path utils
9
+ import { join as pathJoin } from "node:path";
10
+
11
+ import type { DocumentRow, IndexStatus } from "../store/types";
12
+
13
+ import { DEFAULT_INDEX_NAME, VERSION, getIndexDbPath } from "../app/constants";
14
+ import {
15
+ isProcessAlive,
16
+ readPidFile,
17
+ resolveProcessPaths,
18
+ } from "../cli/detach";
19
+ import { CliError } from "../cli/errors";
20
+ import { isInitialized, loadConfig } from "../config";
21
+ import { SqliteAdapter } from "../store/sqlite/adapter";
22
+
23
+ export const PEEK_SCHEMA_VERSION = "peek@1.0" as const;
24
+ export const PEEK_RECENT_LIMIT = 10;
25
+
26
+ export interface PeekCounts {
27
+ documents: number;
28
+ collections: number;
29
+ }
30
+
31
+ export interface PeekBacklog {
32
+ pending: number;
33
+ failed: number;
34
+ }
35
+
36
+ export interface PeekRecentItem {
37
+ docid: string;
38
+ uri: string;
39
+ title: string | null;
40
+ collection: string;
41
+ absPath: string;
42
+ modifiedAt: string;
43
+ }
44
+
45
+ export interface PeekServe {
46
+ running: boolean;
47
+ url: string | null;
48
+ }
49
+
50
+ export interface PeekSnapshot {
51
+ schemaVersion: typeof PEEK_SCHEMA_VERSION;
52
+ gnoVersion: string;
53
+ generatedAt: string;
54
+ initialized: boolean;
55
+ indexName: string;
56
+ counts: PeekCounts | null;
57
+ backlog: PeekBacklog | null;
58
+ lastIndexedAt: string | null;
59
+ recent: PeekRecentItem[];
60
+ serve: PeekServe;
61
+ }
62
+
63
+ export interface BuildPeekOptions {
64
+ configPath?: string;
65
+ indexName?: string;
66
+ }
67
+
68
+ function asRuntimeError(error: unknown, fallback: string): CliError {
69
+ if (error instanceof CliError) {
70
+ return error;
71
+ }
72
+ return new CliError(
73
+ "RUNTIME",
74
+ error instanceof Error ? error.message : fallback
75
+ );
76
+ }
77
+
78
+ async function readServeLiveness(): Promise<PeekServe> {
79
+ try {
80
+ const { pidFile } = resolveProcessPaths("serve");
81
+ const payload = await readPidFile(pidFile);
82
+ if (!payload || !isProcessAlive(payload.pid) || payload.port == null) {
83
+ return { running: false, url: null };
84
+ }
85
+ return {
86
+ running: true,
87
+ url: `http://localhost:${payload.port}`,
88
+ };
89
+ } catch (error) {
90
+ throw asRuntimeError(error, "Failed to read serve process state");
91
+ }
92
+ }
93
+
94
+ function mapRecent(
95
+ documents: DocumentRow[],
96
+ status: IndexStatus
97
+ ): PeekRecentItem[] {
98
+ const collectionPaths = new Map(
99
+ status.collections.map((collection) => [collection.name, collection.path])
100
+ );
101
+ return documents.slice(0, PEEK_RECENT_LIMIT).map((doc) => {
102
+ const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
103
+ const collectionPath = collectionPaths.get(doc.collection) ?? "";
104
+ return {
105
+ docid: doc.docid,
106
+ uri: doc.uri,
107
+ title: doc.title,
108
+ collection: doc.collection,
109
+ absPath: pathJoin(collectionPath, sourceRelPath),
110
+ modifiedAt: doc.sourceMtime,
111
+ };
112
+ });
113
+ }
114
+
115
+ function emptySnapshot(
116
+ indexName: string,
117
+ generatedAt: string,
118
+ serve: PeekServe
119
+ ): PeekSnapshot {
120
+ return {
121
+ schemaVersion: PEEK_SCHEMA_VERSION,
122
+ gnoVersion: VERSION,
123
+ generatedAt,
124
+ initialized: false,
125
+ indexName,
126
+ counts: null,
127
+ backlog: null,
128
+ lastIndexedAt: null,
129
+ recent: [],
130
+ serve,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Build a peek@1.0 snapshot. Throws CliError("RUNTIME") on any subquery
136
+ * failure so callers never emit a half-filled payload.
137
+ */
138
+ export async function buildPeekSnapshot(
139
+ options: BuildPeekOptions = {}
140
+ ): Promise<PeekSnapshot> {
141
+ const generatedAt = new Date().toISOString();
142
+ const requestedIndex = options.indexName ?? DEFAULT_INDEX_NAME;
143
+ const serve = await readServeLiveness();
144
+
145
+ const initialized = await isInitialized(options.configPath);
146
+ if (!initialized) {
147
+ return emptySnapshot(requestedIndex, generatedAt, serve);
148
+ }
149
+
150
+ const configResult = await loadConfig(options.configPath);
151
+ if (!configResult.ok) {
152
+ throw new CliError("RUNTIME", configResult.error.message);
153
+ }
154
+
155
+ const store = new SqliteAdapter();
156
+ const openResult = await store.open(
157
+ getIndexDbPath(options.indexName),
158
+ configResult.value.ftsTokenizer
159
+ );
160
+ if (!openResult.ok) {
161
+ throw new CliError("RUNTIME", openResult.error.message);
162
+ }
163
+
164
+ try {
165
+ const statusResult = await store.getStatus();
166
+ if (!statusResult.ok) {
167
+ throw new CliError("RUNTIME", statusResult.error.message);
168
+ }
169
+
170
+ const recentResult = await store.listDocumentsPaginated({
171
+ limit: PEEK_RECENT_LIMIT,
172
+ offset: 0,
173
+ });
174
+ if (!recentResult.ok) {
175
+ throw new CliError("RUNTIME", recentResult.error.message);
176
+ }
177
+
178
+ const status = statusResult.value;
179
+ return {
180
+ schemaVersion: PEEK_SCHEMA_VERSION,
181
+ gnoVersion: VERSION,
182
+ generatedAt,
183
+ initialized: true,
184
+ indexName: status.indexName,
185
+ counts: {
186
+ documents: status.activeDocuments,
187
+ collections: status.collections.length,
188
+ },
189
+ backlog: {
190
+ pending: status.embeddingBacklog,
191
+ failed: status.recentErrors,
192
+ },
193
+ lastIndexedAt: status.lastUpdatedAt,
194
+ recent: mapRecent(recentResult.value.documents, status),
195
+ serve,
196
+ };
197
+ } catch (error) {
198
+ throw asRuntimeError(error, "Failed to build peek snapshot");
199
+ } finally {
200
+ await store.close();
201
+ }
202
+ }
@@ -11,6 +11,27 @@ export { defaultLanguageDetector, SimpleLanguageDetector } from "./language";
11
11
  // Sync service
12
12
  export { defaultSyncService, SyncService } from "./sync";
13
13
  export { resolveContentTypeRules, withContentTypeRules } from "./sync-options";
14
+ // Source availability (content-boundary guard)
15
+ export {
16
+ createDirectoryAvailability,
17
+ createSourceContentReader,
18
+ DEFAULT_SOURCE_AVAILABILITY,
19
+ findUnprovenAvailabilityPrefix,
20
+ isSourceAvailabilitySkip,
21
+ isUnprovenAbsenceCode,
22
+ memoizeDirectoryAvailability,
23
+ relPathUnderAnyPrefix,
24
+ resolveSourceAvailability,
25
+ SOURCE_AVAILABILITY_MODES,
26
+ } from "./source-availability";
27
+ export type {
28
+ DirectoryAvailabilityPort,
29
+ DirectoryAvailabilityResult,
30
+ SourceAvailabilityCode,
31
+ SourceAvailabilityMode,
32
+ SourceContentReaderPort,
33
+ SourceReadResult,
34
+ } from "./source-availability";
14
35
  // Types
15
36
  export type {
16
37
  ChunkerPort,
@@ -58,6 +58,11 @@ interface RecordContainerInput {
58
58
  sourceMtime: string;
59
59
  sourceSize: number;
60
60
  store: StorePort;
61
+ /**
62
+ * Optional pre-read source bytes from the guarded content boundary.
63
+ * When set, record import streams from this buffer and never reopens the path.
64
+ */
65
+ sourceBytes?: Uint8Array;
61
66
  }
62
67
 
63
68
  interface AppliedRecordReconciliation {
@@ -283,6 +288,20 @@ const sourceStream = (
283
288
  },
284
289
  });
285
290
 
291
+ const bytesStream = (
292
+ bytes: Uint8Array,
293
+ signal?: AbortSignal
294
+ ): AsyncIterable<Uint8Array> => ({
295
+ async *[Symbol.asyncIterator]() {
296
+ if (signal?.aborted) {
297
+ throw new Error("record adapter aborted");
298
+ }
299
+ if (bytes.byteLength > 0) {
300
+ yield bytes;
301
+ }
302
+ },
303
+ });
304
+
286
305
  const loadPreviousStructure = async (
287
306
  store: StorePort,
288
307
  existing: DocumentRow | undefined
@@ -443,7 +462,10 @@ export async function processRecordContainer(
443
462
  collection: input.collection.name,
444
463
  mime: input.mime,
445
464
  ext: input.ext,
446
- open: (signal) => sourceStream(input.entry.absPath, signal),
465
+ open: (signal) =>
466
+ input.sourceBytes
467
+ ? bytesStream(input.sourceBytes, signal)
468
+ : sourceStream(input.entry.absPath, signal),
447
469
  limits: {
448
470
  ...DEFAULT_RECORD_ADAPTER_LIMITS,
449
471
  timeoutMs: Math.min(
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Darwin FFI backend for no-materialization I/O policy and guarded content reads.
3
+ * Provider-neutral; reuses the TN3150 mechanism proven in
4
+ * scripts/macos-file-provider-smoke.ts (IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES).
5
+ * No provider SDK; no pin/evict/download/availability mutation.
6
+ *
7
+ * @module src/ingestion/source-availability/darwin-io
8
+ */
9
+
10
+ // bun:ffi — getiopolicy_np/setiopolicy_np/open/read require libc FFI; no Bun high-level equivalent
11
+ import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi";
12
+ // node:fs constants expose Darwin O_NOFOLLOW; Bun has no file-open flag API.
13
+ import { constants as fsConstants } from "node:fs";
14
+
15
+ /** TN3150 / File Provider constants (from smoke harness evidence). */
16
+ export const SF_DATALESS = 0x4000_0000;
17
+ export const IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES = 3;
18
+ export const IOPOL_SCOPE_PROCESS = 0;
19
+ export const IOPOL_MATERIALIZE_DATALESS_FILES_OFF = 1;
20
+ /** Darwin errno for guarded dataless materialization refusal. */
21
+ export const DARWIN_EDEADLK = 11;
22
+ export const DARWIN_EACCES = 13;
23
+ export const DARWIN_EPERM = 1;
24
+ export const DARWIN_ENOENT = 2;
25
+ export const DARWIN_EISDIR = 21;
26
+ export const DARWIN_ELOOP = 62;
27
+ export const DARWIN_EIO = 5;
28
+ const OPEN_RDONLY_NOFOLLOW = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
29
+
30
+ export type DarwinIoPolicyPort = {
31
+ get: (type: number, scope: number) => number;
32
+ set: (type: number, scope: number, policy: number) => number;
33
+ readErrno: () => number;
34
+ };
35
+
36
+ export type DarwinFileIoPort = {
37
+ open: (absPath: string, flags: number) => number;
38
+ read: (fd: number, buf: Uint8Array) => number;
39
+ close: (fd: number) => number;
40
+ readErrno: () => number;
41
+ };
42
+
43
+ /**
44
+ * No-follow lstat of `st_flags` for SF_DATALESS directory classification.
45
+ * Distinct from content open/read — used only at directory boundaries.
46
+ */
47
+ export type DarwinStatPort = {
48
+ lstatFlags: (
49
+ absPath: string
50
+ ) => { ok: true; stFlags: number } | { ok: false; errno: number };
51
+ };
52
+
53
+ export type DarwinIoBundle = {
54
+ policy: DarwinIoPolicyPort;
55
+ file: DarwinFileIoPort;
56
+ stat: DarwinStatPort;
57
+ };
58
+
59
+ /** Darwin `struct stat` layout used by the smoke harness (arm64/x86_64). */
60
+ export const DARWIN_STAT_BUF_SIZE = 144;
61
+ export const DARWIN_ST_FLAGS_OFFSET = 116;
62
+
63
+ type LibSymbols = {
64
+ getiopolicy_np: (type: number, scope: number) => number;
65
+ setiopolicy_np: (type: number, scope: number, policy: number) => number;
66
+ open: (path: ReturnType<typeof ptr>, flags: number) => number;
67
+ close: (fd: number) => number;
68
+ read: (fd: number, buf: ReturnType<typeof ptr>, n: bigint) => bigint;
69
+ lstat: (path: ReturnType<typeof ptr>, buf: ReturnType<typeof ptr>) => number;
70
+ __error: () => ReturnType<typeof ptr> | null;
71
+ };
72
+
73
+ let cachedBundle: DarwinIoBundle | null | undefined;
74
+
75
+ /** Test-only: clear FFI caches between cases. */
76
+ export function resetDarwinIoCachesForTests(): void {
77
+ cachedBundle = undefined;
78
+ }
79
+
80
+ function asFn<T extends (...args: never[]) => unknown>(
81
+ value: unknown
82
+ ): T | null {
83
+ return typeof value === "function" ? (value as T) : null;
84
+ }
85
+
86
+ function cstr(value: string): Uint8Array {
87
+ return Buffer.from(`${value}\0`);
88
+ }
89
+
90
+ function readErrnoFrom(symbols: LibSymbols): number {
91
+ const p = symbols.__error();
92
+ if (!p) {
93
+ return 0;
94
+ }
95
+ return new Int32Array(toArrayBuffer(p, 0, 4))[0] ?? 0;
96
+ }
97
+
98
+ function loadLibSystem(): {
99
+ library: ReturnType<typeof dlopen>;
100
+ symbols: LibSymbols;
101
+ } | null {
102
+ if (process.platform !== "darwin") {
103
+ return null;
104
+ }
105
+ for (const soname of ["libSystem.B.dylib", "libSystem.dylib"]) {
106
+ try {
107
+ const library = dlopen(soname, {
108
+ getiopolicy_np: {
109
+ args: [FFIType.i32, FFIType.i32],
110
+ returns: FFIType.i32,
111
+ },
112
+ setiopolicy_np: {
113
+ args: [FFIType.i32, FFIType.i32, FFIType.i32],
114
+ returns: FFIType.i32,
115
+ },
116
+ open: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
117
+ close: { args: [FFIType.i32], returns: FFIType.i32 },
118
+ read: {
119
+ args: [FFIType.i32, FFIType.ptr, FFIType.u64],
120
+ returns: FFIType.i64,
121
+ },
122
+ lstat: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
123
+ __error: { args: [], returns: FFIType.ptr },
124
+ });
125
+ const raw = library.symbols as Record<string, unknown>;
126
+ const getiopolicy_np = asFn<LibSymbols["getiopolicy_np"]>(
127
+ raw.getiopolicy_np
128
+ );
129
+ const setiopolicy_np = asFn<LibSymbols["setiopolicy_np"]>(
130
+ raw.setiopolicy_np
131
+ );
132
+ const openFn = asFn<LibSymbols["open"]>(raw.open);
133
+ const closeFn = asFn<LibSymbols["close"]>(raw.close);
134
+ const readFn = asFn<LibSymbols["read"]>(raw.read);
135
+ const lstatFn = asFn<LibSymbols["lstat"]>(raw.lstat);
136
+ const errorFn = asFn<LibSymbols["__error"]>(raw.__error);
137
+ if (
138
+ !getiopolicy_np ||
139
+ !setiopolicy_np ||
140
+ !openFn ||
141
+ !closeFn ||
142
+ !readFn ||
143
+ !lstatFn ||
144
+ !errorFn
145
+ ) {
146
+ continue;
147
+ }
148
+ return {
149
+ library,
150
+ symbols: {
151
+ getiopolicy_np,
152
+ setiopolicy_np,
153
+ open: openFn,
154
+ close: closeFn,
155
+ read: readFn,
156
+ lstat: lstatFn,
157
+ __error: errorFn,
158
+ },
159
+ };
160
+ } catch {
161
+ // try next soname
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** Load Darwin policy + file I/O ports; null when unavailable. */
168
+ export function loadDarwinIo(): DarwinIoBundle | null {
169
+ if (cachedBundle !== undefined) {
170
+ return cachedBundle;
171
+ }
172
+ const loaded = loadLibSystem();
173
+ if (!loaded) {
174
+ cachedBundle = null;
175
+ return null;
176
+ }
177
+ const { symbols } = loaded;
178
+ const readErrno = (): number => readErrnoFrom(symbols);
179
+ cachedBundle = {
180
+ policy: {
181
+ get: symbols.getiopolicy_np,
182
+ set: symbols.setiopolicy_np,
183
+ readErrno,
184
+ },
185
+ file: {
186
+ open: (absPath: string, flags: number): number =>
187
+ symbols.open(ptr(cstr(absPath)), flags),
188
+ read: (fd: number, buf: Uint8Array): number =>
189
+ Number(symbols.read(fd, ptr(buf), BigInt(buf.byteLength))),
190
+ close: symbols.close,
191
+ readErrno,
192
+ },
193
+ stat: {
194
+ lstatFlags: (absPath: string) => {
195
+ const buf = new Uint8Array(DARWIN_STAT_BUF_SIZE);
196
+ const rc = symbols.lstat(ptr(cstr(absPath)), ptr(buf));
197
+ if (rc !== 0) {
198
+ return { ok: false as const, errno: readErrno() };
199
+ }
200
+ const stFlags = new DataView(
201
+ buf.buffer,
202
+ buf.byteOffset,
203
+ buf.byteLength
204
+ ).getUint32(DARWIN_ST_FLAGS_OFFSET, true);
205
+ return { ok: true as const, stFlags };
206
+ },
207
+ },
208
+ };
209
+ // Keep library strongly referenced via symbols closure for process lifetime.
210
+ void loaded.library;
211
+ return cachedBundle;
212
+ }
213
+
214
+ /**
215
+ * Run `fn` under process-scoped IOPOL_MATERIALIZE_DATALESS_FILES_OFF.
216
+ * Always restores prior policy. Fail-closed on setup/restore failure.
217
+ */
218
+ export function withNoMaterializePolicy<T>(
219
+ run: () => T,
220
+ port: DarwinIoPolicyPort
221
+ ):
222
+ | { ok: true; value: T }
223
+ | {
224
+ ok: false;
225
+ error:
226
+ | "policy_get_failed"
227
+ | "policy_set_failed"
228
+ | "policy_restore_failed";
229
+ } {
230
+ let prior: number;
231
+ try {
232
+ prior = port.get(
233
+ IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
234
+ IOPOL_SCOPE_PROCESS
235
+ );
236
+ } catch {
237
+ return { ok: false, error: "policy_get_failed" };
238
+ }
239
+ if (prior < 0) {
240
+ return { ok: false, error: "policy_get_failed" };
241
+ }
242
+ let setupResult: number;
243
+ try {
244
+ setupResult = port.set(
245
+ IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
246
+ IOPOL_SCOPE_PROCESS,
247
+ IOPOL_MATERIALIZE_DATALESS_FILES_OFF
248
+ );
249
+ } catch {
250
+ return { ok: false, error: "policy_set_failed" };
251
+ }
252
+ if (setupResult !== 0) {
253
+ return { ok: false, error: "policy_set_failed" };
254
+ }
255
+ let value: T;
256
+ let thrown: unknown;
257
+ try {
258
+ value = run();
259
+ } catch (error) {
260
+ thrown = error;
261
+ }
262
+ let restoreResult: number;
263
+ try {
264
+ restoreResult = port.set(
265
+ IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES,
266
+ IOPOL_SCOPE_PROCESS,
267
+ prior
268
+ );
269
+ } catch {
270
+ return { ok: false, error: "policy_restore_failed" };
271
+ }
272
+ if (restoreResult !== 0) {
273
+ return { ok: false, error: "policy_restore_failed" };
274
+ }
275
+ if (thrown !== undefined) {
276
+ throw thrown;
277
+ }
278
+ return { ok: true, value: value! };
279
+ }
280
+
281
+ export function guardedOpenFlags(): number {
282
+ return OPEN_RDONLY_NOFOLLOW;
283
+ }
284
+
285
+ export function classifyGuardedReadErrno(
286
+ errno: number
287
+ ): "EDEADLK" | "EACCES" | "EPERM" | "ENOENT" | "EISDIR" | "ELOOP" | "OTHER" {
288
+ if (errno === DARWIN_EDEADLK) return "EDEADLK";
289
+ if (errno === DARWIN_EACCES) return "EACCES";
290
+ if (errno === DARWIN_EPERM) return "EPERM";
291
+ if (errno === DARWIN_ENOENT) return "ENOENT";
292
+ if (errno === DARWIN_EISDIR) return "EISDIR";
293
+ if (errno === DARWIN_ELOOP) return "ELOOP";
294
+ return "OTHER";
295
+ }