@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/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 (meta3.matcher) {
39850
- entry.matcher = meta3.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 === "^(Bash|apply_patch|ApplyPatch|functions\\.apply_patch)$" && handler.timeout === 10 && (handler.async === undefined || handler.async === false) && handler.asyncRewake !== true, "guard_definition_invalid");
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
- export declare const planNativeReadinessRegistration: (options: NativeRegistrationOptions) => Promise<NativeRegistrationPlan>;
56
- export declare const applyNativeReadinessRegistration: (options: NativeRegistrationOptions & {
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;
@@ -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[];
@@ -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
@@ -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 record = { schema: "hasna.hooks.native-safety.v1", harness: "sumi", command: options.command ?? installedNativeSafetyCommand("trash-guard") };
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
+ }