@hasna/hooks 0.11.6 → 0.12.0
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 +129 -4
- package/bin/hooks-mcp.js +37 -5
- package/bin/index.js +153 -15
- package/bin/native-safety-entry.js +15 -1718
- package/bin/serve.js +27 -0
- package/dist/index.js +38 -5
- package/dist/lib/native-safety-registration.d.ts +49 -6
- package/dist/lib/registry.d.ts +15 -0
- package/dist/native-safety.d.ts +4 -0
- package/dist/native-safety.js +8 -4
- package/hooks/hook-signed-link-guard/README.md +228 -0
- package/hooks/hook-signed-link-guard/package.json +12 -0
- package/hooks/hook-signed-link-guard/src/classify.ts +1280 -0
- package/hooks/hook-signed-link-guard/src/hook.ts +72 -0
- package/hooks/hook-signed-link-guard/src/jq.ts +545 -0
- package/hooks/hook-signed-link-guard/src/links.ts +136 -0
- package/hooks/hook-signed-link-output/README.md +47 -0
- package/hooks/hook-signed-link-output/package.json +12 -0
- package/hooks/hook-signed-link-output/src/hook.ts +111 -0
- package/hooks/hook-trash-guard/src/hook.ts +204 -80
- package/hooks/native-safety-entry.ts +28 -7
- package/package.json +2 -2
- package/scripts/validate-package.ts +12 -2
package/bin/serve.js
CHANGED
|
@@ -131,6 +131,7 @@ function getExplicitDbPath(env = process.env) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
// src/lib/registry.ts
|
|
134
|
+
var CLAUDE_TRASH_GUARD_MATCHER = "^(Bash|Monitor|apply_patch|ApplyPatch|functions\\.apply_patch)$";
|
|
134
135
|
var HOOKS = [
|
|
135
136
|
{
|
|
136
137
|
name: "gitguard",
|
|
@@ -181,6 +182,8 @@ var HOOKS = [
|
|
|
181
182
|
category: "Git Safety",
|
|
182
183
|
event: "PreToolUse",
|
|
183
184
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
185
|
+
targetMatchers: { claude: "^(Bash|Monitor|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$" },
|
|
186
|
+
legacyMatchers: ["^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
184
187
|
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
185
188
|
},
|
|
186
189
|
{
|
|
@@ -191,6 +194,8 @@ var HOOKS = [
|
|
|
191
194
|
category: "Git Safety",
|
|
192
195
|
event: "PreToolUse",
|
|
193
196
|
matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
197
|
+
targetMatchers: { claude: CLAUDE_TRASH_GUARD_MATCHER },
|
|
198
|
+
legacyMatchers: ["^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
194
199
|
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
195
200
|
rewritesInput: true,
|
|
196
201
|
timeoutSeconds: 10
|
|
@@ -295,6 +300,28 @@ var HOOKS = [
|
|
|
295
300
|
matcher: "Bash",
|
|
296
301
|
tags: ["security", "credentials", "secrets", "detection", "audit"]
|
|
297
302
|
},
|
|
303
|
+
{
|
|
304
|
+
name: "signed-link-guard",
|
|
305
|
+
displayName: "Signed Link Guard",
|
|
306
|
+
description: "Refuses gh reads that would print composite GitHub content (bodies, comments, reviews, commit messages, check links and details, raw issue/PR/check API objects) where signed action links appear; bounded scalar projections, diffs, lists and writes pass",
|
|
307
|
+
version: "0.1.0",
|
|
308
|
+
category: "Security",
|
|
309
|
+
event: "PreToolUse",
|
|
310
|
+
matcher: "^(Bash|Monitor)$",
|
|
311
|
+
tags: ["github", "gh", "signed-links", "capabilities", "transcript", "guard", "security"],
|
|
312
|
+
timeoutSeconds: 10
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: "signed-link-output",
|
|
316
|
+
displayName: "Signed Link Output",
|
|
317
|
+
description: "Claude PostToolUse backstop: replaces signed-link URLs in tool output with a redaction marker (updatedToolOutput, where the running Claude Code supports it) and tells the model not to repeat or store the link. It runs after the tool, so it cannot undo display or other persistence",
|
|
318
|
+
version: "0.1.0",
|
|
319
|
+
category: "Security",
|
|
320
|
+
event: "PostToolUse",
|
|
321
|
+
matcher: "^(Bash|WebFetch|mcp__.*)$",
|
|
322
|
+
tags: ["github", "signed-links", "capabilities", "redaction", "transcript", "security"],
|
|
323
|
+
timeoutSeconds: 10
|
|
324
|
+
},
|
|
298
325
|
{
|
|
299
326
|
name: "phonenotify",
|
|
300
327
|
displayName: "Phone Notify",
|
package/dist/index.js
CHANGED
|
@@ -6979,6 +6979,10 @@ var HOOK_EVENTS = [
|
|
|
6979
6979
|
"UserPromptSubmit",
|
|
6980
6980
|
"SubagentStart"
|
|
6981
6981
|
];
|
|
6982
|
+
var CLAUDE_TRASH_GUARD_MATCHER = "^(Bash|Monitor|apply_patch|ApplyPatch|functions\\.apply_patch)$";
|
|
6983
|
+
function matcherFor(meta, target) {
|
|
6984
|
+
return meta.targetMatchers?.[target] ?? meta.matcher;
|
|
6985
|
+
}
|
|
6982
6986
|
var CATEGORIES = [
|
|
6983
6987
|
"Git Safety",
|
|
6984
6988
|
"Code Quality",
|
|
@@ -7041,6 +7045,8 @@ var HOOKS = [
|
|
|
7041
7045
|
category: "Git Safety",
|
|
7042
7046
|
event: "PreToolUse",
|
|
7043
7047
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
7048
|
+
targetMatchers: { claude: "^(Bash|Monitor|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$" },
|
|
7049
|
+
legacyMatchers: ["^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
7044
7050
|
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
7045
7051
|
},
|
|
7046
7052
|
{
|
|
@@ -7051,6 +7057,8 @@ var HOOKS = [
|
|
|
7051
7057
|
category: "Git Safety",
|
|
7052
7058
|
event: "PreToolUse",
|
|
7053
7059
|
matcher: "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
7060
|
+
targetMatchers: { claude: CLAUDE_TRASH_GUARD_MATCHER },
|
|
7061
|
+
legacyMatchers: ["^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$"],
|
|
7054
7062
|
tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
|
|
7055
7063
|
rewritesInput: true,
|
|
7056
7064
|
timeoutSeconds: 10
|
|
@@ -7155,6 +7163,28 @@ var HOOKS = [
|
|
|
7155
7163
|
matcher: "Bash",
|
|
7156
7164
|
tags: ["security", "credentials", "secrets", "detection", "audit"]
|
|
7157
7165
|
},
|
|
7166
|
+
{
|
|
7167
|
+
name: "signed-link-guard",
|
|
7168
|
+
displayName: "Signed Link Guard",
|
|
7169
|
+
description: "Refuses gh reads that would print composite GitHub content (bodies, comments, reviews, commit messages, check links and details, raw issue/PR/check API objects) where signed action links appear; bounded scalar projections, diffs, lists and writes pass",
|
|
7170
|
+
version: "0.1.0",
|
|
7171
|
+
category: "Security",
|
|
7172
|
+
event: "PreToolUse",
|
|
7173
|
+
matcher: "^(Bash|Monitor)$",
|
|
7174
|
+
tags: ["github", "gh", "signed-links", "capabilities", "transcript", "guard", "security"],
|
|
7175
|
+
timeoutSeconds: 10
|
|
7176
|
+
},
|
|
7177
|
+
{
|
|
7178
|
+
name: "signed-link-output",
|
|
7179
|
+
displayName: "Signed Link Output",
|
|
7180
|
+
description: "Claude PostToolUse backstop: replaces signed-link URLs in tool output with a redaction marker (updatedToolOutput, where the running Claude Code supports it) and tells the model not to repeat or store the link. It runs after the tool, so it cannot undo display or other persistence",
|
|
7181
|
+
version: "0.1.0",
|
|
7182
|
+
category: "Security",
|
|
7183
|
+
event: "PostToolUse",
|
|
7184
|
+
matcher: "^(Bash|WebFetch|mcp__.*)$",
|
|
7185
|
+
tags: ["github", "signed-links", "capabilities", "redaction", "transcript", "security"],
|
|
7186
|
+
timeoutSeconds: 10
|
|
7187
|
+
},
|
|
7158
7188
|
{
|
|
7159
7189
|
name: "phonenotify",
|
|
7160
7190
|
displayName: "Phone Notify",
|
|
@@ -39789,6 +39819,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
39789
39819
|
if (uniqueEventKeys.length === 0) {
|
|
39790
39820
|
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
39791
39821
|
}
|
|
39822
|
+
const matcher = matcherFor(meta3, target);
|
|
39792
39823
|
const settings = readSettings2(scope, target);
|
|
39793
39824
|
if (!settings.hooks)
|
|
39794
39825
|
settings.hooks = {};
|
|
@@ -39823,10 +39854,12 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
39823
39854
|
}
|
|
39824
39855
|
}
|
|
39825
39856
|
if (positions.length) {
|
|
39826
|
-
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== (meta3.matcher ?? ""))) {
|
|
39857
|
+
if (positions.length !== uniqueEventKeys.length || uniqueEventKeys.some((event) => positions.filter((position) => position.event === event).length !== 1) || positions.some((position) => (position.entry.matcher ?? "") !== matcher && !(meta3.legacyMatchers ?? []).includes(position.entry.matcher ?? ""))) {
|
|
39827
39858
|
throw new Error("Codex hook events, matchers or duplicate registrations require explicit reconciliation; no settings were changed.");
|
|
39828
39859
|
}
|
|
39829
39860
|
for (const { entry, index } of positions) {
|
|
39861
|
+
if (matcher)
|
|
39862
|
+
entry.matcher = matcher;
|
|
39830
39863
|
entry.hooks[index] = { ...entry.hooks[index], type: "command", command: hookCommand };
|
|
39831
39864
|
if (typeof meta3.timeoutSeconds === "number" && meta3.timeoutSeconds > 0)
|
|
39832
39865
|
entry.hooks[index].timeout = meta3.timeoutSeconds;
|
|
@@ -39846,8 +39879,8 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
|
|
|
39846
39879
|
const entry = {
|
|
39847
39880
|
hooks: [hookEntry]
|
|
39848
39881
|
};
|
|
39849
|
-
if (
|
|
39850
|
-
entry.matcher =
|
|
39882
|
+
if (matcher) {
|
|
39883
|
+
entry.matcher = matcher;
|
|
39851
39884
|
}
|
|
39852
39885
|
settings.hooks[eventKey].push(entry);
|
|
39853
39886
|
}
|
|
@@ -41351,7 +41384,7 @@ async function verifyNativeSafetyCommand(command, home = homedir7()) {
|
|
|
41351
41384
|
}
|
|
41352
41385
|
|
|
41353
41386
|
// src/lib/codex-safety-check.ts
|
|
41354
|
-
var versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.154.0-alpha.6.1", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1", "codex-cli 0.157.0", "codex-cli 0.157.1", "codex-cli 0.158.0"]);
|
|
41387
|
+
var versions = new Set(["codex-cli 0.153.0", "codex-cli 0.154.0", "codex-cli 0.154.0-alpha.6.1", "codex-cli 0.155.0", "codex-cli 0.155.1", "codex-cli 0.156.1", "codex-cli 0.157.0", "codex-cli 0.157.1", "codex-cli 0.158.0", "codex-cli 0.159.0"]);
|
|
41355
41388
|
var matcher = "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$";
|
|
41356
41389
|
|
|
41357
41390
|
class CodexSafetyError extends Error {
|
|
@@ -41896,7 +41929,7 @@ async function verifyClaudeSafetyConfiguration(options = {}, dependencies = {})
|
|
|
41896
41929
|
need8(!/^(?:hooks run trash-guard|hook-trash-guard)(?:\s|$)/.test(handler.command.trim()), "guard_definition_invalid");
|
|
41897
41930
|
if (binding?.name !== "trash-guard")
|
|
41898
41931
|
continue;
|
|
41899
|
-
need8(binding.home === home && event === "PreToolUse" && handler.type === "command" && group.matcher ===
|
|
41932
|
+
need8(binding.home === home && event === "PreToolUse" && handler.type === "command" && group.matcher === CLAUDE_TRASH_GUARD_MATCHER && handler.timeout === 10 && (handler.async === undefined || handler.async === false) && handler.asyncRewake !== true, "guard_definition_invalid");
|
|
41900
41933
|
guards.push({ command: handler.command, path: file2.path });
|
|
41901
41934
|
}
|
|
41902
41935
|
}
|
|
@@ -7,9 +7,12 @@ export declare class NativeRegistrationError extends Error {
|
|
|
7
7
|
export interface NativeRegistrationOptions {
|
|
8
8
|
target: "codex" | "claude";
|
|
9
9
|
}
|
|
10
|
+
export interface NativeReadinessRegistrationOptions {
|
|
11
|
+
target: "codex" | "claude" | "sumi";
|
|
12
|
+
}
|
|
10
13
|
export interface NativeRegistrationPlan {
|
|
11
14
|
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
12
|
-
target: "codex" | "claude";
|
|
15
|
+
target: "codex" | "claude" | "sumi";
|
|
13
16
|
settingsPath: string;
|
|
14
17
|
resolvedPath: string;
|
|
15
18
|
beforeSHA256: string;
|
|
@@ -24,6 +27,21 @@ export interface NativeRegistrationPlan {
|
|
|
24
27
|
interface Dependencies {
|
|
25
28
|
verify?: typeof verifyNativeSafetyCommand;
|
|
26
29
|
}
|
|
30
|
+
/** Sumi observes startup inside its own process through the bundled
|
|
31
|
+
* `@hasna/hooks/native-readiness` SDK, enabled by Sumi's own
|
|
32
|
+
* `experimental.trash_readiness` setting, so there is no startup command to
|
|
33
|
+
* write into a harness settings file. The Hooks-owned Sumi registration is the
|
|
34
|
+
* native guard record `~/.hasna/hooks/native/sumi-trash-guard.json`: it pins a
|
|
35
|
+
* Hooks runtime and worker, and Sumi's startup check requires it (Trash runs
|
|
36
|
+
* `hooks safety verify --target sumi --execution`). Planning re-points that
|
|
37
|
+
* record at this installed package without writing. Applying requires the exact
|
|
38
|
+
* plan digest, keeps the previous record as `<path>.before-<sha256>` and journals
|
|
39
|
+
* the operation. Sumi's own configuration is never read or written here. */
|
|
40
|
+
export interface SumiRegistrationDependencies {
|
|
41
|
+
home?: string;
|
|
42
|
+
command?: string;
|
|
43
|
+
verify?: typeof verifyNativeSafetyCommand;
|
|
44
|
+
}
|
|
27
45
|
export declare const planNativeSafetyRegistration: (options: NativeRegistrationOptions, dependencies?: Dependencies) => Promise<NativeRegistrationPlan>;
|
|
28
46
|
export declare const applyNativeSafetyRegistration: (options: NativeRegistrationOptions & {
|
|
29
47
|
expectedPlanDigest: string;
|
|
@@ -38,7 +56,7 @@ export declare const applyNativeSafetyRegistration: (options: NativeRegistration
|
|
|
38
56
|
guardVerified: boolean;
|
|
39
57
|
} | {
|
|
40
58
|
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
41
|
-
target: "codex" | "claude";
|
|
59
|
+
target: "codex" | "claude" | "sumi";
|
|
42
60
|
settingsPath: string;
|
|
43
61
|
resolvedPath: string;
|
|
44
62
|
beforeSHA256: string;
|
|
@@ -52,10 +70,11 @@ export declare const applyNativeSafetyRegistration: (options: NativeRegistration
|
|
|
52
70
|
ok: boolean;
|
|
53
71
|
changed: boolean;
|
|
54
72
|
}>;
|
|
55
|
-
|
|
56
|
-
export declare const
|
|
73
|
+
/** `sumiDependencies` is a test seam for the Sumi target only. */
|
|
74
|
+
export declare const planNativeReadinessRegistration: (options: NativeReadinessRegistrationOptions, sumiDependencies?: SumiRegistrationDependencies) => Promise<NativeRegistrationPlan>;
|
|
75
|
+
export declare const applyNativeReadinessRegistration: (options: NativeReadinessRegistrationOptions & {
|
|
57
76
|
expectedPlanDigest: string;
|
|
58
|
-
}) => Promise<{
|
|
77
|
+
}, sumiDependencies?: SumiRegistrationDependencies) => Promise<{
|
|
59
78
|
nativeAdoptionVerified: boolean;
|
|
60
79
|
readinessRunnerVerified?: boolean | undefined;
|
|
61
80
|
ok: boolean;
|
|
@@ -66,7 +85,31 @@ export declare const applyNativeReadinessRegistration: (options: NativeRegistrat
|
|
|
66
85
|
guardVerified: boolean;
|
|
67
86
|
} | {
|
|
68
87
|
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
69
|
-
target: "codex" | "claude";
|
|
88
|
+
target: "codex" | "claude" | "sumi";
|
|
89
|
+
settingsPath: string;
|
|
90
|
+
resolvedPath: string;
|
|
91
|
+
beforeSHA256: string;
|
|
92
|
+
desiredSHA256: string;
|
|
93
|
+
commandSHA256: string;
|
|
94
|
+
action: "unchanged" | "register";
|
|
95
|
+
planDigest: string;
|
|
96
|
+
guardVerified: boolean;
|
|
97
|
+
readinessRunnerVerified?: true;
|
|
98
|
+
nativeAdoptionVerified: false;
|
|
99
|
+
ok: boolean;
|
|
100
|
+
changed: boolean;
|
|
101
|
+
}> | Promise<{
|
|
102
|
+
nativeAdoptionVerified: boolean;
|
|
103
|
+
backup?: string | undefined;
|
|
104
|
+
ok: boolean;
|
|
105
|
+
changed: boolean;
|
|
106
|
+
operationId: `${string}-${string}-${string}-${string}-${string}`;
|
|
107
|
+
planDigest: string;
|
|
108
|
+
settingsSHA256: string;
|
|
109
|
+
guardVerified: boolean;
|
|
110
|
+
} | {
|
|
111
|
+
schema: "hasna.hooks.native-safety-registration/v1" | "hasna.hooks.native-readiness-registration/v1";
|
|
112
|
+
target: "codex" | "claude" | "sumi";
|
|
70
113
|
settingsPath: string;
|
|
71
114
|
resolvedPath: string;
|
|
72
115
|
beforeSHA256: string;
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -21,6 +21,17 @@ export interface HookMeta {
|
|
|
21
21
|
* reports it.
|
|
22
22
|
*/
|
|
23
23
|
rewritesInput?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* A different matcher for one harness. Claude Code's `Monitor` tool runs a
|
|
26
|
+
* shell command and streams its stdout to the model, so the bundled guards
|
|
27
|
+
* add it on Claude; Codex has no such tool and keeps its verified matcher.
|
|
28
|
+
*/
|
|
29
|
+
targetMatchers?: Partial<Record<"claude" | "codex", string>>;
|
|
30
|
+
/**
|
|
31
|
+
* Earlier matchers of an installed native registration that an in-place
|
|
32
|
+
* update may replace with the current one.
|
|
33
|
+
*/
|
|
34
|
+
legacyMatchers?: string[];
|
|
24
35
|
/**
|
|
25
36
|
* Timeout (seconds) written into the agent's settings entry for this hook.
|
|
26
37
|
* The harness default is 600s, and a TIMED-OUT HOOK DOES NOT BLOCK: a guard
|
|
@@ -28,6 +39,10 @@ export interface HookMeta {
|
|
|
28
39
|
*/
|
|
29
40
|
timeoutSeconds?: number;
|
|
30
41
|
}
|
|
42
|
+
/** The Claude Code matcher of the bundled Trash guard registration (Bash, Monitor and patch tools). */
|
|
43
|
+
export declare const CLAUDE_TRASH_GUARD_MATCHER = "^(Bash|Monitor|apply_patch|ApplyPatch|functions\\.apply_patch)$";
|
|
44
|
+
/** The matcher a hook is registered with for one harness. */
|
|
45
|
+
export declare function matcherFor(meta: HookMeta, target: string): string;
|
|
31
46
|
export declare const CATEGORIES: readonly ["Git Safety", "Code Quality", "Security", "Notifications", "Context Management", "Workflow Automation", "Environment", "Permissions", "Observability", "Agent Teams"];
|
|
32
47
|
export type Category = (typeof CATEGORIES)[number];
|
|
33
48
|
export declare const HOOKS: HookMeta[];
|
package/dist/native-safety.d.ts
CHANGED
|
@@ -38,10 +38,14 @@ export type NativeSafetyRecord = {
|
|
|
38
38
|
harness: "sumi";
|
|
39
39
|
command: string;
|
|
40
40
|
};
|
|
41
|
+
/** The exact bytes every writer stores for a Sumi guard record. Planners use
|
|
42
|
+
* the same bytes to compute a desired digest before any write. */
|
|
43
|
+
export declare function nativeSafetyRecordText(command: string): string;
|
|
41
44
|
export declare function readNativeSafetyRegistration(home?: string): {
|
|
42
45
|
record: NativeSafetyRecord;
|
|
43
46
|
sha256: string;
|
|
44
47
|
path: string;
|
|
48
|
+
text: string;
|
|
45
49
|
};
|
|
46
50
|
/** Bounded, non-leaking explanation for a refused binding: which integrity
|
|
47
51
|
* property the saved record fails. A binding that a third party could rewrite
|
package/dist/native-safety.js
CHANGED
|
@@ -199,11 +199,16 @@ function syncDirectory(path) {
|
|
|
199
199
|
closeSync2(fd);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
+
function nativeSafetyRecordText(command) {
|
|
203
|
+
const record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command };
|
|
204
|
+
return JSON.stringify(record) + `
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
202
207
|
function readNativeSafetyRegistration(home = homedir2()) {
|
|
203
208
|
try {
|
|
204
209
|
home = canonicalHome(home);
|
|
205
210
|
const path = join2(home, ".hasna/hooks/native", filename), text = readOwned(path);
|
|
206
|
-
return { record: parseRecord(text, home), sha256: hash(text), path };
|
|
211
|
+
return { record: parseRecord(text, home), sha256: hash(text), path, text };
|
|
207
212
|
} catch (error) {
|
|
208
213
|
if (error instanceof NativeSafetyError)
|
|
209
214
|
throw error;
|
|
@@ -229,9 +234,7 @@ function nativeSafetyBindingIntegrityReason(home = homedir2()) {
|
|
|
229
234
|
}
|
|
230
235
|
function registerNativeSafety(options = {}) {
|
|
231
236
|
const home = canonicalHome(options.home ?? homedir2());
|
|
232
|
-
const
|
|
233
|
-
const text = JSON.stringify(record) + `
|
|
234
|
-
`;
|
|
237
|
+
const text = nativeSafetyRecordText(options.command ?? installedNativeSafetyCommand("trash-guard"));
|
|
235
238
|
parseRecord(text, home);
|
|
236
239
|
for (const dir of [join2(home, ".hasna"), join2(home, ".hasna/hooks"), join2(home, ".hasna/hooks/native")]) {
|
|
237
240
|
try {
|
|
@@ -412,6 +415,7 @@ export {
|
|
|
412
415
|
verifyNativeSafetyCommand,
|
|
413
416
|
registerNativeSafety,
|
|
414
417
|
readNativeSafetyRegistration,
|
|
418
|
+
nativeSafetyRecordText,
|
|
415
419
|
nativeSafetyBindingIntegrityReason,
|
|
416
420
|
evaluateNativeSafetyForExecution,
|
|
417
421
|
evaluateNativeSafety,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# signed-link-guard
|
|
2
|
+
|
|
3
|
+
PreToolUse guard for shell commands, installed as `hooks run signed-link-guard`
|
|
4
|
+
with the matcher `^(Bash|Monitor)$`. It judges Claude Code's `Bash` tool and its
|
|
5
|
+
`Monitor` tool (Monitor runs a shell command and streams each stdout line to the
|
|
6
|
+
model), and Codex's `Bash`. The bundled native safety entry
|
|
7
|
+
(`hooks safety install trash-guard`) also evaluates it for every Bash and
|
|
8
|
+
Monitor command, whatever capability name the registration uses.
|
|
9
|
+
|
|
10
|
+
Composite GitHub content (bodies, comments, reviews, commit messages, check
|
|
11
|
+
links and check details) carries third-party signed action links: URLs with a
|
|
12
|
+
signature parameter, a multi-week expiry and no revoke path. A tool result stays
|
|
13
|
+
in the agent's transcript, so a printed link is an exposed capability. This
|
|
14
|
+
guard refuses a command before it runs when any `gh` invocation in it would
|
|
15
|
+
print that content. The refusal reason is always exactly:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
composite output withheld: signed-link shape; use bounded scalar projections per class disposition 799310
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it refuses
|
|
22
|
+
|
|
23
|
+
1. `gh pr view`, `gh issue view`, `gh release view`, `gh discussion view`:
|
|
24
|
+
- without `--json` (the human view prints the body), so a plain
|
|
25
|
+
`gh pr view <n>` is refused;
|
|
26
|
+
- with `--comments` / `-c`;
|
|
27
|
+
- with a `--json` field outside the scalar list below, unless a `--jq`
|
|
28
|
+
projection provably prints only bounded scalars.
|
|
29
|
+
`--web` / `-w` is allowed: it opens a browser and prints nothing. A flag's
|
|
30
|
+
last occurrence wins and `--web=false`, `-w=false` or `--web=0` is not set,
|
|
31
|
+
as gh's own flag parser reads them.
|
|
32
|
+
2. `gh pr checks` without `--json` (the table prints check links, and so does
|
|
33
|
+
`--watch`), or with a `--json` field other than `bucket, completedAt, event,
|
|
34
|
+
name, startedAt, state, workflow` (`link` and `description` are refused)
|
|
35
|
+
unless a scalar `--jq` projection selects from them.
|
|
36
|
+
3. `gh api` on REST endpoints that return bodies, comments, reviews, commit
|
|
37
|
+
messages, check runs or suites, statuses, events, deployment payloads or
|
|
38
|
+
issue and pull request objects:
|
|
39
|
+
- `repos/<o>/<r>/{pulls,issues,commits,check-runs,check-suites,statuses,
|
|
40
|
+
comments,releases,compare,events,discussions,deployments,milestones}`
|
|
41
|
+
(issue labels excepted);
|
|
42
|
+
- a single branch `repos/<o>/<r>/branches/<b>` (it carries the head
|
|
43
|
+
commit's message; the branch list, protection and rename endpoints do
|
|
44
|
+
not);
|
|
45
|
+
- `…/git/{commits,tags}`, `…/actions/{runs,jobs}` (logs excepted, see
|
|
46
|
+
below), `…/actions/workflows/<id>/runs`, webhook deliveries;
|
|
47
|
+
- `issues`, `user/issues`, `orgs/<o>/issues`, `search/{issues,commits}`,
|
|
48
|
+
user, org and network events, project items and cards, team
|
|
49
|
+
discussions.
|
|
50
|
+
|
|
51
|
+
The endpoint is normalised as the request reaches GitHub first: an
|
|
52
|
+
`https://api.github.com/` or GHES `/api/v3/` origin, the query string and
|
|
53
|
+
fragment are stripped, `%2F` and other escapes are decoded, `.` and `..`
|
|
54
|
+
are resolved and letters are lower-cased. Any other absolute URL (a
|
|
55
|
+
github.com page, a raw `.patch`) cannot be classified and is refused.
|
|
56
|
+
4. `gh api graphql` queries selecting `body`, `bodyText`, `bodyHTML`,
|
|
57
|
+
`comments`, `reviews`, `reviewThreads`, commit `message*`,
|
|
58
|
+
`autoMergeRequest { commitBody commitHeadline }`, `summary`, `text`,
|
|
59
|
+
`description`, `annotations`, `payload`, `url` or any `*Url` / `*HTML`
|
|
60
|
+
field, and a check run's or status context's `title`. The query is read
|
|
61
|
+
with a GraphQL tokenizer, so strings, block strings and `#` comments are
|
|
62
|
+
not selections; an unbalanced query counts as sensitive.
|
|
63
|
+
5. `gh api` writes whose response echoes the object: `PATCH` of an issue,
|
|
64
|
+
pull request, review or comment, and other writes on the families above.
|
|
65
|
+
Writes whose documented response is empty or scalar pass (GitHub REST
|
|
66
|
+
OpenAPI description 1.1.4): deleting a comment, reaction, label, release,
|
|
67
|
+
asset, run, deployment or milestone; re-running, cancelling or approving a
|
|
68
|
+
run or job; `PUT pulls/<n>/merge` (it returns `sha`, `merged` and a GitHub
|
|
69
|
+
status message) and `update-branch`; setting, adding or removing issue
|
|
70
|
+
labels; locking an issue. For the others use `--silent` or a scalar `--jq`.
|
|
71
|
+
6. `gh api` reads of a sensitive family without `--silent` or a `--jq` / `-q`
|
|
72
|
+
projection the guard can prove prints only bounded scalars (see below).
|
|
73
|
+
7. Other composite reads: `gh status` (it prints comment and mention
|
|
74
|
+
excerpts), `gh pr diff --patch` (each commit's full message), `gh project
|
|
75
|
+
item-list --format json` without a scalar `--jq`, and `--json` lists with a
|
|
76
|
+
composite field on `gh pr list`, `gh issue list`, `gh pr status`,
|
|
77
|
+
`gh issue status`, `gh search prs|issues|commits`, `gh release list` and
|
|
78
|
+
`gh discussion list`.
|
|
79
|
+
8. Traffic logging. `GH_DEBUG` (any value except empty, `0`, `false` and `no`;
|
|
80
|
+
gh's legacy `DEBUG` for `1`, `true`, `yes` and `api`) and `gh api
|
|
81
|
+
--verbose` make gh log the raw HTTP responses whatever `--jq` or `--silent`
|
|
82
|
+
print. Under them a call passes only when it provably fetches no link field:
|
|
83
|
+
a view, list, status or search whose `--json` fields are all scalar, or a
|
|
84
|
+
`gh api` call on an endpoint outside the families above (logs endpoints
|
|
85
|
+
excepted). Every other gh command is refused under `GH_DEBUG`, writes and
|
|
86
|
+
`gh run` included; `gh pr checks` is always refused under it, because its
|
|
87
|
+
query fetches every check's `detailsUrl` whatever `--json` selects. The
|
|
88
|
+
variable is read from assignments in the command (`GH_DEBUG=api gh …`,
|
|
89
|
+
`export GH_DEBUG=1; gh …`, `env GH_DEBUG=1 gh …`).
|
|
90
|
+
9. Fail closed: an endpoint, field list or query built from an expansion
|
|
91
|
+
(`$VAR`, `$( )`, a glob or brace list), a GraphQL query read from a file or
|
|
92
|
+
stdin, an unknown `gh` command (it may be an alias for a read above), an
|
|
93
|
+
unknown subcommand of `pr`, `issue`, `release`, `discussion` or `search`, a
|
|
94
|
+
read whose arguments `xargs` appends from stdin (`… | xargs gh pr view`),
|
|
95
|
+
and a command that cannot be parsed to its end while it mentions `gh`.
|
|
96
|
+
|
|
97
|
+
## Scalar `--json` fields
|
|
98
|
+
|
|
99
|
+
- Pull requests: `additions, assignees, author, baseRefName, baseRefOid,
|
|
100
|
+
changedFiles, closed, closedAt, createdAt, deletions, files, fullDatabaseId,
|
|
101
|
+
headRefName, headRefOid, headRepository, headRepositoryOwner, id,
|
|
102
|
+
isCrossRepository, isDraft, labels, maintainerCanModify, mergeCommit,
|
|
103
|
+
mergeStateStatus, mergeable, mergedAt, mergedBy, number,
|
|
104
|
+
potentialMergeCommit, reactionGroups, reviewDecision, reviewRequests, state,
|
|
105
|
+
title, updatedAt, url`.
|
|
106
|
+
- Issues: `assignees, author, closed, closedAt, createdAt, id, isPinned,
|
|
107
|
+
labels, number, reactionGroups, state, stateReason, subIssuesSummary, title,
|
|
108
|
+
updatedAt, url`.
|
|
109
|
+
- Releases: `apiUrl, author, createdAt, databaseId, id, isDraft, isImmutable,
|
|
110
|
+
isLatest, isPrerelease, name, publishedAt, tagName, tarballUrl,
|
|
111
|
+
targetCommitish, uploadUrl, url, zipballUrl`.
|
|
112
|
+
|
|
113
|
+
The object's own `url` is allowed: GitHub generates it from the owner,
|
|
114
|
+
repository and number, and it has no query string that could carry a
|
|
115
|
+
signature. Labels are allowed because a label description is a repository
|
|
116
|
+
setting capped at 100 characters. Refused composite fields include `body,
|
|
117
|
+
comments, reviews, latestReviews, statusCheckRollup, commits,
|
|
118
|
+
autoMergeRequest, closingIssuesReferences, closedByPullRequestsReferences,
|
|
119
|
+
blockedBy, blocking, parent, subIssues, milestone, projectCards,
|
|
120
|
+
projectItems, issueType, category, assets`, and any field a future gh adds.
|
|
121
|
+
|
|
122
|
+
## `--jq` projections
|
|
123
|
+
|
|
124
|
+
A `--jq` / `-q` filter admits a command when every output provably ends at a
|
|
125
|
+
scalar, non-free-text field. This applies to `gh api`, to `gh project
|
|
126
|
+
item-list --format json`, and to the `--json` commands above, where it admits
|
|
127
|
+
a composite field: `gh pr view 12 --json body --jq '.body | length'` passes.
|
|
128
|
+
Accepted examples: `.head.sha`, `.[] | .name`, `[.number, .title]`,
|
|
129
|
+
`{number, state}`, `"\(.number) \(.title)"`, `.check_runs[] | [.name,
|
|
130
|
+
.conclusion] | @tsv`, `.body | length`, `map(.name) | join(",")`,
|
|
131
|
+
`.[] | select(.user.login == "x") | .id`, and on a single pull request or
|
|
132
|
+
issue the counts `.commits`, `.comments` and `.review_comments`.
|
|
133
|
+
|
|
134
|
+
Scalar leaves are identifiers, numbers, counts, booleans, enums, refs, object
|
|
135
|
+
ids, timestamps and short names (`id`, `number`, `state`, `conclusion`,
|
|
136
|
+
`name`, `login`, `sha`, `title`, `created_at`, `digest`, `size_in_bytes`,
|
|
137
|
+
`run_started_at`, `date`, `wait_timer`, `ahead_by`, `behind_by`,
|
|
138
|
+
`total_commits`, …) and `html_url`. `html_url` is admitted because in every
|
|
139
|
+
response the guard refuses it is a GitHub-generated `github.com/<owner>/<repo>/…`
|
|
140
|
+
page address with at most an anchor or a GitHub filter query; the schemas where
|
|
141
|
+
someone else sets it (license, Pages site, dependency-snapshot job) are not
|
|
142
|
+
among them. Other URL fields (`url`, `details_url`, `target_url`) stay refused.
|
|
143
|
+
|
|
144
|
+
Refused: anything outside the supported jq subset (`..`, `if`, `reduce`,
|
|
145
|
+
variables, `$ENV`, `input`, dynamic object keys, regular-expression functions
|
|
146
|
+
with a non-literal pattern, `#` comments, which gojq continues across a
|
|
147
|
+
backslash-newline), any whole object (`.`, `.head`, `.[]`), any free-text leaf
|
|
148
|
+
(`.body`, `.output.summary`), anything projected out of a free-form container
|
|
149
|
+
(`payload`, `inputs`, check `output`, `config`, …), and anything projected out
|
|
150
|
+
of an object or array the filter built from a composite field
|
|
151
|
+
(`{name: .body} | .name`, `[.body] | .[0]`). `--verbose` and `--template` void
|
|
152
|
+
a projection.
|
|
153
|
+
|
|
154
|
+
## Where commands can hide
|
|
155
|
+
|
|
156
|
+
Command position is honoured (only a `gh` word in command position counts,
|
|
157
|
+
after assignments, keywords and `env`, `sudo`, `timeout`, `nice`, `command`,
|
|
158
|
+
`exec`, `nohup`, `time` and similar wrappers), and the guard follows:
|
|
159
|
+
|
|
160
|
+
- pipelines, `&&`, `||`, `;`, subshells, `{ …; }` groups and `function`
|
|
161
|
+
bodies;
|
|
162
|
+
- `$( … )` and backticks, also inside double quotes and unquoted
|
|
163
|
+
here-documents; a here-document body inside `$( )` is data, so the default
|
|
164
|
+
write spelling `gh pr comment 1 --body "$(cat <<'EOF' … EOF)"` passes
|
|
165
|
+
whatever the body says (apostrophes, an unbalanced `)`, nested quotes, or
|
|
166
|
+
the text of a refused command);
|
|
167
|
+
- `bash|sh|zsh -c`, also when the string is built by `$(cat <<EOF …)` or
|
|
168
|
+
`$(echo …)`, `eval`, `env -S`, `su|runuser|script|flock -c`, `ssh <host>
|
|
169
|
+
<command>`, `gh codespace ssh … -- <command>` and `watch`;
|
|
170
|
+
- text a shell reads as its script: literal `echo`, `printf` or `cat <<EOF`
|
|
171
|
+
output piped into `bash` or `sh`, here-documents and here-strings fed to a
|
|
172
|
+
shell, `source <(…)`, `. <(…)` and `. /dev/stdin`;
|
|
173
|
+
- `xargs … gh …` and `find … -exec gh … ;`;
|
|
174
|
+
- runners that pass a bare `gh` word on (`secrets exec … -- gh`, `op run --
|
|
175
|
+
gh`, `unbuffer gh`, `npx`, `bun x`, `docker`, …).
|
|
176
|
+
|
|
177
|
+
A command word built by expansion is judged by its literal basename when it
|
|
178
|
+
has one: `"$HOME/.bun/bin/tool" status` is not gh, `"$HOME/bin/gh" pr view 1`
|
|
179
|
+
is. A word with no readable name (`$GH`, `"$(command -v gh)"`) is judged as gh.
|
|
180
|
+
Any other program's `gh` argument is data: `node x.js gh pr view 1` is not a
|
|
181
|
+
gh call.
|
|
182
|
+
|
|
183
|
+
## Always allowed
|
|
184
|
+
|
|
185
|
+
`gh pr diff` (without `--patch`), `gh pr list` / `gh issue list` tables,
|
|
186
|
+
`gh pr view --json` with scalar fields, every `gh` write subcommand (`create`,
|
|
187
|
+
`comment`, `edit`, `merge`, `review`, …; `--body` and `--body-file` are input,
|
|
188
|
+
not output), `gh run`, `gh repo`, `gh workflow`, `gh release download`, `gh
|
|
189
|
+
api` on other endpoints, `--help`, and every command that does not run `gh`.
|
|
190
|
+
|
|
191
|
+
Workflow logs are allowed: `gh run view --log`, `--log-failed` and `gh api
|
|
192
|
+
…/actions/runs/<id>/logs` or `…/actions/jobs/<id>/logs` print the
|
|
193
|
+
repository's own step output, not the composite objects of this class, and
|
|
194
|
+
the two routes are treated the same. They are downloads through a redirect to
|
|
195
|
+
a signed storage URL that gh follows without printing; `--verbose` and
|
|
196
|
+
`GH_DEBUG` print that redirect, so under them the logs endpoints are refused.
|
|
197
|
+
|
|
198
|
+
## Scalar alternatives
|
|
199
|
+
|
|
200
|
+
| Instead of | Use |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `gh pr view 12` | `gh pr view 12 --json number,title,state,headRefOid,url` |
|
|
203
|
+
| reading a body | `gh pr view 12 --json body --jq '.body \| length'`, or `gh pr view 12 --web` |
|
|
204
|
+
| `gh pr checks 12` | `gh pr checks 12 --json name,state,bucket` |
|
|
205
|
+
| failing checks | `gh pr checks 12 --json name,bucket --jq '.[] \| select(.bucket == "fail") \| .name'` |
|
|
206
|
+
| `gh pr checks 12 --watch` | `gh run watch <run-id> --exit-status`, or poll `gh pr checks 12 --json bucket --jq '[.[] \| select(.bucket == "pending")] \| length'` |
|
|
207
|
+
| `gh api repos/o/r/pulls/12` | `gh api repos/o/r/pulls/12 --jq .head.sha` |
|
|
208
|
+
| `gh api -X PATCH repos/o/r/issues/12 -f state=closed` | the same with `--silent` |
|
|
209
|
+
|
|
210
|
+
gh refuses `--watch` together with `--json`, so a scalar watch is a polling
|
|
211
|
+
loop or `gh run watch`.
|
|
212
|
+
|
|
213
|
+
## Limits
|
|
214
|
+
|
|
215
|
+
- It judges the command text. A script file, a shell alias or function
|
|
216
|
+
defined in an earlier command or a startup file, a program that runs `gh`
|
|
217
|
+
itself (Python, Node, Perl, awk, `make`, a git alias) and a `gh` extension's
|
|
218
|
+
own output are not visible to it.
|
|
219
|
+
- `GH_DEBUG` set in the harness's own environment, rather than in the
|
|
220
|
+
command, is not visible to it.
|
|
221
|
+
- `curl`, `wget` and other HTTP clients calling `api.github.com` directly,
|
|
222
|
+
and `git log` or `git show` of a commit message, are outside its scope.
|
|
223
|
+
- It is not the only exposure path: workflow logs, `gh pr diff`, file
|
|
224
|
+
contents and other tools can print a link that someone wrote there. The
|
|
225
|
+
optional `signed-link-output` PostToolUse hook is the backstop for output.
|
|
226
|
+
- Codex's interactive terminal input (`write_stdin` into a running unified
|
|
227
|
+
exec session) has not been verified to pass through PreToolUse; a command
|
|
228
|
+
typed into a running shell that way may not be judged.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signed-link-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PreToolUse Signed Link Guard hook for @hasna/hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|