@hasna/hooks 0.6.5 → 0.6.8

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.
@@ -0,0 +1,98 @@
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 (HOME, LANG, TZ,
10
+ * SHELL, TERM, USER, PWD) plus PATH — PATH rebuilt from a trusted
11
+ * baseline with writable-directory entries removed (or a per-hook
12
+ * manifest env.PATH override, verbatim);
13
+ * 2. non-secret HOOKS_* projections of the parent's HASNA_HOOKS_* config
14
+ * (data dir, DB path, lock path, config path) so in-process DB/config
15
+ * resolution inside the hook still finds the store the parent configured;
16
+ * 3. caller-supplied extras, filtered through the same deny list.
17
+ *
18
+ * Denial is NAME-BASED by design. A value-shape test is explicitly rejected
19
+ * as an acceptability test: a value test would have to miss something for a
20
+ * credential to leak, so the deny list is the documented prefix/suffix set
21
+ * below. The deny list strips:
22
+ *
23
+ * suffix: KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, CREDENTIALS,
24
+ * URL, URI (a URL/URI-bearing variable can embed credentials)
25
+ * prefix: HASNA_, AWS_, AZURE_, GCP_, VAULT_, GOOGLE_, OPENAI_,
26
+ * ANTHROPIC_, POSTGRES_, MYSQL_, REDIS_, MONGO_, MEMENTOS_
27
+ * contains: DATABASE_URL (any variant)
28
+ *
29
+ * The deny list alone is NOT the whole boundary (bug cf99cf76): it strips
30
+ * credential-shaped NAMES from the parent env, but a credential can be
31
+ * re-imported from a FILE through interpreter machinery. BASH_ENV tells bash
32
+ * to source a file before running any non-interactive script, and ENV does
33
+ * the same for interactive shells — a parent whose BASH_ENV points at e.g.
34
+ * hasna-cloud-env.sh hands the hook child a process that re-exports the
35
+ * fleet credential env AFTER the deny list ran. The interpreter-injection
36
+ * set below strips every variable that makes a child interpreter run or
37
+ * source code the hook did not ask for (sourcing vectors, interpreter option
38
+ * lists, module/startup injection, dynamic-loader preloads, TLS-trust
39
+ * redirection, gconv/locale module injection). Exact case-sensitive names:
40
+ * POSIX interpreters read these literals, and a differently-cased variant is
41
+ * an inert, unrelated variable.
42
+ *
43
+ * Names that merely resemble credentials (e.g. HOOKS_DATA_DIR) survive; a
44
+ * path is not a credential.
45
+ */
46
+ /**
47
+ * True when a variable NAME is credential-bearing and must never reach a
48
+ * hook child process. Case-insensitive.
49
+ */
50
+ export declare function isDeniedEnvName(name: string): boolean;
51
+ /**
52
+ * True when a variable NAME is interpreter-injection (bug cf99cf76): it can
53
+ * make a child interpreter run or source code the hook did not ask for.
54
+ * Exact case-sensitive match — interpreters read these literals verbatim.
55
+ */
56
+ export declare function isInterpreterInjectionEnvName(name: string): boolean;
57
+ /**
58
+ * True when a variable NAME is a bash exported-function entry
59
+ * (BASH_FUNC_<name>%%): bash imports EXPORTED FUNCTIONS from the environment
60
+ * into the child shell, where they shadow commands (env/cat/git/node) and
61
+ * run attacker code on the hook's first command. The %% suffix is bash's
62
+ * export encoding; the prefix test covers every form (%% , single %, none).
63
+ *
64
+ * Aliases are deliberately NOT denied: bash cannot import aliases from the
65
+ * environment (BASH_ALIASES is an associative array and is never exported) —
66
+ * functions are the only env-importable code vector for bash, so the strip
67
+ * is precisely the BASH_FUNC_* prefix.
68
+ */
69
+ export declare function isBashFunctionEnvName(name: string): boolean;
70
+ /**
71
+ * True when a PATH entry may hold attacker-controlled executables: an empty
72
+ * or relative entry (resolves against the child's cwd), an entry under the
73
+ * user's home, an entry under a shared tmp tree, or a world-writable
74
+ * directory (any user can drop a fake binary there).
75
+ */
76
+ export declare function isUnsafePathEntry(entry: string, home: string | undefined): boolean;
77
+ /**
78
+ * Rebuild a PATH for the hook child from a trusted baseline: the system
79
+ * directories (plus /opt/homebrew/bin on macOS when present) and the
80
+ * runner's own bun directory (the interpreter the hook runs under, trusted
81
+ * by definition even when it lives under $HOME). Parent entries are kept
82
+ * only when they do not live under $HOME, /tmp, /var/tmp, or a world-writable
83
+ * path — a fake `node`/`git` planted in a writable directory must never
84
+ * execute on the hook's first command. An explicit per-hook env.PATH
85
+ * (manifest) override bypasses this entirely: the hook author declares the
86
+ * complete PATH it needs.
87
+ */
88
+ export declare function sanitizeHookPath(pathValue: string, home: string | undefined): string;
89
+ /**
90
+ * Build the sanitized environment for a hook child process.
91
+ *
92
+ * `source` is the parent's environment; `extra` is any caller-supplied env
93
+ * the hook may need. The deny list AND the interpreter-injection set apply
94
+ * to BOTH — a caller cannot reintroduce a credential-bearing name, and
95
+ * cannot reintroduce a variable that would make the child's interpreter
96
+ * source or run code (bug cf99cf76).
97
+ */
98
+ export declare function buildHookEnv(source: Record<string, string | undefined>, extra?: Record<string, string | undefined>): Record<string, string>;
@@ -9,14 +9,32 @@ 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>;
21
+ /**
22
+ * Per-hook environment passed to the hook child (reviewer efcad315).
23
+ * Every name is still filtered through the credential deny list and the
24
+ * interpreter-injection strip; `env.PATH` here is the documented way to
25
+ * override the sanitized default PATH — it is passed verbatim, bypassing
26
+ * PATH sanitization (the hook author declares the complete PATH the hook
27
+ * needs).
28
+ */
29
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
14
30
  }, "strip", z.ZodTypeAny, {
15
31
  version: string;
16
32
  events: string[];
17
33
  name: string;
18
34
  script: string;
35
+ env?: Record<string, string> | undefined;
19
36
  description?: string | undefined;
37
+ script_kind?: "inline" | "file" | undefined;
20
38
  args?: string[] | undefined;
21
39
  timeout_ms?: number | undefined;
22
40
  }, {
@@ -24,7 +42,9 @@ export declare const manifestSchema: z.ZodObject<{
24
42
  events: string[];
25
43
  name: string;
26
44
  script: string;
45
+ env?: Record<string, string> | undefined;
27
46
  description?: string | undefined;
47
+ script_kind?: "inline" | "file" | undefined;
28
48
  args?: string[] | undefined;
29
49
  timeout_ms?: number | undefined;
30
50
  }>;
@@ -52,10 +72,23 @@ export declare class ScriptContainmentError extends Error {
52
72
  * root, then call assertContainedReal.
53
73
  */
54
74
  export declare function assertContained(target: string, root: string, raw?: string): string;
75
+ export declare function isInlineScript(manifest: HookManifest): boolean;
76
+ /**
77
+ * Resolve a manifest's script to its RELATIVE target path under the hook
78
+ * directory. Shared by every install path (P2-14 / P1-2 round 2): the explicit
79
+ * script_kind discriminator wins; without it the legacy heuristic applies (a
80
+ * value containing a newline is inline content, otherwise a relative path).
81
+ * This is the ONE decision point — registry sync, exact-pin fetch and custom
82
+ * installs all call it, so a one-line inline manifest can never be mistaken
83
+ * for a path again.
84
+ */
85
+ export declare function scriptRelFor(manifest: HookManifest): string;
55
86
  /**
56
87
  * Resolve a manifest's script to (relative path, content).
57
- * A script value containing a newline is inline content; anything else is a
58
- * relative path resolved against the manifest directory.
88
+ * The explicit script_kind discriminator wins (P2-14); without it the
89
+ * legacy heuristic applies: a script value containing a newline is inline
90
+ * content, anything else is a relative path resolved against the manifest
91
+ * directory.
59
92
  */
60
93
  export declare function resolveScript(manifest: HookManifest, manifestDir: string): {
61
94
  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,46 @@
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
+ * Numeric identifiers are STRICT (reviewer efcad315): per semver.org §2,
12
+ * numeric identifiers MUST NOT carry leading zeroes ("1.0.0-01" is not
13
+ * "1.0.0-1" — it is not semver at all), and identifiers longer than 16
14
+ * digits are rejected as invalid so no surface can ever store a version the
15
+ * comparator cannot order exactly (Number() precision ends at
16
+ * MAX_SAFE_INTEGER; the comparator itself uses BigInt, see compareVersions).
17
+ */
18
+ export declare const SEMVER_PATTERN: RegExp;
19
+ /**
20
+ * A version segment used inside a URL path. Prerelease and build metadata
21
+ * can contain '+' (build metadata) which must be percent-encoded in URLs;
22
+ * the route regex accepts the encoded and decoded forms.
23
+ */
24
+ export declare const SEMVER_PATH_SEGMENT = "[0-9A-Za-z.+_-]+";
25
+ export declare function isValidSemver(value: string): boolean;
26
+ export declare function semverError(value: string): string;
27
+ /**
28
+ * Full semver precedence (semver.org §11), shared by every surface that
29
+ * must ORDER published versions — the registry latest pointer (bug
30
+ * 6e412e52) compares with this and nothing else.
31
+ *
32
+ * Returns <0 when a sorts before b, 0 when equal, >0 when a sorts after b.
33
+ * Build metadata never participates. Prerelease identifiers compare by the
34
+ * spec's rules: numeric identifiers numerically, numeric before alphanumeric,
35
+ * fewer identifiers before more, release before any prerelease.
36
+ *
37
+ * Numeric identifiers compare as BigInt (reviewer efcad315): Number()
38
+ * loses precision past MAX_SAFE_INTEGER, which made near-16-digit numeric
39
+ * identifiers compare equal and made "1.0.0-01" vs "1.0.0-1" compare >0 in
40
+ * BOTH directions (Number("01") === Number("1") while the strings differ).
41
+ * BigInt keeps the comparison exact and antisymmetric at any length; the
42
+ * validation pattern rejects leading-zero and >16-digit identifiers as
43
+ * invalid, and this comparator stays correct even if an old invalid value
44
+ * ever reaches it.
45
+ */
46
+ export declare function compareVersions(a: string, b: string): number;
@@ -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;
@@ -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
- export declare function planSync(): Promise<SyncPlan>;
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, write it to the custom store, and pin it.
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). The sha check is against the remote
52
- * lock — the exact version named by the user, never a mutable latest.
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
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
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(rows[0]));
659
- result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
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 columns = filterLocalColumns(db, table, Object.keys(rows[0]));
675
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
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.5",
3
+ "version": "0.6.8",
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": "^0.2.22",
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",