@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/README.md +27 -0
- package/bin/hooks-mcp.js +282 -123
- package/bin/index.js +625 -252
- package/bin/native-safety-entry.js +1136 -0
- package/bin/serve.js +329 -127
- package/dist/index.js +610 -249
- package/dist/lib/native-safety.d.ts +15 -0
- package/dist/openapi.d.ts +314 -45
- package/dist/sdk/authority.d.ts +31 -0
- package/dist/sdk/generated.d.ts +97 -0
- package/dist/sdk/index.d.ts +14 -15
- package/dist/sdk/index.js +172 -1
- package/dist/sdk/registry-client.d.ts +75 -0
- package/hooks/native-safety-entry.ts +28 -0
- package/package.json +7 -4
- package/scripts/generate-sdk.ts +60 -0
- package/scripts/validate-package.ts +34 -0
- package/scripts/verify-generated-artifacts.ts +237 -0
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/
|
|
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.
|
|
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).
|