@hasna/hooks 0.11.7 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }