@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.
- package/README.md +1 -1
- package/bin/index.js +697 -181
- package/bin/serve.js +111 -28
- package/dist/config.d.ts +8 -1
- package/dist/db/remote-storage.d.ts +22 -0
- package/dist/index.js +506 -113
- package/dist/lib/hook-env.d.ts +98 -0
- package/dist/lib/manifest.d.ts +35 -2
- package/dist/lib/redact.d.ts +40 -0
- package/dist/lib/semver.d.ts +46 -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,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>;
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
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;
|
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.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": "
|
|
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",
|