@hasna/hooks 0.6.4 → 0.6.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 +1 -1
- package/bin/index.js +619 -174
- package/bin/serve.js +115 -35
- package/dist/config.d.ts +8 -1
- package/dist/db/remote-storage.d.ts +22 -0
- package/dist/index.js +442 -121
- package/dist/lib/hook-env.d.ts +42 -0
- package/dist/lib/manifest.d.ts +24 -2
- package/dist/lib/redact.d.ts +40 -0
- package/dist/lib/semver.d.ts +19 -0
- package/dist/lib/store.d.ts +16 -0
- package/dist/lib/sync.d.ts +55 -4
- package/dist/serve.d.ts +2 -1
- package/dist/storage.js +104 -7
- package/package.json +2 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook child-process environment — a fixed allowlist plus a name-based deny
|
|
3
|
+
* list (P1-1 env isolation).
|
|
4
|
+
*
|
|
5
|
+
* A hook executes third-party bytes inside the agent's session. Passing the
|
|
6
|
+
* parent's process.env wholesale hands every credential the agent can reach
|
|
7
|
+
* to arbitrary code. The child gets:
|
|
8
|
+
*
|
|
9
|
+
* 1. a fixed allowlist of non-secret session variables (PATH, HOME, LANG,
|
|
10
|
+
* TZ, SHELL, TERM, USER, PWD);
|
|
11
|
+
* 2. non-secret HOOKS_* projections of the parent's HASNA_HOOKS_* config
|
|
12
|
+
* (data dir, DB path, lock path, config path) so in-process DB/config
|
|
13
|
+
* resolution inside the hook still finds the store the parent configured;
|
|
14
|
+
* 3. caller-supplied extras, filtered through the same deny list.
|
|
15
|
+
*
|
|
16
|
+
* Denial is NAME-BASED by design. A value-shape test is explicitly rejected
|
|
17
|
+
* as an acceptability test: a value test would have to miss something for a
|
|
18
|
+
* credential to leak, so the deny list is the documented prefix/suffix set
|
|
19
|
+
* below. The deny list strips:
|
|
20
|
+
*
|
|
21
|
+
* suffix: KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, CREDENTIALS,
|
|
22
|
+
* URL, URI (a URL/URI-bearing variable can embed credentials)
|
|
23
|
+
* prefix: HASNA_, AWS_, AZURE_, GCP_, VAULT_, GOOGLE_, OPENAI_,
|
|
24
|
+
* ANTHROPIC_, POSTGRES_, MYSQL_, REDIS_, MONGO_, MEMENTOS_
|
|
25
|
+
* contains: DATABASE_URL (any variant)
|
|
26
|
+
*
|
|
27
|
+
* Names that merely resemble credentials (e.g. HOOKS_DATA_DIR) survive; a
|
|
28
|
+
* path is not a credential.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* True when a variable NAME is credential-bearing and must never reach a
|
|
32
|
+
* hook child process. Case-insensitive.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isDeniedEnvName(name: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Build the sanitized environment for a hook child process.
|
|
37
|
+
*
|
|
38
|
+
* `source` is the parent's environment; `extra` is any caller-supplied env
|
|
39
|
+
* the hook may need. The deny list applies to BOTH — a caller cannot
|
|
40
|
+
* reintroduce a credential-bearing name.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildHookEnv(source: Record<string, string | undefined>, extra?: Record<string, string | undefined>): Record<string, string>;
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export declare const manifestSchema: z.ZodObject<{
|
|
|
9
9
|
description: z.ZodOptional<z.ZodString>;
|
|
10
10
|
events: z.ZodArray<z.ZodString, "many">;
|
|
11
11
|
script: z.ZodString;
|
|
12
|
+
/**
|
|
13
|
+
* Explicit script-kind discriminator (P2-14): "inline" means the script
|
|
14
|
+
* value IS the hook body; "file" means it names a relative path. When
|
|
15
|
+
* absent, the legacy newline heuristic applies (documented fallback for
|
|
16
|
+
* older manifests — a value with a newline is inline, otherwise a path).
|
|
17
|
+
*/
|
|
18
|
+
script_kind: z.ZodOptional<z.ZodEnum<["inline", "file"]>>;
|
|
12
19
|
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
13
20
|
timeout_ms: z.ZodOptional<z.ZodNumber>;
|
|
14
21
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -17,6 +24,7 @@ export declare const manifestSchema: z.ZodObject<{
|
|
|
17
24
|
name: string;
|
|
18
25
|
script: string;
|
|
19
26
|
description?: string | undefined;
|
|
27
|
+
script_kind?: "inline" | "file" | undefined;
|
|
20
28
|
args?: string[] | undefined;
|
|
21
29
|
timeout_ms?: number | undefined;
|
|
22
30
|
}, {
|
|
@@ -25,6 +33,7 @@ export declare const manifestSchema: z.ZodObject<{
|
|
|
25
33
|
name: string;
|
|
26
34
|
script: string;
|
|
27
35
|
description?: string | undefined;
|
|
36
|
+
script_kind?: "inline" | "file" | undefined;
|
|
28
37
|
args?: string[] | undefined;
|
|
29
38
|
timeout_ms?: number | undefined;
|
|
30
39
|
}>;
|
|
@@ -52,10 +61,23 @@ export declare class ScriptContainmentError extends Error {
|
|
|
52
61
|
* root, then call assertContainedReal.
|
|
53
62
|
*/
|
|
54
63
|
export declare function assertContained(target: string, root: string, raw?: string): string;
|
|
64
|
+
export declare function isInlineScript(manifest: HookManifest): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a manifest's script to its RELATIVE target path under the hook
|
|
67
|
+
* directory. Shared by every install path (P2-14 / P1-2 round 2): the explicit
|
|
68
|
+
* script_kind discriminator wins; without it the legacy heuristic applies (a
|
|
69
|
+
* value containing a newline is inline content, otherwise a relative path).
|
|
70
|
+
* This is the ONE decision point — registry sync, exact-pin fetch and custom
|
|
71
|
+
* installs all call it, so a one-line inline manifest can never be mistaken
|
|
72
|
+
* for a path again.
|
|
73
|
+
*/
|
|
74
|
+
export declare function scriptRelFor(manifest: HookManifest): string;
|
|
55
75
|
/**
|
|
56
76
|
* Resolve a manifest's script to (relative path, content).
|
|
57
|
-
*
|
|
58
|
-
*
|
|
77
|
+
* The explicit script_kind discriminator wins (P2-14); without it the
|
|
78
|
+
* legacy heuristic applies: a script value containing a newline is inline
|
|
79
|
+
* content, anything else is a relative path resolved against the manifest
|
|
80
|
+
* directory.
|
|
59
81
|
*/
|
|
60
82
|
export declare function resolveScript(manifest: HookManifest, manifestDir: string): {
|
|
61
83
|
path: string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-safe redaction for hook event rows (P1-3).
|
|
3
|
+
*
|
|
4
|
+
* tool_input / error / metadata were stored verbatim (truncated only) and
|
|
5
|
+
* returned by the MCP log tools and `hooks log`. A tool call that carries a
|
|
6
|
+
* credential (a Bash command embedding a token, a tool_input object with an
|
|
7
|
+
* api_key field, an error echoing a secret) persisted it to disk and served
|
|
8
|
+
* it back.
|
|
9
|
+
*
|
|
10
|
+
* The projection is applied:
|
|
11
|
+
* - at WRITE time, in db-writer.writeHookEvent, so nothing sensitive is
|
|
12
|
+
* persisted locally or pushed to the remote sync store;
|
|
13
|
+
* - at READ time, on every MCP log response and `hooks log` output, so
|
|
14
|
+
* rows stored by older versions (which wrote verbatim) are redacted
|
|
15
|
+
* before they reach a reader (truncate-on-read projection; documented
|
|
16
|
+
* retention note in CHANGELOG.md — no destructive backfill command).
|
|
17
|
+
*
|
|
18
|
+
* Two mechanisms:
|
|
19
|
+
* 1. JSON-aware: when the payload parses, values under secret-typed keys
|
|
20
|
+
* are replaced with [REDACTED] (covers tool_input objects and metadata).
|
|
21
|
+
* 2. Shape-based: known credential shapes anywhere in the text are
|
|
22
|
+
* replaced (covers inline tokens in command strings and error text).
|
|
23
|
+
*
|
|
24
|
+
* Redaction is intentionally conservative: these fields are internal
|
|
25
|
+
* observability data, so over-redaction costs little and under-redaction
|
|
26
|
+
* costs a leaked credential.
|
|
27
|
+
*/
|
|
28
|
+
export declare function redactText(value: string): string;
|
|
29
|
+
export declare function redactValue(value: unknown): unknown;
|
|
30
|
+
/**
|
|
31
|
+
* Project one event payload (tool_input / error / metadata text) to its
|
|
32
|
+
* event-safe form. Null passthrough, JSON-aware, always shape-scrubbed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function redactEventPayload(text: string | null | undefined): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Read projection for a full hook_events row: the fixed-field allowlist is
|
|
37
|
+
* the row schema; tool_input, error and metadata pass through
|
|
38
|
+
* redactEventPayload, everything else is returned unchanged.
|
|
39
|
+
*/
|
|
40
|
+
export declare function projectEventRowForRead(row: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared semver validation (P2-10).
|
|
3
|
+
*
|
|
4
|
+
* Every surface that accepts a hook version — manifest validation, publish,
|
|
5
|
+
* the serve and worker artifact routes — must accept and reject the SAME
|
|
6
|
+
* set. Previously the manifest accepted "1.2.3-beta" (unanchored regex)
|
|
7
|
+
* while the artifact routes demanded a bare `\d+\.\d+\.\d+` at the end of
|
|
8
|
+
* the path, so a pinned prerelease install 404'd. This is the one pattern:
|
|
9
|
+
* full semver with optional prerelease and build metadata.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SEMVER_PATTERN: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* A version segment used inside a URL path. Prerelease and build metadata
|
|
14
|
+
* can contain '+' (build metadata) which must be percent-encoded in URLs;
|
|
15
|
+
* the route regex accepts the encoded and decoded forms.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SEMVER_PATH_SEGMENT = "[0-9A-Za-z.+_-]+";
|
|
18
|
+
export declare function isValidSemver(value: string): boolean;
|
|
19
|
+
export declare function semverError(value: string): string;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export interface LockEntry {
|
|
|
21
21
|
version: string;
|
|
22
22
|
sha256: string;
|
|
23
23
|
source: string;
|
|
24
|
+
/**
|
|
25
|
+
* True when the pin came from an explicit `hooks install/update
|
|
26
|
+
* <name>@<version>` (P2-9): an explicit older pin is preserved across
|
|
27
|
+
* syncs instead of being silently bumped to the latest. Absent/undefined
|
|
28
|
+
* pins are ordinary sync-maintained pins and follow the latest.
|
|
29
|
+
*/
|
|
30
|
+
pinned?: boolean;
|
|
24
31
|
}
|
|
25
32
|
export interface LockFile {
|
|
26
33
|
hooks: Record<string, LockEntry>;
|
|
@@ -39,6 +46,15 @@ export declare function upsertHookRecord(db: Database, record: {
|
|
|
39
46
|
last_verified_at?: string | null;
|
|
40
47
|
}): void;
|
|
41
48
|
export declare function removeHookRecord(db: Database, name: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Raised when hooks.lock is present but malformed. P1-9: a malformed lock
|
|
51
|
+
* used to degrade to {hooks:{}} — a fail-open that would let the next sync
|
|
52
|
+
* re-trust hooks as if nothing had been pinned. It now fails hard with a
|
|
53
|
+
* repair message; no execution path treats a broken lock as an empty store.
|
|
54
|
+
*/
|
|
55
|
+
export declare class LockFileError extends Error {
|
|
56
|
+
constructor(message: string);
|
|
57
|
+
}
|
|
42
58
|
export declare function readLock(): LockFile;
|
|
43
59
|
export declare function writeLock(lock: LockFile): string;
|
|
44
60
|
export declare function setPinnedHook(name: string, entry: LockEntry): string;
|
package/dist/lib/sync.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* failure mid-sync leaves the local store untouched. Local-only hooks are
|
|
7
7
|
* never deleted by a remote sync.
|
|
8
8
|
*/
|
|
9
|
+
import { parseManifest } from "./manifest.js";
|
|
9
10
|
export interface SyncDiff {
|
|
10
11
|
added: string[];
|
|
11
12
|
updated: string[];
|
|
@@ -24,15 +25,62 @@ export interface ArtifactResponse {
|
|
|
24
25
|
description?: string;
|
|
25
26
|
events: string[];
|
|
26
27
|
script: string;
|
|
28
|
+
script_kind?: "inline" | "file";
|
|
27
29
|
args?: string[];
|
|
28
30
|
timeout_ms?: number;
|
|
29
31
|
};
|
|
30
32
|
script: string;
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
interface RemoteLock {
|
|
35
|
+
hooks: Record<string, {
|
|
36
|
+
version: string;
|
|
37
|
+
sha256: string;
|
|
38
|
+
source: string;
|
|
39
|
+
versions?: string[];
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
export declare function planSync(options?: {
|
|
43
|
+
dryRun?: boolean;
|
|
44
|
+
}): Promise<SyncPlan>;
|
|
33
45
|
export declare function syncHooks(options?: {
|
|
34
46
|
dryRun?: boolean;
|
|
35
47
|
}): Promise<SyncPlan>;
|
|
48
|
+
/**
|
|
49
|
+
* One fully-validated artifact ready to be committed. Nothing has touched
|
|
50
|
+
* the store at this point: every fetch, sha check and manifest parse has
|
|
51
|
+
* already passed, so a failure inside staging leaves the store unchanged.
|
|
52
|
+
*/
|
|
53
|
+
export interface StagedSyncArtifact {
|
|
54
|
+
name: string;
|
|
55
|
+
version: string;
|
|
56
|
+
sha256: string;
|
|
57
|
+
source: string;
|
|
58
|
+
manifest: ReturnType<typeof parseManifest>;
|
|
59
|
+
scriptContent: string;
|
|
60
|
+
scriptRel: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* P1-9 stage phase: fetch + validate ALL artifacts before anything is
|
|
64
|
+
* written. A failure here (network, sha mismatch, malformed manifest,
|
|
65
|
+
* containment violation) throws with the store untouched.
|
|
66
|
+
*/
|
|
67
|
+
export declare function stageSyncArtifacts(apiUrl: string, names: string[], remoteLock: RemoteLock): Promise<StagedSyncArtifact[]>;
|
|
68
|
+
/**
|
|
69
|
+
* P1-9 commit phase, ordered so a mid-commit failure cannot leave a partial
|
|
70
|
+
* store that reads as trusted:
|
|
71
|
+
*
|
|
72
|
+
* Phase A — write ALL hook files and verify their hashes. A failure here
|
|
73
|
+
* leaves lock and DB untouched; files written without pins are
|
|
74
|
+
* refused at run time (fail-closed trust), never silently
|
|
75
|
+
* trusted.
|
|
76
|
+
* Phase B — write ALL lock pins in ONE atomic write (temp + rename).
|
|
77
|
+
* Phase C — DB records in one transaction.
|
|
78
|
+
*
|
|
79
|
+
* A failure between A and B leaves the DB untouched and the old pins in
|
|
80
|
+
* place; a failure between B and C leaves old DB records, which take
|
|
81
|
+
* precedence in checkScriptHash and refuse the new bytes until re-sync.
|
|
82
|
+
*/
|
|
83
|
+
export declare function commitSyncArtifacts(staged: StagedSyncArtifact[], apiUrl: string, remoteLock: RemoteLock): Promise<void>;
|
|
36
84
|
export interface PinnedHookInstall {
|
|
37
85
|
name: string;
|
|
38
86
|
version: string;
|
|
@@ -44,11 +92,14 @@ export interface PinnedHookInstall {
|
|
|
44
92
|
}
|
|
45
93
|
/**
|
|
46
94
|
* Fetch one exact hook version from the remote registry, verify its sha
|
|
47
|
-
* against the remote lock
|
|
95
|
+
* against the remote lock (or the exact-version header for older versions),
|
|
96
|
+
* write it to the custom store, and pin it.
|
|
48
97
|
* Powers `hooks install <name>@<version>` / `hooks update <name>@<version>`
|
|
49
98
|
* (QA-2 finding: pinned-version install/update was unsupported).
|
|
50
99
|
*
|
|
51
|
-
* Requires an api_url (remote registry).
|
|
52
|
-
*
|
|
100
|
+
* Requires an api_url (remote registry). P1-4: the exact version named by
|
|
101
|
+
* the user is fetched from the versioned registry — older-than-latest pins
|
|
102
|
+
* are first-class, never rejected as "not the latest".
|
|
53
103
|
*/
|
|
54
104
|
export declare function fetchPinnedHook(name: string, version: string, apiUrl: string): Promise<PinnedHookInstall>;
|
|
105
|
+
export {};
|
package/dist/serve.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface CatalogEntry {
|
|
|
15
15
|
events: string[];
|
|
16
16
|
description: string;
|
|
17
17
|
source: string;
|
|
18
|
+
versions: string[];
|
|
18
19
|
}
|
|
19
20
|
export interface ArtifactPayload {
|
|
20
21
|
manifest: {
|
|
@@ -23,6 +24,7 @@ export interface ArtifactPayload {
|
|
|
23
24
|
description?: string;
|
|
24
25
|
events: string[];
|
|
25
26
|
script: string;
|
|
27
|
+
script_kind?: "inline" | "file";
|
|
26
28
|
args?: string[];
|
|
27
29
|
timeout_ms?: number;
|
|
28
30
|
};
|
|
@@ -32,5 +34,4 @@ export declare function handleServeRequest(req: Request, apiKey: string | undefi
|
|
|
32
34
|
export declare function startServeServer(options: {
|
|
33
35
|
port?: number;
|
|
34
36
|
host?: string;
|
|
35
|
-
apiKey?: string;
|
|
36
37
|
}): ReturnType<typeof Bun.serve>;
|
package/dist/storage.js
CHANGED
|
@@ -399,6 +399,60 @@ var init_db = __esm(() => {
|
|
|
399
399
|
init_legacy_import();
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
// src/lib/redact.ts
|
|
403
|
+
function redactText(value) {
|
|
404
|
+
let out = value;
|
|
405
|
+
for (const pattern of SECRET_SHAPES) {
|
|
406
|
+
out = out.replace(pattern, REDACTED);
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function redactValue(value) {
|
|
411
|
+
if (typeof value === "string")
|
|
412
|
+
return redactText(value);
|
|
413
|
+
if (Array.isArray(value))
|
|
414
|
+
return value.map(redactValue);
|
|
415
|
+
if (value !== null && typeof value === "object") {
|
|
416
|
+
const out = {};
|
|
417
|
+
for (const [key, child] of Object.entries(value)) {
|
|
418
|
+
out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue(child);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
return value;
|
|
423
|
+
}
|
|
424
|
+
function redactEventPayload(text) {
|
|
425
|
+
if (text === null || text === undefined)
|
|
426
|
+
return null;
|
|
427
|
+
try {
|
|
428
|
+
const parsed = JSON.parse(text);
|
|
429
|
+
return redactText(JSON.stringify(redactValue(parsed)));
|
|
430
|
+
} catch {
|
|
431
|
+
return redactText(text);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
|
|
435
|
+
var init_redact = __esm(() => {
|
|
436
|
+
SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
|
|
437
|
+
SECRET_SHAPES = [
|
|
438
|
+
/\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
|
|
439
|
+
/\bsk-[A-Za-z0-9]{16,}/g,
|
|
440
|
+
/\btpe_[A-Za-z0-9]{8,}/g,
|
|
441
|
+
/\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
|
|
442
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
443
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
444
|
+
/xox[baprs]-[A-Za-z0-9-]{20,}/g,
|
|
445
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
446
|
+
/ASIA[0-9A-Z]{16}/g,
|
|
447
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
448
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
449
|
+
/-----BEGIN OPENSSH PRIVATE KEY-----/g,
|
|
450
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
|
|
451
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
|
|
452
|
+
/\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
|
|
453
|
+
];
|
|
454
|
+
});
|
|
455
|
+
|
|
402
456
|
// src/db/storage-sync.ts
|
|
403
457
|
init_db();
|
|
404
458
|
|
|
@@ -409,7 +463,7 @@ var PG_MIGRATIONS = [
|
|
|
409
463
|
timestamp TEXT NOT NULL,
|
|
410
464
|
session_id TEXT NOT NULL,
|
|
411
465
|
hook_name TEXT NOT NULL,
|
|
412
|
-
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
466
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
|
|
413
467
|
tool_name TEXT,
|
|
414
468
|
tool_input TEXT,
|
|
415
469
|
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
@@ -442,7 +496,10 @@ var PG_MIGRATIONS = [
|
|
|
442
496
|
)`,
|
|
443
497
|
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
444
498
|
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
445
|
-
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))
|
|
499
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`,
|
|
500
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
501
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
502
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart'))`
|
|
446
503
|
];
|
|
447
504
|
|
|
448
505
|
// src/db/remote-storage.ts
|
|
@@ -456,7 +513,34 @@ function normalizeParams(params) {
|
|
|
456
513
|
return flat.map((value) => value === undefined ? null : value);
|
|
457
514
|
}
|
|
458
515
|
function sslConfigFor(connectionString) {
|
|
459
|
-
|
|
516
|
+
const wantsSsl = wantsSslFor(connectionString);
|
|
517
|
+
if (!wantsSsl)
|
|
518
|
+
return;
|
|
519
|
+
const insecure = process.env.HASNA_HOOKS_PG_INSECURE_TLS === "1" || process.env.HOOKS_PG_INSECURE_TLS === "1";
|
|
520
|
+
if (insecure && false) {}
|
|
521
|
+
return insecure ? { rejectUnauthorized: false } : { rejectUnauthorized: true };
|
|
522
|
+
}
|
|
523
|
+
function wantsSslFor(connectionString) {
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = new URL(connectionString).search.replace(/^\?/, "");
|
|
527
|
+
} catch {
|
|
528
|
+
raw = connectionString;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
raw = decodeURIComponent(raw);
|
|
532
|
+
} catch {}
|
|
533
|
+
const paramValue = (name) => {
|
|
534
|
+
const match = raw.match(new RegExp(`(?:^|[&\\s])${name}\\s*=\\s*([^&\\s]+)`, "i"));
|
|
535
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
536
|
+
};
|
|
537
|
+
const sslmode = paramValue("sslmode");
|
|
538
|
+
if (sslmode !== undefined)
|
|
539
|
+
return sslmode !== "disable" && sslmode !== "allow";
|
|
540
|
+
const ssl = paramValue("ssl");
|
|
541
|
+
if (ssl !== undefined)
|
|
542
|
+
return ssl !== "false" && ssl !== "0" && ssl !== "no";
|
|
543
|
+
return false;
|
|
460
544
|
}
|
|
461
545
|
|
|
462
546
|
class PgAdapterAsync {
|
|
@@ -478,6 +562,7 @@ class PgAdapterAsync {
|
|
|
478
562
|
}
|
|
479
563
|
|
|
480
564
|
// src/db/storage-sync.ts
|
|
565
|
+
init_redact();
|
|
481
566
|
var STORAGE_TABLES = [
|
|
482
567
|
"hook_events",
|
|
483
568
|
"schema_migrations",
|
|
@@ -654,9 +739,15 @@ async function pushTable(db, remote, table) {
|
|
|
654
739
|
result.rowsRead = rows.length;
|
|
655
740
|
if (rows.length === 0)
|
|
656
741
|
return result;
|
|
742
|
+
const projected = table === "hook_events" ? rows.map((row) => ({
|
|
743
|
+
...row,
|
|
744
|
+
tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
|
|
745
|
+
error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
|
|
746
|
+
metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
|
|
747
|
+
})) : rows;
|
|
657
748
|
const remoteColumns = await getRemoteColumns(remote, table);
|
|
658
|
-
const columns = filterRemoteColumns(remoteColumns, Object.keys(
|
|
659
|
-
result.rowsWritten = await upsertPg(remote, table, columns,
|
|
749
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(projected[0]));
|
|
750
|
+
result.rowsWritten = await upsertPg(remote, table, columns, projected, remoteColumns);
|
|
660
751
|
} catch (error) {
|
|
661
752
|
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
662
753
|
}
|
|
@@ -671,8 +762,14 @@ async function pullTable(remote, db, table) {
|
|
|
671
762
|
result.rowsRead = rows.length;
|
|
672
763
|
if (rows.length === 0)
|
|
673
764
|
return result;
|
|
674
|
-
const
|
|
675
|
-
|
|
765
|
+
const projected = table === "hook_events" ? rows.map((row) => ({
|
|
766
|
+
...row,
|
|
767
|
+
tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
|
|
768
|
+
error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
|
|
769
|
+
metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
|
|
770
|
+
})) : rows;
|
|
771
|
+
const columns = filterLocalColumns(db, table, Object.keys(projected[0]));
|
|
772
|
+
result.rowsWritten = upsertSqlite(db, table, columns, projected);
|
|
676
773
|
} catch (error) {
|
|
677
774
|
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
678
775
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@hasna/events": "^0.1.6",
|
|
56
|
-
"@hasna/secrets": "
|
|
56
|
+
"@hasna/secrets": "0.3.0",
|
|
57
57
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
58
58
|
"chalk": "^5.3.0",
|
|
59
59
|
"commander": "^12.1.0",
|