@hasna/hooks 0.9.0 → 0.9.1
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/README.md +33 -5
- package/bin/hooks-mcp.js +4862 -0
- package/bin/index.js +283 -109
- package/bin/serve.js +23 -12
- package/dist/db/index.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2870 -2757
- package/dist/lib/db-writer.d.ts +30 -1
- package/dist/lib/local-opt-in.d.ts +26 -0
- package/dist/lib/sync.d.ts +17 -1
- package/dist/sdk/index.d.ts +103 -0
- package/dist/sdk/index.js +1050 -0
- package/dist/storage.js +15 -1
- package/hooks/hook-trash-guard/README.md +20 -5
- package/hooks/hook-trash-guard/package.json +1 -1
- package/hooks/hook-trash-guard/src/hook.ts +45 -17
- package/package.json +7 -2
- package/scripts/validate-package.ts +31 -4
package/dist/storage.js
CHANGED
|
@@ -458,6 +458,18 @@ function runRetention(db, days) {
|
|
|
458
458
|
import { Database } from "bun:sqlite";
|
|
459
459
|
import { existsSync as existsSync3, mkdirSync, cpSync } from "fs";
|
|
460
460
|
import { join as join3 } from "path";
|
|
461
|
+
function refuseLocalStore(message) {
|
|
462
|
+
localStoreRefusal = message;
|
|
463
|
+
}
|
|
464
|
+
function allowLocalStore() {
|
|
465
|
+
localStoreRefusal = null;
|
|
466
|
+
}
|
|
467
|
+
function isLocalStoreRefused() {
|
|
468
|
+
return localStoreRefusal !== null;
|
|
469
|
+
}
|
|
470
|
+
function localStoreRefusalMessage() {
|
|
471
|
+
return localStoreRefusal;
|
|
472
|
+
}
|
|
461
473
|
function resolveDataDir() {
|
|
462
474
|
const effective = getEffectiveDataRoot();
|
|
463
475
|
const oldDir = join3(getHomeDir(), ".hooks");
|
|
@@ -481,6 +493,8 @@ function ensureDir(dbPath) {
|
|
|
481
493
|
}
|
|
482
494
|
}
|
|
483
495
|
function getDb() {
|
|
496
|
+
if (localStoreRefusal !== null)
|
|
497
|
+
throw new Error(localStoreRefusal);
|
|
484
498
|
if (instance)
|
|
485
499
|
return instance;
|
|
486
500
|
const dbPath = getDbPath();
|
|
@@ -506,7 +520,7 @@ function getDb() {
|
|
|
506
520
|
}
|
|
507
521
|
return instance;
|
|
508
522
|
}
|
|
509
|
-
var instance = null;
|
|
523
|
+
var instance = null, localStoreRefusal = null;
|
|
510
524
|
var init_db = __esm(() => {
|
|
511
525
|
init_app_home();
|
|
512
526
|
init_migrations();
|
|
@@ -94,8 +94,7 @@ advisory warning.
|
|
|
94
94
|
The registration is written with `timeout: 5`. The harness's documented
|
|
95
95
|
default is 600s, and a hook that times out **does not block** — only a verdict
|
|
96
96
|
already on stdout does. Five seconds is several orders of magnitude above this
|
|
97
|
-
hook's measured cost (a
|
|
98
|
-
one `statSync` per PATH entry).
|
|
97
|
+
hook's measured cost (a bounded lexer and a 500 ms identity check against a verified package binary).
|
|
99
98
|
|
|
100
99
|
## Known limitations
|
|
101
100
|
|
|
@@ -111,8 +110,7 @@ The guard is a best-effort **text** classifier, not an execution sandbox.
|
|
|
111
110
|
here.
|
|
112
111
|
- **A hook only ever sees the agent's own tool calls.** It cannot stop a file
|
|
113
112
|
being deleted by another process, by a build tool, by a script the agent
|
|
114
|
-
runs, or by `unlink(2)` called directly.
|
|
115
|
-
nothing else — `Write`/`Edit` pre-image capture is wave 2.
|
|
113
|
+
runs, or by `unlink(2)` called directly. Bash `rm` is rewritten, and native `apply_patch` whole-file deletion is refused with an instruction to call Trash first. Ordinary `Write`/`Edit` pre-image capture is not implemented.
|
|
116
114
|
- **Nested and generated commands escape it.** `bash -c`, `eval`, `make`,
|
|
117
115
|
`npm run`, a `Dockerfile`, a heredoc-fed interpreter: the hook can only see
|
|
118
116
|
that a shell string mentions a delete verb and refuse it, never redirect
|
|
@@ -128,5 +126,22 @@ The guard is a best-effort **text** classifier, not an execution sandbox.
|
|
|
128
126
|
## Configuration
|
|
129
127
|
|
|
130
128
|
None. The home directory comes from `os.homedir()`; the trash binary is
|
|
131
|
-
resolved by scanning `PATH` for
|
|
129
|
+
resolved by scanning `PATH` for a verified `@hasna/trash` package and its `--identity` protocol and rewriting to the
|
|
132
130
|
absolute path found, so the rewritten command does not depend on `PATH` again.
|
|
131
|
+
|
|
132
|
+
## Native Codex and Claude
|
|
133
|
+
|
|
134
|
+
The guard emits their documented `PreToolUse` decision contract, including a
|
|
135
|
+
complete `updatedInput.command`. No-op hooks emit no output. Codex unified exec
|
|
136
|
+
also matches `Bash`; a `Delete File` patch must use `trash put` first. Configure
|
|
137
|
+
Codex in `~/.codex/hooks.json` with matcher
|
|
138
|
+
`^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$` and the command
|
|
139
|
+
`hooks run trash-guard`, with hook timeout 5 seconds. Preserve other registrations
|
|
140
|
+
and refuse a second overlapping input-rewriting hook. Claude registration uses
|
|
141
|
+
`hooks install trash-guard --target claude`. The command rewrite defaults to
|
|
142
|
+
600 seconds for upload and verification, preserving an explicitly supplied timeout.
|
|
143
|
+
|
|
144
|
+
After installation, prove the native harness actually executes the rewritten
|
|
145
|
+
command using a disposable file and a hosted entry/restore receipt; a hook JSON
|
|
146
|
+
response alone does not prove interception. Other harnesses can use the Trash
|
|
147
|
+
CLI/MCP directly; this hook does not claim their native interception.
|
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { homedir } from "os";
|
|
50
|
-
import { isAbsolute, join, normalize, resolve, sep } from "path";
|
|
51
|
-
import { statSync } from "fs";
|
|
50
|
+
import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
|
|
51
|
+
import { readFileSync, realpathSync, statSync } from "fs";
|
|
52
|
+
import { spawnSync } from "node:child_process";
|
|
52
53
|
import {
|
|
53
54
|
SYSTEM_PROTECTED_ROOTS,
|
|
54
55
|
getCommand,
|
|
@@ -63,10 +64,10 @@ const RULE = "trash-guard";
|
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Default Bash-tool timeout (ms) re-supplied on the rewrite. The rewritten
|
|
66
|
-
* command
|
|
67
|
+
* command uploads and verifies the hosted capsule before source cleanup; an explicit
|
|
67
68
|
* value keeps the rewritten tool_input complete.
|
|
68
69
|
*/
|
|
69
|
-
const DEFAULT_TIMEOUT_MS =
|
|
70
|
+
const DEFAULT_TIMEOUT_MS = 600000;
|
|
70
71
|
|
|
71
72
|
/** Description supplied only when the model did not provide one. */
|
|
72
73
|
const DEFAULT_DESCRIPTION = "Delete via trash guard (rm intercepted and made recoverable)";
|
|
@@ -864,22 +865,37 @@ function gitHasDryRun(segment: Segment, verbIndex: number): boolean {
|
|
|
864
865
|
/* ------------------------------------------------------------------ */
|
|
865
866
|
|
|
866
867
|
/**
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
868
|
+
* A command called trash may be Apple's unrelated system utility. Resolve
|
|
869
|
+
* package provenance before executing a bounded, credential-free identity
|
|
870
|
+
* probe. Never invoke an unknown executable merely to discover what it is.
|
|
870
871
|
*/
|
|
871
872
|
export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
872
873
|
const pathValue = env.PATH ?? "";
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
// rewrite must use, so the rewritten command never depends on PATH again.
|
|
874
|
+
const deadline = Date.now() + 2_000;
|
|
875
|
+
for (const dir of pathValue.split(":").slice(0, 64)) {
|
|
876
|
+
if (!isAbsolute(dir) || Date.now() >= deadline) continue;
|
|
877
877
|
const candidate = resolve(dir, "trash");
|
|
878
878
|
try {
|
|
879
879
|
const stat = statSync(candidate);
|
|
880
|
-
if (stat.isFile()
|
|
880
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) continue;
|
|
881
|
+
const executable = realpathSync(candidate);
|
|
882
|
+
const packageRoot = resolve(dirname(executable), "../..");
|
|
883
|
+
const manifestPath = join(packageRoot, "package.json");
|
|
884
|
+
const manifestStat = statSync(manifestPath);
|
|
885
|
+
if (!manifestStat.isFile() || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0) continue;
|
|
886
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
887
|
+
if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
|
|
888
|
+
typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) continue;
|
|
889
|
+
const probe = spawnSync(candidate, ["--identity"], {
|
|
890
|
+
encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
|
|
891
|
+
env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
|
|
892
|
+
});
|
|
893
|
+
if (probe.error || probe.status !== 0) continue;
|
|
894
|
+
const identity = JSON.parse(probe.stdout);
|
|
895
|
+
if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
|
|
896
|
+
identity.guardProtocol === "hasna.trash.guard.v1") return candidate;
|
|
881
897
|
} catch {
|
|
882
|
-
//
|
|
898
|
+
// Missing, unrelated, malformed or unresponsive packages are not targets.
|
|
883
899
|
}
|
|
884
900
|
}
|
|
885
901
|
return null;
|
|
@@ -1004,10 +1020,18 @@ function resolveTrash(deps: GuardDependencies): { path: string | null; error: st
|
|
|
1004
1020
|
}
|
|
1005
1021
|
|
|
1006
1022
|
const ABSENT_REASON =
|
|
1007
|
-
"[trash-guard]
|
|
1023
|
+
"[trash-guard] No verified @hasna/trash guard was found on PATH. This deletion is refused. Install the current @hasna/trash package with Bun and run its setup; the operating-system trash utility is not a compatible guard.";
|
|
1008
1024
|
|
|
1009
1025
|
export function evaluate(input: CodewithHookInput, deps: GuardDependencies): CodewithHookOutput {
|
|
1010
1026
|
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
1027
|
+
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name ?? "")) {
|
|
1028
|
+
const patch = input.tool_input?.command;
|
|
1029
|
+
if (typeof patch !== "string") return deny("[trash-guard] Unreadable patch input; deletion safety cannot be checked.");
|
|
1030
|
+
if (/^\s*\*\*\* Delete File:/m.test(patch)) {
|
|
1031
|
+
return deny("[trash-guard] Delete File would bypass recoverable deletion. First use `trash put -- <path>` or the trash_put MCP tool, then submit any remaining edits without the deletion block.");
|
|
1032
|
+
}
|
|
1033
|
+
return { continue: true };
|
|
1034
|
+
}
|
|
1011
1035
|
if (input.tool_name !== "Bash") return { continue: true };
|
|
1012
1036
|
|
|
1013
1037
|
// A command we cannot READ is a payload we cannot verify, and the harness
|
|
@@ -1089,7 +1113,7 @@ export function evaluate(input: CodewithHookInput, deps: GuardDependencies): Cod
|
|
|
1089
1113
|
|
|
1090
1114
|
/** Verdict used when the hook itself fails: refuse anything that could delete. */
|
|
1091
1115
|
export function fallbackVerdict(command: string): CodewithHookOutput {
|
|
1092
|
-
return mentionsDeleteVerb(command)
|
|
1116
|
+
return mentionsDeleteVerb(command) || /^\s*\*\*\* Delete File:/m.test(command)
|
|
1093
1117
|
? deny(
|
|
1094
1118
|
"[trash-guard] The hook failed while classifying this command, so it cannot be proven free of an unredirected delete. Re-run the delete as a plain `rm -- <path>` command.",
|
|
1095
1119
|
)
|
|
@@ -1101,10 +1125,14 @@ export async function run(): Promise<void> {
|
|
|
1101
1125
|
const command = getCommand(input);
|
|
1102
1126
|
try {
|
|
1103
1127
|
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
1104
|
-
|
|
1128
|
+
const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary });
|
|
1129
|
+
// Native Codex rejects `continue` in PreToolUse JSON. Silence is the
|
|
1130
|
+
// documented no-op for both Codex and Claude; emit only actual decisions.
|
|
1131
|
+
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
1105
1132
|
} catch (error) {
|
|
1106
1133
|
warn(`${RULE} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1107
|
-
|
|
1134
|
+
const verdict = fallbackVerdict(command);
|
|
1135
|
+
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
1108
1136
|
}
|
|
1109
1137
|
}
|
|
1110
1138
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
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": {
|
|
7
7
|
"hooks": "bin/index.js",
|
|
8
|
+
"hooks-mcp": "bin/hooks-mcp.js",
|
|
8
9
|
"hooks-serve": "bin/serve.js"
|
|
9
10
|
},
|
|
10
11
|
"exports": {
|
|
@@ -12,6 +13,10 @@
|
|
|
12
13
|
"types": "./dist/index.d.ts",
|
|
13
14
|
"import": "./dist/index.js"
|
|
14
15
|
},
|
|
16
|
+
"./sdk": {
|
|
17
|
+
"types": "./dist/sdk/index.d.ts",
|
|
18
|
+
"import": "./dist/sdk/index.js"
|
|
19
|
+
},
|
|
15
20
|
"./storage": {
|
|
16
21
|
"types": "./dist/storage.d.ts",
|
|
17
22
|
"import": "./dist/storage.js"
|
|
@@ -20,7 +25,7 @@
|
|
|
20
25
|
"main": "./dist/index.js",
|
|
21
26
|
"types": "./dist/index.d.ts",
|
|
22
27
|
"scripts": {
|
|
23
|
-
"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/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:types",
|
|
24
29
|
"build:types": "tsc -p tsconfig.build.json",
|
|
25
30
|
"dev": "bun run ./src/cli/index.tsx",
|
|
26
31
|
"test": "bun test",
|
|
@@ -173,15 +173,27 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
|
|
|
173
173
|
const dataDir = join(workspace, "data");
|
|
174
174
|
const binIndex = join(packageDir, "bin", "index.js");
|
|
175
175
|
const binServe = join(packageDir, "bin", "serve.js");
|
|
176
|
+
const binMcp = join(packageDir, "bin", "hooks-mcp.js");
|
|
177
|
+
const sdkBundle = join(packageDir, "dist", "sdk", "index.js");
|
|
176
178
|
if (!existsSync(binIndex)) throw new Error(`packed tarball is missing bin/index.js (CLI bin)`);
|
|
177
179
|
if (!existsSync(binServe)) throw new Error(`packed tarball is missing bin/serve.js (serve bin)`);
|
|
180
|
+
if (!existsSync(binMcp)) throw new Error(`packed tarball is missing bin/hooks-mcp.js (MCP bin)`);
|
|
181
|
+
if (!existsSync(sdkBundle)) throw new Error(`packed tarball is missing dist/sdk/index.js (./sdk export)`);
|
|
178
182
|
|
|
183
|
+
// Hermetic route: the smoke lanes below exercise the on-box store on
|
|
184
|
+
// purpose, so they opt in explicitly (hasna/apps#1720 — without the
|
|
185
|
+
// opt-in `hooks run` and `hooks-mcp` fail closed, which the fail-closed
|
|
186
|
+
// lane checks separately) and keep the station's Keychain out of it.
|
|
179
187
|
const env = {
|
|
180
188
|
...process.env,
|
|
189
|
+
HASNA_STATION: "no-such-station",
|
|
190
|
+
HASNA_HOOKS_LOCAL: "1",
|
|
181
191
|
HASNA_HOOKS_DATA_DIR: dataDir,
|
|
182
192
|
HASNA_HOOKS_DB_PATH: join(dataDir, "hooks.db"),
|
|
183
193
|
NO_COLOR: "1",
|
|
184
194
|
};
|
|
195
|
+
const failClosedEnv = { ...env };
|
|
196
|
+
delete (failClosedEnv as Record<string, string | undefined>).HASNA_HOOKS_LOCAL;
|
|
185
197
|
|
|
186
198
|
// 1. CLI help.
|
|
187
199
|
const help = spawnSync("bun", ["run", binIndex, "--help"], { cwd: smokeDir, env, encoding: "utf8" });
|
|
@@ -210,8 +222,22 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
|
|
|
210
222
|
await new Promise((r) => setTimeout(r, 100));
|
|
211
223
|
}
|
|
212
224
|
|
|
213
|
-
//
|
|
214
|
-
|
|
225
|
+
// 2b. Fail-closed lane (hasna/apps#1720): with nothing configured the
|
|
226
|
+
// packed CLI and the packed hooks-mcp bin exit non-zero, name the
|
|
227
|
+
// credential tiers + the opt-in on stderr, and create no local store.
|
|
228
|
+
const failClosedHome = join(workspace, "failclosed-home");
|
|
229
|
+
const fcEnv = { ...failClosedEnv, HOME: failClosedHome, HASNA_HOOKS_DATA_DIR: join(failClosedHome, "data"), HASNA_HOOKS_DB_PATH: join(failClosedHome, "data", "hooks.db") };
|
|
230
|
+
for (const [label, argv] of [["packed CLI `hooks categories`", [binIndex, "categories"]], ["packed hooks-mcp", [binMcp]]] as Array<[string, string[]]>) {
|
|
231
|
+
const fc = spawnSync("bun", ["run", ...argv], { cwd: smokeDir, env: fcEnv, encoding: "utf8", input: "", timeout: 20000 });
|
|
232
|
+
if (fc.status === 0) throw new Error(`${label} exited 0 with nothing configured (must fail closed)`);
|
|
233
|
+
if (!/HASNA_HOOKS_LOCAL=1/.test(fc.stderr) || !/hasna\.credentials\.hooks\.api-key/.test(fc.stderr)) {
|
|
234
|
+
throw new Error(`${label} refusal did not name the tiers + opt-in: ${fc.stderr.slice(0, 300)}`);
|
|
235
|
+
}
|
|
236
|
+
if (existsSync(join(failClosedHome, "data", "hooks.db"))) throw new Error(`${label} created hooks.db while failing closed`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. MCP stdio startup (explicit local opt-in): an initialize handshake must get a response.
|
|
240
|
+
const mcpProc = spawn("bun", ["run", binMcp], {
|
|
215
241
|
cwd: smokeDir,
|
|
216
242
|
env,
|
|
217
243
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -252,11 +278,12 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
|
|
|
252
278
|
// pg link is best-effort; the import below will fail loudly if needed.
|
|
253
279
|
}
|
|
254
280
|
const sdkSmoke = join(smokeDir, "sdk-smoke.ts");
|
|
255
|
-
await writeFile(sdkSmoke, `import { HOOKS, getStorageStatus } from "@hasna/hooks";\nimport { getStorageStatus as ss } from "@hasna/hooks/storage";\nconsole.log(JSON.stringify({ count: HOOKS.length, backend: getStorageStatus().backend, ss: ss().backend }));\n`);
|
|
281
|
+
await writeFile(sdkSmoke, `import { HOOKS, getStorageStatus } from "@hasna/hooks";\nimport { getStorageStatus as ss } from "@hasna/hooks/storage";\nimport { HooksClient, createHooksClient } from "@hasna/hooks/sdk";\nlet sdk = "threw";\ntry { createHooksClient({ env: { HASNA_STATION: "no-such-station" }, credentials: { keychain: { enabled: false } } }); sdk = "returned"; } catch (e) { sdk = /REMOTE_API_/.test(String(e)) ? "fail-closed" : "threw"; }\nconsole.log(JSON.stringify({ count: HOOKS.length, backend: getStorageStatus().backend, ss: ss().backend, sdk, hasClient: typeof HooksClient === "function" }));\n`);
|
|
256
282
|
const sdk = spawnSync("bun", ["run", sdkSmoke], { cwd: smokeDir, env, encoding: "utf8", timeout: 20000 });
|
|
257
283
|
if (sdk.status !== 0) throw new Error(`packed SDK import failed: ${sdk.stderr}`);
|
|
258
|
-
const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string };
|
|
284
|
+
const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string; sdk: string; hasClient: boolean };
|
|
259
285
|
if (typeof sdkOut.count !== "number" || sdkOut.count <= 0) throw new Error(`packed SDK import returned count ${sdkOut.count}`);
|
|
286
|
+
if (sdkOut.sdk !== "fail-closed" || !sdkOut.hasClient) throw new Error(`packed ./sdk export did not fail closed with nothing configured: ${JSON.stringify(sdkOut)}`);
|
|
260
287
|
|
|
261
288
|
// 5. One bundled-hook run from the packed artifact (isolated data dir;
|
|
262
289
|
// first run self-trusts, then executes).
|