@hasna/hooks 0.9.4 → 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 +40 -2
- package/bin/hooks-mcp.js +330 -149
- package/bin/index.js +974 -350
- package/bin/native-safety-entry.js +1136 -0
- package/bin/serve.js +559 -165
- package/dist/index.d.ts +1 -1
- package/dist/index.js +902 -290
- package/dist/lib/native-safety.d.ts +15 -0
- package/dist/lib/resolver-types.d.ts +1 -1
- package/dist/lib/sync.d.ts +3 -2
- package/dist/lib/transport.d.ts +5 -1
- 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 +16 -17
- package/dist/sdk/index.js +447 -47
- package/dist/sdk/registry-client.d.ts +75 -0
- package/hooks/native-safety-entry.ts +28 -0
- package/package.json +8 -5
- package/scripts/generate-sdk.ts +60 -0
- package/scripts/validate-package.ts +104 -2
- package/scripts/verify-generated-artifacts.ts +237 -0
|
@@ -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,11 +57,11 @@
|
|
|
54
57
|
"@types/react": "^18.2.0",
|
|
55
58
|
"typescript": "^5",
|
|
56
59
|
"bun-types": "1.3.14",
|
|
57
|
-
"@hasna/contracts": "1.
|
|
60
|
+
"@hasna/contracts": "~1.2.1"
|
|
58
61
|
},
|
|
59
62
|
"dependencies": {
|
|
60
63
|
"@hasna/events": "^0.1.16",
|
|
61
|
-
"@hasna/secrets": "0.
|
|
64
|
+
"@hasna/secrets": "0.4.2",
|
|
62
65
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
63
66
|
"chalk": "^5.3.0",
|
|
64
67
|
"commander": "^12.1.0",
|
|
@@ -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}`);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
type PackageJson = {
|
|
7
7
|
name: string;
|
|
8
8
|
types?: string;
|
|
9
9
|
exports?: Record<string, unknown>;
|
|
10
|
+
version?: string;
|
|
11
|
+
dependencies?: Record<string, string>;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
type PackEntry = {
|
|
@@ -142,7 +144,74 @@ async function main(): Promise<void> {
|
|
|
142
144
|
await validateConsumerImports(pkg);
|
|
143
145
|
await validateExtractedSmoke(pkg);
|
|
144
146
|
|
|
145
|
-
console.log("Package validation passed: packed declarations are present, Bundler/NodeNext TypeScript consumer imports resolve, and the extracted tarball smoke-tests CLI help, serve /health, MCP startup, SDK import and one bundled-hook run (P3-17).");
|
|
147
|
+
console.log("Package validation passed: packed declarations are present, Bundler/NodeNext TypeScript consumer imports resolve, and the extracted tarball smoke-tests CLI help, serve /health, MCP startup, SDK import, credential-file vault references and one bundled-hook run (P3-17).");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function validatePackedCredentialReferences(pkg: PackageJson, packageDir: string, smokeDir: string): Promise<void> {
|
|
151
|
+
// Link the consumer's declared installed dependency, never the workspace's
|
|
152
|
+
// unrelated Secrets source. Bundling changes where the shared resolver finds it.
|
|
153
|
+
let parent = root;
|
|
154
|
+
let secretsDir: string | undefined;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const candidate = join(parent, "node_modules", "@hasna", "secrets");
|
|
157
|
+
if (existsSync(candidate)) { secretsDir = await realpath(candidate); break; }
|
|
158
|
+
if (dirname(parent) === parent) throw new Error("Hooks Secrets dependency is not installed");
|
|
159
|
+
parent = dirname(parent);
|
|
160
|
+
}
|
|
161
|
+
const secretsPkg = await readPackageJson(join(secretsDir, "package.json"));
|
|
162
|
+
if (secretsPkg.name !== "@hasna/secrets" || secretsPkg.version !== pkg.dependencies?.["@hasna/secrets"]) {
|
|
163
|
+
throw new Error("Packed reference validation requires the exact declared Secrets dependency");
|
|
164
|
+
}
|
|
165
|
+
const peerDir = join(packageDir, "node_modules", "@hasna");
|
|
166
|
+
await mkdir(peerDir, { recursive: true });
|
|
167
|
+
await symlink(secretsDir, join(peerDir, "secrets"), "dir");
|
|
168
|
+
const fixture = join(smokeDir, "credential-reference-smoke.ts");
|
|
169
|
+
await writeFile(fixture, `
|
|
170
|
+
import { strict as assert } from "node:assert";
|
|
171
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
172
|
+
import { join } from "node:path";
|
|
173
|
+
import { createHooksClient } from "@hasna/hooks/sdk";
|
|
174
|
+
const home = join(import.meta.dir, "reference-home");
|
|
175
|
+
for (const [name, body] of [
|
|
176
|
+
["hooks", "HASNA_HOOKS_API_URL=https://hooks.example.test\\nHASNA_HOOKS_API_KEY_REF=fixture/hooks/key\\n"],
|
|
177
|
+
["secrets", "HASNA_SECRETS_API_URL=https://secrets.example.test\\nHASNA_SECRETS_API_KEY=fixture-bootstrap-key\\n"],
|
|
178
|
+
]) {
|
|
179
|
+
const folder = join(home, ".hasna", name, "config");
|
|
180
|
+
mkdirSync(folder, { recursive: true });
|
|
181
|
+
writeFileSync(join(folder, "credentials"), body, { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
let value = "fixture-first-key", vaultStatus = 200, reads = 0;
|
|
186
|
+
const keys = [];
|
|
187
|
+
globalThis.fetch = async (input, init) => {
|
|
188
|
+
const url = new URL(String(input)), headers = new Headers(init?.headers);
|
|
189
|
+
if (url.origin === "https://secrets.example.test") {
|
|
190
|
+
assert.equal(url.pathname, "/v1/secrets/get");
|
|
191
|
+
assert.equal(url.searchParams.get("key"), "fixture/hooks/key");
|
|
192
|
+
assert.equal(headers.get("x-api-key"), "fixture-bootstrap-key");
|
|
193
|
+
reads++;
|
|
194
|
+
return Response.json(vaultStatus === 200 ? { value } : { error: "fixture denial" }, { status: vaultStatus });
|
|
195
|
+
}
|
|
196
|
+
assert.equal(url.origin, "https://hooks.example.test");
|
|
197
|
+
assert.equal(init.redirect, "manual");
|
|
198
|
+
keys.push(headers.get("x-api-key"));
|
|
199
|
+
return Response.json({ hooks: [] });
|
|
200
|
+
};
|
|
201
|
+
const client = createHooksClient({ env: { HOME: home }, credentials: { keychain: { enabled: false } } });
|
|
202
|
+
await client.catalog();
|
|
203
|
+
value = "fixture-rotated-key";
|
|
204
|
+
await client.catalog();
|
|
205
|
+
vaultStatus = 401;
|
|
206
|
+
await assert.rejects(client.catalog(), /vault.*unavailable/);
|
|
207
|
+
assert.deepEqual(keys, ["fixture-first-key", "fixture-rotated-key"]);
|
|
208
|
+
assert.equal(reads, 3);
|
|
209
|
+
console.log("packed credential-file reference, rotation and terminal failure passed");
|
|
210
|
+
`);
|
|
211
|
+
const result = spawnSync("bun", ["run", fixture], {
|
|
212
|
+
cwd: smokeDir, env: { PATH: process.env.PATH }, encoding: "utf8", timeout: 20000,
|
|
213
|
+
});
|
|
214
|
+
if (result.status !== 0) throw new Error(`Packed credential reference failed: ${result.stderr}`);
|
|
146
215
|
}
|
|
147
216
|
|
|
148
217
|
/**
|
|
@@ -154,6 +223,37 @@ async function main(): Promise<void> {
|
|
|
154
223
|
* Lanes: CLI help, serve /health, MCP stdio startup (initialize handshake),
|
|
155
224
|
* a runtime SDK import, and one bundled-hook run with an isolated data dir.
|
|
156
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
|
+
|
|
157
257
|
async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
|
|
158
258
|
const tempRoot = join(root, "temp");
|
|
159
259
|
await mkdir(tempRoot, { recursive: true });
|
|
@@ -284,6 +384,8 @@ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
|
|
|
284
384
|
const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string; sdk: string; hasClient: boolean };
|
|
285
385
|
if (typeof sdkOut.count !== "number" || sdkOut.count <= 0) throw new Error(`packed SDK import returned count ${sdkOut.count}`);
|
|
286
386
|
if (sdkOut.sdk !== "fail-closed" || !sdkOut.hasClient) throw new Error(`packed ./sdk export did not fail closed with nothing configured: ${JSON.stringify(sdkOut)}`);
|
|
387
|
+
await validatePackedCredentialReferences(pkg, packageDir, smokeDir);
|
|
388
|
+
await validatePackedNativeSafety(packageDir, smokeDir);
|
|
287
389
|
|
|
288
390
|
// 5. One bundled-hook run from the packed artifact (isolated data dir;
|
|
289
391
|
// first run self-trusts, then executes).
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Verify that the committed generated registry client is byte-identical to what
|
|
4
|
+
* the current source produces — and that it is a real client, not an empty file
|
|
5
|
+
* that trivially matches itself.
|
|
6
|
+
*
|
|
7
|
+
* `src/sdk/generated.ts` is committed because `package.json`'s build bundles it
|
|
8
|
+
* into the published `./sdk` export, so a drifted file ships. The repo rule is
|
|
9
|
+
* explicit (".github/workflows/ci.yml", `verify-generated` job): a member that
|
|
10
|
+
* commits generated bundles adds its own step to that job, sharing the job's
|
|
11
|
+
* bun-version pin — "a committed bundle no job regenerates is drift by
|
|
12
|
+
* construction". This is that step for hooks.
|
|
13
|
+
*
|
|
14
|
+
* THE RULES THIS HOLDS TO (mirrors apps/telephony and apps/knowledge):
|
|
15
|
+
*
|
|
16
|
+
* 1. ONE ENTRY POINT. The regeneration happens inside this script via
|
|
17
|
+
* `package.json`'s `generate:sdk`, so it cannot be run without the
|
|
18
|
+
* regeneration and read as a sync check, and the two can never disagree.
|
|
19
|
+
*
|
|
20
|
+
* 2. THE REGENERATION MUST BE BYTE-STABLE, NOT ONLY MATCH THE INDEX. The
|
|
21
|
+
* generator runs TWICE; the two outputs must be byte-identical before the
|
|
22
|
+
* index comparison means anything. A nondeterministic generator would make
|
|
23
|
+
* the byte gate pass on one run and fail on the next with no commit in
|
|
24
|
+
* between — the vacuous-check class wearing a green gate.
|
|
25
|
+
*
|
|
26
|
+
* 3. THE CHECK PROVES THE ARTIFACT IS REAL BEFORE TRUSTING A CLEAN RESULT. A
|
|
27
|
+
* generator that emitted nothing would be byte-stable and match its own
|
|
28
|
+
* empty index forever. The artifact must carry the client class and one
|
|
29
|
+
* method per operation in the SERVED document — the exact binding
|
|
30
|
+
* `hasna.contract.json`'s `serviceSurfaces[3].generatedFrom` claims.
|
|
31
|
+
*
|
|
32
|
+
* 4. THE PRECONDITION IS CHECKED. `git diff` after a regeneration only means
|
|
33
|
+
* something if the file matched the index BEFORE it; a file that was
|
|
34
|
+
* already modified proves nothing afterwards.
|
|
35
|
+
*/
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { spawnSync } from "node:child_process";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { dirname, join, resolve } from "node:path";
|
|
41
|
+
import { generateSdkFromOpenApi } from "@hasna/contracts/sdk";
|
|
42
|
+
import { buildOpenApiDocument } from "../src/openapi.ts";
|
|
43
|
+
|
|
44
|
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
45
|
+
|
|
46
|
+
/** The one committed-and-shipped file `generate:sdk` rewrites. */
|
|
47
|
+
export const GENERATED_FILE = "src/sdk/generated.ts";
|
|
48
|
+
|
|
49
|
+
/** The class the manifest's `./sdk` surface advertises as generated. */
|
|
50
|
+
export const GENERATED_CLASS = "HooksRegistryApiClient";
|
|
51
|
+
|
|
52
|
+
function fail(message: string): never {
|
|
53
|
+
console.error(`verify-generated-artifacts: ${message}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function git(args: string[]): { status: number; stdout: string; stderr: string } {
|
|
58
|
+
const run = spawnSync("git", args, { cwd: appRoot, encoding: "utf8" });
|
|
59
|
+
// A spawn that never started reports status null. Treat that as failure: this
|
|
60
|
+
// whole script is a control, and a control that cannot run must not pass.
|
|
61
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sha256Of(relativePath: string): string {
|
|
65
|
+
return createHash("sha256").update(readFileSync(join(appRoot, relativePath))).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ *
|
|
69
|
+
* Rule 3: prove the committed artifact is a real generated client for *
|
|
70
|
+
* the served document — before any clean result is believed. *
|
|
71
|
+
* ------------------------------------------------------------------ */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The operations the SERVED document declares. Derived from
|
|
75
|
+
* `buildOpenApiDocument`, the same builder src/serve.ts answers
|
|
76
|
+
* `GET /openapi.json` with, so this is the binding the contract claims.
|
|
77
|
+
*/
|
|
78
|
+
export function servedOperations(): string[] {
|
|
79
|
+
const spec = buildOpenApiDocument("0.0.0") as {
|
|
80
|
+
paths?: Record<string, Record<string, unknown>>;
|
|
81
|
+
};
|
|
82
|
+
const methods = ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
83
|
+
const found: string[] = [];
|
|
84
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
85
|
+
for (const method of methods) {
|
|
86
|
+
if (item[method]) found.push(`${method.toUpperCase()} ${path}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return found.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Problems with the committed artifact, or [] when it looks generated. */
|
|
93
|
+
export function artifactProblems(text: string): string[] {
|
|
94
|
+
const problems: string[] = [];
|
|
95
|
+
if (!text.includes("// @generated")) {
|
|
96
|
+
problems.push(`${GENERATED_FILE} carries no "@generated" header — it is not generator output`);
|
|
97
|
+
}
|
|
98
|
+
if (!text.includes(`export class ${GENERATED_CLASS}`)) {
|
|
99
|
+
problems.push(`${GENERATED_FILE} does not declare "export class ${GENERATED_CLASS}"`);
|
|
100
|
+
}
|
|
101
|
+
return problems;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The operations the committed artifact exposes, as `METHOD path` pairs. Read
|
|
106
|
+
* from the generated `this.request("METHOD", \`path\`)` call sites so this is
|
|
107
|
+
* about what was actually emitted, not about what the spec says. Emitted path
|
|
108
|
+
* templates interpolate path params (`\${encodeURIComponent(String(name))}`);
|
|
109
|
+
* they are normalized back to the OpenAPI `{name}` form so the comparison is
|
|
110
|
+
* against the served document's spelling.
|
|
111
|
+
*/
|
|
112
|
+
export function artifactOperations(text: string): string[] {
|
|
113
|
+
const found: string[] = [];
|
|
114
|
+
const re = /this\.request\(\s*"([A-Z]+)",\s*`([^`]+)`/g;
|
|
115
|
+
for (const match of text.matchAll(re)) {
|
|
116
|
+
found.push(`${match[1]} ${normalizePathTemplate(match[2]!)}`);
|
|
117
|
+
}
|
|
118
|
+
return found.sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `\${encodeURIComponent(String(name))}` -> `{name}`. */
|
|
122
|
+
export function normalizePathTemplate(path: string): string {
|
|
123
|
+
return path.replace(
|
|
124
|
+
/\$\{encodeURIComponent\(String\(([A-Za-z_$][A-Za-z0-9_$]*)\)\)\}/g,
|
|
125
|
+
"{$1}",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A served-operation extractor that can no longer find call sites reports "all
|
|
131
|
+
* covered" forever. These fixtures prove the extraction still fires, and still
|
|
132
|
+
* normalizes, before a clean result is believed. The fixture is the exact
|
|
133
|
+
* emitted shape for the parameterized artifact route.
|
|
134
|
+
*/
|
|
135
|
+
export const EXTRACTION_FIXTURE =
|
|
136
|
+
'return this.request("GET", `/api/v1/hooks/${encodeURIComponent(String(name))}/${encodeURIComponent(String(version))}`, {';
|
|
137
|
+
export const EXTRACTION_FIXTURE_EXPECTED = "GET /api/v1/hooks/{name}/{version}";
|
|
138
|
+
// Must NOT match: a bare path with no request call site is not an operation.
|
|
139
|
+
export const EXTRACTION_COUNTER_FIXTURE = 'const p = "/api/v1/hooks/{name}/{version}";';
|
|
140
|
+
|
|
141
|
+
/** Problems with the extraction pattern itself; [] means it is worth believing. */
|
|
142
|
+
export function patternSelfCheck(): string[] {
|
|
143
|
+
const problems: string[] = [];
|
|
144
|
+
const extracted = artifactOperations(EXTRACTION_FIXTURE);
|
|
145
|
+
if (extracted.length !== 1 || extracted[0] !== EXTRACTION_FIXTURE_EXPECTED) {
|
|
146
|
+
problems.push(
|
|
147
|
+
`the operation extractor no longer reads its own fixture (got ${JSON.stringify(extracted)} for ${EXTRACTION_FIXTURE_EXPECTED}) — the coverage check below cannot detect anything`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (artifactOperations(EXTRACTION_COUNTER_FIXTURE).length !== 0) {
|
|
151
|
+
problems.push("the operation extractor matches its counter-fixture — it is too loose to be meaningful");
|
|
152
|
+
}
|
|
153
|
+
return problems;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The served operations minus the ones the artifact emitted. Empty means the
|
|
158
|
+
* committed client covers the served document exactly.
|
|
159
|
+
*/
|
|
160
|
+
export function missingOperations(text: string): string[] {
|
|
161
|
+
const emitted = new Set(artifactOperations(text));
|
|
162
|
+
return servedOperations().filter((op) => !emitted.has(op));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main(): void {
|
|
166
|
+
const committed = readFileSync(join(appRoot, GENERATED_FILE), "utf8");
|
|
167
|
+
|
|
168
|
+
// Rule 3 first: a clean regeneration of a broken artifact must not be read
|
|
169
|
+
// as a pass, and a dead extractor must not be read as full coverage.
|
|
170
|
+
const patternProblems = patternSelfCheck();
|
|
171
|
+
const problems = [...patternProblems, ...artifactProblems(committed)];
|
|
172
|
+
const missing = missingOperations(committed);
|
|
173
|
+
if (missing.length > 0) {
|
|
174
|
+
problems.push(
|
|
175
|
+
`${GENERATED_FILE} is missing ${missing.length} operation(s) the served /openapi.json declares: ${missing.join(", ")}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (problems.length > 0) {
|
|
179
|
+
for (const problem of problems) console.error(`verify-generated-artifacts: ${problem}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Rule 4: the precondition for the diff gate below.
|
|
184
|
+
const dirtyBefore = git(["status", "--porcelain", "--", GENERATED_FILE]);
|
|
185
|
+
if (dirtyBefore.status !== 0) fail(`git status failed: ${dirtyBefore.stderr.trim()}`);
|
|
186
|
+
if (dirtyBefore.stdout.trim() !== "") {
|
|
187
|
+
fail(
|
|
188
|
+
`${GENERATED_FILE} is already modified before the regeneration, so this check cannot tell drift from your edits:\n` +
|
|
189
|
+
`${dirtyBefore.stdout.trimEnd()}\nCommit or restore it, then re-run.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rule 1: regenerate through the package script, never a repeated command.
|
|
194
|
+
const generate = (pass: number): void => {
|
|
195
|
+
const run = spawnSync("bun", ["run", "generate:sdk"], { cwd: appRoot, stdio: "inherit" });
|
|
196
|
+
if ((run.status ?? 1) !== 0) {
|
|
197
|
+
fail(`\`bun run generate:sdk\` (pass ${pass}) exited ${run.status ?? "without a status"}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Rule 2: two regenerations of the same source must be byte-identical.
|
|
202
|
+
generate(1);
|
|
203
|
+
const first = sha256Of(GENERATED_FILE);
|
|
204
|
+
generate(2);
|
|
205
|
+
const second = sha256Of(GENERATED_FILE);
|
|
206
|
+
if (first !== second) {
|
|
207
|
+
fail(
|
|
208
|
+
`regeneration is NOT byte-stable: two consecutive \`bun run generate:sdk\` runs of the same source produced different bytes for ${GENERATED_FILE}.\n` +
|
|
209
|
+
`This bun is ${process.versions?.bun ?? "unknown"}. A nondeterministic generator makes the byte gate vacuous — fix the generator (or the bun version) rather than committing either output.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The gate: the regeneration must equal what is committed.
|
|
214
|
+
const drift = git(["diff", "--exit-code", "--", GENERATED_FILE]);
|
|
215
|
+
if (drift.status !== 0) {
|
|
216
|
+
console.error(
|
|
217
|
+
`verify-generated-artifacts: the committed registry client is not what the current source generates.\n` +
|
|
218
|
+
`Run \`bun run generate:sdk\` and commit the result. ${GENERATED_FILE} is bundled into the published\n` +
|
|
219
|
+
`\`./sdk\` export, so an uncommitted regeneration ships a client that disagrees with src/openapi.ts.`,
|
|
220
|
+
);
|
|
221
|
+
process.exit(drift.status ?? 1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Post-condition: the artifact survived the regeneration with its binding intact.
|
|
225
|
+
const finalText = readFileSync(join(appRoot, GENERATED_FILE), "utf8");
|
|
226
|
+
const finalProblems = [...artifactProblems(finalText), ...missingOperations(finalText).map((op) => `missing ${op}`)];
|
|
227
|
+
if (finalProblems.length > 0) {
|
|
228
|
+
fail(`regeneration rewrote ${GENERATED_FILE} but it is not usable: ${finalProblems.join("; ")}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log(
|
|
232
|
+
`verify-generated-artifacts: two consecutive regenerations of ${GENERATED_FILE} are byte-identical to each other and to the committed output; all ${servedOperations().length} operations in the served /openapi.json are covered.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Run only when invoked directly, so the exports above are importable from tests.
|
|
237
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) main();
|