@hasna/hooks 0.9.5 → 0.9.6

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/dist/sdk/index.js CHANGED
@@ -1194,7 +1194,7 @@ function resolveHooksTransport(env = process.env, options = {}) {
1194
1194
  };
1195
1195
  }
1196
1196
 
1197
- // src/sdk/index.ts
1197
+ // src/sdk/authority.ts
1198
1198
  function resolveHooksSdkAuthority(env = process.env, options = {}) {
1199
1199
  if (selectsHooksLocalStore(env)) {
1200
1200
  throw new Error("REMOTE_COMMAND_UNSUPPORTED: @hasna/hooks/sdk is a hosted registry client and has no local mode; " + `${HOOKS_LOCAL_OPT_IN_HINT} selects the on-box SQLite store, which only the \`hooks\` CLI serves. ` + `Unset it and configure a registry credential (${HOOKS_CREDENTIAL_TIERS}).`);
@@ -1205,7 +1205,174 @@ function resolveHooksSdkAuthority(env = process.env, options = {}) {
1205
1205
  }
1206
1206
  return transport.authority;
1207
1207
  }
1208
+ // src/sdk/generated.ts
1209
+ class ApiError extends Error {
1210
+ status;
1211
+ body;
1212
+ constructor(status, message, body) {
1213
+ super(message);
1214
+ this.status = status;
1215
+ this.body = body;
1216
+ this.name = "ApiError";
1217
+ }
1218
+ }
1208
1219
 
1220
+ class HooksRegistryApiClient {
1221
+ baseUrl;
1222
+ apiKey;
1223
+ fetchImpl;
1224
+ baseHeaders;
1225
+ constructor(options) {
1226
+ if (!options.baseUrl)
1227
+ throw new Error("HooksRegistryApiClient requires a baseUrl.");
1228
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
1229
+ this.apiKey = options.apiKey;
1230
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
1231
+ this.baseHeaders = options.headers ?? {};
1232
+ }
1233
+ async request(method, path, opts) {
1234
+ const url = new URL(this.baseUrl + path);
1235
+ if (opts.query) {
1236
+ for (const [key, value] of Object.entries(opts.query)) {
1237
+ if (value === undefined || value === null)
1238
+ continue;
1239
+ if (Array.isArray(value)) {
1240
+ for (const item of value) {
1241
+ if (item !== undefined && item !== null)
1242
+ url.searchParams.append(key, String(item));
1243
+ }
1244
+ } else {
1245
+ url.searchParams.set(key, String(value));
1246
+ }
1247
+ }
1248
+ }
1249
+ const headers = { Accept: "application/json", ...this.baseHeaders, ...opts.init?.headers };
1250
+ if (this.apiKey)
1251
+ headers["x-api-key"] = this.apiKey;
1252
+ let payload;
1253
+ if (opts.body !== undefined) {
1254
+ headers["Content-Type"] = "application/json";
1255
+ payload = JSON.stringify(opts.body);
1256
+ }
1257
+ const response = await this.fetchImpl(url.toString(), { ...opts.init, method, headers, body: payload });
1258
+ const text = await response.text();
1259
+ const data = text ? (() => {
1260
+ try {
1261
+ return JSON.parse(text);
1262
+ } catch {
1263
+ return text;
1264
+ }
1265
+ })() : undefined;
1266
+ if (!response.ok) {
1267
+ throw new ApiError(response.status, `${method} ${path} failed: ${response.status}`, data);
1268
+ }
1269
+ return data;
1270
+ }
1271
+ async getCatalog(init) {
1272
+ return this.request("GET", `/api/v1/catalog`, {
1273
+ body: undefined,
1274
+ query: undefined,
1275
+ init
1276
+ });
1277
+ }
1278
+ async putHook(body, init) {
1279
+ return this.request("PUT", `/api/v1/hooks`, {
1280
+ body,
1281
+ query: undefined,
1282
+ init
1283
+ });
1284
+ }
1285
+ async getHookArtifact(name, version, init) {
1286
+ return this.request("GET", `/api/v1/hooks/${encodeURIComponent(String(name))}/${encodeURIComponent(String(version))}`, {
1287
+ body: undefined,
1288
+ query: undefined,
1289
+ init
1290
+ });
1291
+ }
1292
+ async getLock(init) {
1293
+ return this.request("GET", `/api/v1/lock`, {
1294
+ body: undefined,
1295
+ query: undefined,
1296
+ init
1297
+ });
1298
+ }
1299
+ async getHealth(init) {
1300
+ return this.request("GET", `/health`, {
1301
+ body: undefined,
1302
+ query: undefined,
1303
+ init
1304
+ });
1305
+ }
1306
+ async getOpenApi(init) {
1307
+ return this.request("GET", `/openapi.json`, {
1308
+ body: undefined,
1309
+ query: undefined,
1310
+ init
1311
+ });
1312
+ }
1313
+ async getReady(init) {
1314
+ return this.request("GET", `/ready`, {
1315
+ body: undefined,
1316
+ query: undefined,
1317
+ init
1318
+ });
1319
+ }
1320
+ async getVersion(init) {
1321
+ return this.request("GET", `/version`, {
1322
+ body: undefined,
1323
+ query: undefined,
1324
+ init
1325
+ });
1326
+ }
1327
+ }
1328
+ // src/sdk/registry-client.ts
1329
+ function bindAuthority(client, authority) {
1330
+ for (const [key, value] of [
1331
+ ["authorityOrigin", authority.origin],
1332
+ ["apiKeySource", authority.apiKeySource],
1333
+ ["apiUrlSource", authority.apiUrlSource]
1334
+ ]) {
1335
+ Object.defineProperty(client, key, {
1336
+ value,
1337
+ enumerable: true,
1338
+ writable: false,
1339
+ configurable: false
1340
+ });
1341
+ }
1342
+ return client;
1343
+ }
1344
+ function bindAuthorityFetch(authority, baseFetch = fetch) {
1345
+ const expectedOrigin = new URL(authority.origin).origin;
1346
+ return async (input, init) => {
1347
+ const resolved = await resolveHooksRequestAuthority(authority);
1348
+ const url = new URL(String(input));
1349
+ if (url.origin !== expectedOrigin) {
1350
+ throw new Error(`REMOTE_API_ORIGIN_MISMATCH: hooks registry request targeted ${url.origin}, ` + `not the resolved registry origin ${expectedOrigin}; no credential was sent.`);
1351
+ }
1352
+ const headers = new Headers(init?.headers);
1353
+ headers.set("x-api-key", resolved.apiKey);
1354
+ return baseFetch(url, {
1355
+ ...init,
1356
+ headers,
1357
+ redirect: "manual"
1358
+ });
1359
+ };
1360
+ }
1361
+ function hooksRegistryApiClientFor(authority, options = {}) {
1362
+ const boundFetch = bindAuthorityFetch(authority, options.fetch ?? fetch);
1363
+ const client = new HooksRegistryApiClient({
1364
+ baseUrl: authority.origin,
1365
+ fetch: boundFetch
1366
+ });
1367
+ return bindAuthority(client, authority);
1368
+ }
1369
+ function createHooksRegistryApiClient(options = {}) {
1370
+ const authority = resolveHooksSdkAuthority(options.env ?? process.env, {
1371
+ credentials: options.credentials
1372
+ });
1373
+ return hooksRegistryApiClientFor(authority, { fetch: options.fetch });
1374
+ }
1375
+ // src/sdk/index.ts
1209
1376
  class HooksClient {
1210
1377
  origin;
1211
1378
  v1BaseUrl;
@@ -1274,6 +1441,10 @@ function createHooksClient(options = {}) {
1274
1441
  export {
1275
1442
  resolveHooksSdkAuthority,
1276
1443
  hooksRegistryOrigin,
1444
+ hooksRegistryApiClientFor,
1445
+ createHooksRegistryApiClient,
1277
1446
  createHooksClient,
1447
+ ApiError as HooksRegistryApiError,
1448
+ HooksRegistryApiClient,
1278
1449
  HooksClient
1279
1450
  };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `@hasna/hooks` — resolver-backed factory for the generated registry client.
3
+ *
4
+ * The generated {@link HooksRegistryApiClient} takes an explicit
5
+ * `baseUrl`/`apiKey` and has no environment surface of its own. This module is
6
+ * the ONE adapter from that client onto the @hasna/contracts credential chain
7
+ * (owner ruling 2026-09-04, hasna/apps#1720) so callers never write a private
8
+ * copy of the chain — the same rule `HooksClient` (src/sdk/index.ts) follows.
9
+ *
10
+ * FOUR PROPERTIES THIS ADAPTER EXISTS TO KEEP, matching `HooksClient`:
11
+ *
12
+ * 1. FAIL CLOSED. `resolveHooksSdkAuthority` refuses the local opt-in by
13
+ * name and throws `REMOTE_API_*` (naming every tier it consulted) when
14
+ * nothing resolves. A client is never built that would answer from a
15
+ * local file, and an unauthenticated client is never built at all.
16
+ *
17
+ * 2. FRESH PER REQUEST. The credential is re-resolved on every dispatch
18
+ * through `resolveHooksRequestAuthority`, so a rotation or a vault
19
+ * pointer resolves again for each request instead of being frozen at
20
+ * construction — and a changed authority binding refuses the request
21
+ * rather than sending the previous credential to the new URL.
22
+ *
23
+ * 3. NEVER FOLLOW A REDIRECT. The generated client does not set `redirect`,
24
+ * which would make fetch follow a 3xx and carry `x-api-key` to another
25
+ * origin. The adapter forces `redirect: "manual"` for every dispatch,
26
+ * after the caller's init, so it cannot be overridden.
27
+ *
28
+ * 4. STAY ON THE RESOLVED ORIGIN. The adapter refuses any dispatch whose
29
+ * URL is not exactly the resolved registry origin, so a malformed path
30
+ * can never turn the credential into a cross-origin request.
31
+ */
32
+ import { type HooksRemoteAuthority } from "../lib/transport.js";
33
+ import { type HooksClientOptions } from "./authority.js";
34
+ import { HooksRegistryApiClient } from "./generated.js";
35
+ /** The transport's fetch shape (Bun's global fetch carries extra static props). */
36
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
37
+ /** The fields a resolver-backed client exposes for diagnostics. Never a value. */
38
+ export interface HooksRegistryApiClientAuthority {
39
+ /** Registry origin WITHOUT `/v1`; the registry routes hang off `<origin>/api/v1`. */
40
+ readonly authorityOrigin: string;
41
+ /** WHERE the credential came from (env NAME, Keychain reference, file PATH). Never a value. */
42
+ readonly apiKeySource: string | null;
43
+ /** WHERE the authority came from. Never a value. */
44
+ readonly apiUrlSource: string | null;
45
+ }
46
+ /** A generated registry client bound to a resolved authority. */
47
+ export type ResolvedHooksRegistryApiClient = HooksRegistryApiClient & HooksRegistryApiClientAuthority;
48
+ /**
49
+ * Bind an already-resolved authority to a fetch implementation: every dispatch
50
+ * re-resolves the credential, pins the origin and refuses to follow redirects.
51
+ *
52
+ * Exported so the two guards below are directly testable — they are
53
+ * defense-in-depth for a dispatch path the generated client does not otherwise
54
+ * let you reach with a foreign URL.
55
+ */
56
+ export declare function bindAuthorityFetch(authority: HooksRemoteAuthority, baseFetch?: FetchLike): FetchLike;
57
+ /**
58
+ * Bind the generated client to an already-resolved authority: every dispatch
59
+ * re-resolves the credential, pins the origin and refuses to follow redirects.
60
+ *
61
+ * Exported for callers that hold an authority (e.g. `HooksClient`'s
62
+ * `#authority`) rather than an env object.
63
+ */
64
+ export declare function hooksRegistryApiClientFor(authority: HooksRemoteAuthority, options?: {
65
+ fetch?: typeof fetch;
66
+ }): ResolvedHooksRegistryApiClient;
67
+ /**
68
+ * Resolve the hosted registry authority (fail closed) and return a generated
69
+ * client bound to it.
70
+ *
71
+ * Throws — never returns a local fallback — when nothing resolves, and refuses
72
+ * the local opt-in by name: there is no SDK surface for the on-box store.
73
+ */
74
+ export declare function createHooksRegistryApiClient(options?: HooksClientOptions): ResolvedHooksRegistryApiClient;
75
+ export {};
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute } from "node:path";
3
+ import { evaluate as evaluateTrash, findTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
+ import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
+ import type { CodewithHookInput } from "./codewith-native-common";
6
+
7
+ // Bundled with import.meta.main=false, so the original standalone run wrappers
8
+ // cannot swallow exceptions or emit a second response. Only this entry runs.
9
+ const name = process.argv[1];
10
+ if (name !== "trash-guard" && name !== "workspace-repos-guard") throw new Error("Invalid safety capability");
11
+ const input = JSON.parse(await Bun.stdin.text()) as CodewithHookInput;
12
+ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string"
13
+ || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
14
+ || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
15
+ if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
16
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
17
+ if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
18
+ const patch = input.tool_input.command ?? input.tool_input.patch;
19
+ if (typeof patch !== "string") throw new Error("Invalid patch input");
20
+ input.tool_input = { ...input.tool_input, command: patch, patch };
21
+ }
22
+ const repos = evaluateRepos(input).output;
23
+ const handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
24
+ const verdict = repos.decision === "block"
25
+ ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } }
26
+ : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } }
27
+ : name === "trash-guard" ? evaluateTrash(input, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary }) : { continue: true };
28
+ process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,8 +25,10 @@
25
25
  "main": "./dist/index.js",
26
26
  "types": "./dist/index.d.ts",
27
27
  "scripts": {
28
- "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/mcp/hooks-mcp.ts --outdir ./bin --target bun --external pg --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/sdk/index.ts --outdir ./dist/sdk --target bun && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
28
+ "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/mcp/hooks-mcp.ts --outdir ./bin --target bun --external pg --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/sdk/index.ts --outdir ./dist/sdk --target bun && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:native-safety && bun run build:types",
29
29
  "build:types": "tsc -p tsconfig.build.json",
30
+ "generate:sdk": "bun run scripts/generate-sdk.ts",
31
+ "verify:generated": "bun run scripts/verify-generated-artifacts.ts",
30
32
  "dev": "bun run ./src/cli/index.tsx",
31
33
  "test": "bun test",
32
34
  "typecheck": "tsc --noEmit",
@@ -34,7 +36,8 @@
34
36
  "prepublishOnly": "bun run validate:package",
35
37
  "postinstall": "node scripts/ensure-profiles-dir.mjs",
36
38
  "scan:artifact": "bun scripts/artifact-scan.ts",
37
- "prepack": "bun run build && bun run scan:artifact"
39
+ "prepack": "bun run build && bun run scan:artifact",
40
+ "build:native-safety": "bun build ./hooks/native-safety-entry.ts --outdir ./bin --target bun --define import.meta.main=false"
38
41
  },
39
42
  "keywords": [
40
43
  "claude-code",
@@ -54,7 +57,7 @@
54
57
  "@types/react": "^18.2.0",
55
58
  "typescript": "^5",
56
59
  "bun-types": "1.3.14",
57
- "@hasna/contracts": "1.2.1"
60
+ "@hasna/contracts": "~1.2.1"
58
61
  },
59
62
  "dependencies": {
60
63
  "@hasna/events": "^0.1.16",
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regenerate the typed @hasna/hooks registry client from the serve OpenAPI
4
+ * document.
5
+ *
6
+ * bun run generate:sdk
7
+ *
8
+ * Output: src/sdk/generated.ts — a dependency-free fetch client plus the
9
+ * interfaces derived from the document, produced by `@hasna/contracts`'
10
+ * `generateSdkFromOpenApi` (the fleet generator: apps/logs, apps/telephony
11
+ * and apps/todos use the same helper). Do not hand-edit the output; change
12
+ * `src/openapi.ts` and rerun this.
13
+ *
14
+ * THE SPEC IS THE SERVED ONE. `buildOpenApiDocument(version)` is the exact
15
+ * object `GET /openapi.json` returns (src/serve.ts), so the committed client
16
+ * and the served contract cannot describe different routes. That is what
17
+ * makes the `serviceSurfaces[3].generatedFrom` declaration in
18
+ * hasna.contract.json (`/openapi.json`) true rather than aspirational:
19
+ * `scripts/verify-generated-artifacts.ts` re-runs this script and
20
+ * byte-compares the result so the claim stays true (AGE11-00571).
21
+ *
22
+ * `@hasna/contracts` is a BUILD-TIME devDependency import; it is never a
23
+ * runtime dependency of the published bundle.
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { generateSdkFromOpenApi } from "@hasna/contracts/sdk";
29
+ import { buildOpenApiDocument } from "../src/openapi.js";
30
+
31
+ const appRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
32
+
33
+ /** The same value src/serve.ts's packageVersion() puts in the served document. */
34
+ function packageVersion(): string {
35
+ const pkgPath = join(appRoot, "package.json");
36
+ if (!existsSync(pkgPath)) throw new Error(`generate-sdk: package.json not found at ${pkgPath}`);
37
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
38
+ if (!parsed.version) throw new Error("generate-sdk: package.json carries no version");
39
+ return parsed.version;
40
+ }
41
+
42
+ const version = packageVersion();
43
+ const generated = generateSdkFromOpenApi(buildOpenApiDocument(version), {
44
+ className: "HooksRegistryApiClient",
45
+ apiKeyHeader: "x-api-key",
46
+ });
47
+
48
+ const outFile = join(appRoot, "src", "sdk", "generated.ts");
49
+ const header = [
50
+ `// @generated from the @hasna/hooks serve OpenAPI document (${version}) by scripts/generate-sdk.ts.`,
51
+ "// DO NOT EDIT. Regenerate: bun run generate:sdk",
52
+ "",
53
+ "",
54
+ ].join("\n");
55
+ writeFileSync(outFile, header + generated.code, "utf8");
56
+
57
+ console.log(
58
+ `generate-sdk: wrote src/sdk/generated.ts — ${generated.operations.length} operations from /openapi.json`,
59
+ );
60
+ for (const warning of generated.warnings) console.warn(`generate-sdk: warning: ${warning}`);
@@ -180,6 +180,8 @@ for (const [name, body] of [
180
180
  mkdirSync(folder, { recursive: true });
181
181
  writeFileSync(join(folder, "credentials"), body, { mode: 0o600 });
182
182
  }
183
+
184
+
183
185
  let value = "fixture-first-key", vaultStatus = 200, reads = 0;
184
186
  const keys = [];
185
187
  globalThis.fetch = async (input, init) => {
@@ -221,6 +223,37 @@ console.log("packed credential-file reference, rotation and terminal failure pas
221
223
  * Lanes: CLI help, serve /health, MCP stdio startup (initialize handshake),
222
224
  * a runtime SDK import, and one bundled-hook run with an isolated data dir.
223
225
  */
226
+ async function validatePackedNativeSafety(packageDir: string, smokeDir: string): Promise<void> {
227
+ const home = join(smokeDir, "native-safety-home");
228
+ await mkdir(home, { mode: 0o700 });
229
+ const settings = join(home, ".codex", "hooks.json");
230
+ const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, HASNA_STATION: "no-such-station", HASNA_HOOKS_CODEX_SETTINGS_PATH: settings };
231
+ for (const key of Object.keys(env)) if (/^(HASNA_HOOKS_|HOOKS_)/.test(key) && key !== "HASNA_HOOKS_CODEX_SETTINGS_PATH") delete env[key];
232
+ const cli = join(packageDir, "bin/index.js");
233
+ const installed = spawnSync(process.execPath, [cli, "safety", "install", "trash-guard", "--target", "codex"], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
234
+ if (installed.status !== 0 || !JSON.parse(installed.stdout).success) throw new Error("Packed native safety registration failed");
235
+ if (existsSync(join(home, ".hasna", "hooks"))) throw new Error("Stateless safety registration created app state");
236
+ const native = JSON.parse(await readFile(settings, "utf8"));
237
+ const command = native.hooks.PreToolUse[0].hooks[0].command;
238
+ const input = (text: string) => JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: home, tool_input: { command: text } });
239
+ const invoke = (text: string) => spawnSync("/bin/sh", ["-c", command], { cwd: home, env, input: input(text), encoding: "utf8", timeout: 9_000 });
240
+ const benign = invoke("pwd");
241
+ if (benign.status !== 0 || benign.stdout !== "" || benign.stderr !== "") throw new Error("Packed native benign verdict failed");
242
+ const refused = invoke(`rm -- ${join(home, "owned-fixture")}`);
243
+ if (refused.status !== 0 || JSON.parse(refused.stdout).hookSpecificOutput.permissionDecision !== "deny") throw new Error("Packed native missing-Trash refusal failed");
244
+ const probe = join(smokeDir, "native-registration.ts");
245
+ await writeFile(probe, `import { getRegisteredHooksForTarget } from "@hasna/hooks"; console.log(JSON.stringify(getRegisteredHooksForTarget("global", "codex")));`);
246
+ const listed = spawnSync(process.execPath, [probe], { cwd: smokeDir, env, encoding: "utf8", timeout: 15_000 });
247
+ if (listed.status !== 0 || JSON.stringify(JSON.parse(listed.stdout)) !== '["trash-guard"]') throw new Error("Packed SDK cannot recognize the compiled CLI safety registration");
248
+ const worker = join(packageDir, "bin/native-safety-entry.js");
249
+ const bytes = await readFile(worker);
250
+ try {
251
+ await writeFile(worker, Buffer.concat([bytes, Buffer.from("\n// changed fixture\n")]));
252
+ const changed = invoke("rm -- owned-fixture");
253
+ if (changed.status !== 2 || changed.stdout !== "" || !changed.stderr.includes("safety guard refused")) throw new Error("Packed native modified-bundle refusal failed");
254
+ } finally { await writeFile(worker, bytes); }
255
+ }
256
+
224
257
  async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
225
258
  const tempRoot = join(root, "temp");
226
259
  await mkdir(tempRoot, { recursive: true });
@@ -352,6 +385,7 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
352
385
  if (typeof sdkOut.count !== "number" || sdkOut.count <= 0) throw new Error(`packed SDK import returned count ${sdkOut.count}`);
353
386
  if (sdkOut.sdk !== "fail-closed" || !sdkOut.hasClient) throw new Error(`packed ./sdk export did not fail closed with nothing configured: ${JSON.stringify(sdkOut)}`);
354
387
  await validatePackedCredentialReferences(pkg, packageDir, smokeDir);
388
+ await validatePackedNativeSafety(packageDir, smokeDir);
355
389
 
356
390
  // 5. One bundled-hook run from the packed artifact (isolated data dir;
357
391
  // first run self-trusts, then executes).