@cldmv/gh-broker 0.8.0 → 0.10.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.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@cldmv/gh-broker",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local credential-routing MCP proxy: holds per-org GitHub PATs out of the agent's reach, routes by repo owner, exposes a config-driven default-deny toolset in front of GitHub's hosted MCP.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "bin": {
8
- "gh-broker": "./dist/index.mjs"
8
+ "gh-broker": "./deploy/cli.mjs"
9
9
  },
10
10
  "main": "./dist/index.mjs",
11
+ "files": [
12
+ "dist",
13
+ "deploy",
14
+ "config.example.json",
15
+ "config.sample.json"
16
+ ],
11
17
  "engines": {
12
18
  "node": ">=22.12.0"
13
19
  },
@@ -61,12 +67,17 @@
61
67
  "dependencies": {
62
68
  "@modelcontextprotocol/sdk": "^1.32.2",
63
69
  "@octokit/auth-app": "^8.3.1",
64
- "@octokit/core": "^7.0.8"
70
+ "@octokit/core": "^7.0.8",
71
+ "qrcode": "^1.5.4"
65
72
  },
66
73
  "publishConfig": {
67
74
  "access": "public"
68
75
  },
69
76
  "allowScripts": {
70
77
  "esbuild": true
78
+ },
79
+ "overrides": {
80
+ "katex": ">=0.19.1 <1.0.0",
81
+ "minimatch": "^11.0.3"
71
82
  }
72
83
  }
package/docs/TOOLS.md DELETED
@@ -1,538 +0,0 @@
1
- # Tools reference
2
-
3
- Every tool the broker advertises falls into one of three categories, set per entry in `config.tools` (`src/types.ts` `ToolRule`) and dispatched accordingly in `router.ts`:
4
-
5
- | Category | Config flag | What happens | Example |
6
- |---|---|---|---|
7
- | **Proxied** | (neither flag) | Forwarded to the hosted GitHub MCP untouched — the advertised schema and response are exactly what the hosted MCP defines (the one addition: `issue_write`'s description carries a warning that `labels` replaces the whole set). | `list_issues`, `pull_request_read` (base behavior) |
8
- | **Custom** | `"custom": true` | Handled entirely locally — no call to the hosted MCP at all. The broker builds its own `Octokit` from the resolved PAT and does the work itself (raw REST/GraphQL, or composing several reads). | `report_unlabeled`, `report_security_alerts`, `report_release_prs`, `report_ci_status`, `issue_labels`, `npm_scaffold`, `resolve_review_thread`, `reply_review_thread`, `ghcr_push`, `ghcr_push_status`, `ghcr_tags` |
9
- | **Augmented** | `"augment": true` | Forwarded to the hosted MCP like a plain proxy, then the broker post-processes the result before returning it. | `pull_request_read`'s `ciVerbosity` extension |
10
-
11
- The broker is not obligated to make a proxied tool's advertised schema or response match the hosted MCP's exactly — see the `augment` category above. It presents its own MCP contract to the agent; a tool can extend what the hosted MCP offers.
12
-
13
- This document covers the **custom** and **augmented** tools — the ones that don't exist in the hosted MCP at all, or that gh-broker extends beyond what it offers. For everything else, see the hosted GitHub MCP's own tool documentation; gh-broker just routes the credential.
14
-
15
- ---
16
-
17
- ## `report_unlabeled`
18
-
19
- Issues and/or PRs with no labels, for one repo or every non-archived repo an owner has. Read-only, `mode: "read"`.
20
-
21
- ```json
22
- {
23
- "owner": "cldmv",
24
- "repo": "gh-broker",
25
- "target": "both",
26
- "state": "open"
27
- }
28
- ```
29
-
30
- | Param | Type | Default | Notes |
31
- |---|---|---|---|
32
- | `owner` | string | — (required) | Selects the read credential. |
33
- | `repo` | string | — | Omit to scan every non-archived repo visible to this owner's read PAT (enumerated via `GET /orgs/{owner}/repos`, falling back to `GET /users/{owner}/repos` on a 404). |
34
- | `target` | `"issues"` \| `"pulls"` \| `"both"` | `"both"` | |
35
- | `state` | `"open"` \| `"closed"` \| `"all"` | `"open"` | |
36
- | `includeDrafts` | boolean | `false` | A draft PR with no labels isn't triage-ready by default; include it explicitly if wanted. |
37
- | `maxRepos` | number | 50 (hard cap 200) | Only relevant when `repo` is omitted. |
38
- | `perRepoLimit` | number | 200 (hard cap 1000) | Items scanned per repo, per resource type. |
39
- | `maxApiCalls` | number | 250 (hard cap 1000) | Hard ceiling on total REST calls for one invocation, regardless of the two caps above. |
40
-
41
- Real example — `CLDMV/gh-broker` scanned for unlabeled issues/PRs (`state: "all"`):
42
-
43
- ```json
44
- {
45
- "scanned": { "repos": 1, "issues": 4, "pulls": 54 },
46
- "unlabeled": [
47
- { "repo": "CLDMV/gh-broker", "type": "issue", "number": 9, "title": "Deploy/runtime is Linux + systemd only, despite the app being portable Node", "url": "https://github.com/CLDMV/gh-broker/issues/9", "createdAt": "2026-08-03T02:04:22Z" }
48
- ]
49
- }
50
- ```
51
-
52
- **Fan-out safety.** Hitting a rate limit or the `maxApiCalls` ceiling mid-scan returns `truncated: true` plus a `truncatedReason` (`"rate-limited"` | `"max-api-calls"` | `"max-repos"`) and whatever was already found, rather than discarding partial results. The rate-limit floor is not a hardcoded number — every response's `x-ratelimit-limit` header is read and the scan backs off at 2% of that token's real limit (floored at an absolute minimum for low-limit tokens), so it's correct regardless of which token type is configured.
53
-
54
- ---
55
-
56
- ## `report_security_alerts`
57
-
58
- Every open Dependabot, code-scanning (CodeQL) and secret-scanning alert, for one repo or everything an owner has. Read-only, `mode: "read"`, `custom: true`.
59
-
60
- ```json
61
- {
62
- "owner": "cldmv",
63
- "types": ["dependabot", "codeql", "secrets"]
64
- }
65
- ```
66
-
67
- | Param | Type | Default | Notes |
68
- |---|---|---|---|
69
- | `owner` | string | — (required) | Selects the read credential. |
70
- | `repo` | string | — | Omit to cover every non-archived repo the owner has. When given, only the per-repo endpoints are used. |
71
- | `types` | array of `"dependabot"` \| `"codeql"` \| `"secrets"` | all three | |
72
- | `maxRepos` | number | 200 (hard cap 500) | Only relevant to a per-repo sweep (see below). |
73
- | `alertLimit` | number | 500 (hard cap 5000) | Alerts fetched per type in org mode, or per repo per type in a per-repo sweep. |
74
- | `maxApiCalls` | number | 700 (hard cap 3000) | Hard ceiling on total REST calls for one invocation. |
75
- | `summaryOnly` | boolean | `false` | Return only the per-repo roll-up and counts. An org run is about 700 KB with the alert list and a few KB without it; `alertsOmitted` says how many alerts were left out. |
76
- | `minSeverity` | `"critical"` \| `"high"` \| `"medium"` \| `"low"` | — | Keep only alerts at or above this severity. Secret-scanning alerts have no severity and are always kept. |
77
- | `repos` | string[] | — | Keep only these repos (names, with or without the owner prefix, case-insensitive). |
78
- | `classify` | boolean | `false` | Add each repo's flow tier to its roll-up row. About 3 REST calls per repo that has alerts. |
79
-
80
- **Org-level first, per-repo as the fallback.** For each requested type the verb asks the org-level endpoint (`/orgs/{owner}/dependabot/alerts`, `/code-scanning/alerts`, `/secret-scanning/alerts`) — a handful of calls for the whole owner. If that endpoint is not available to the token (a user-owned account answers 404, a PAT without org alert access answers 403), that type alone falls back to one call per repo. `scope` in the result says which path each type took, and `disabled` counts the repos where a type is switched off or unreadable (code scanning and secret scanning are off on many repos, and that is a normal answer rather than an error).
81
-
82
- **Per-repo roll-up.** Every result carries `byRepo`: one row per repo with at least one alert (after the filters), worst first (criticals, then highs, then total), with `total`, per-type counts (`dependabot`, `codeql`, `secrets`) and `critical` / `high` / `medium` / `low`. With `classify: true` each row also has `tier` and `tierSignals`:
83
-
84
- | `tier` | Meaning |
85
- |---|---|
86
- | `v4` | `.github/workflows/next-release.yml` exists on the default branch, and `next` carries its own work (or there is no `dev` branch). |
87
- | `dev-trunk` | A v4 repo with a `dev` branch that differs from the default branch while `next` is missing or equal to the default branch (scaffold only). |
88
- | `non-v4` | No `next-release.yml` on the default branch — even if `next` / `hotfixes` branches exist. |
89
-
90
- The tier is a structural signal, not a verdict. A stale `dev` branch on a repo whose `next` happens to equal `master` right after a release reads as `dev-trunk`; `tierSignals` carries the raw facts (`v4Workflow`, `defaultBranch`, `next`, `dev`, `nextEqualsDefault`, `devDiffersFromDefault`) so a caller can cross-check.
91
-
92
- Each alert is normalized to one shape: `repo`, `type`, `number`, `severity`, `summary`, `url`, `createdAt`, plus type-specific fields — Dependabot adds `package`, `ecosystem`, `manifest`, `scope`, `ghsa`, `cve`, `vulnerableRange` and `patchedVersion` (the patched version for the range that actually matches the alert); code scanning adds `rule`, `tool` and `location`; secret scanning adds `secretType` and `validity`. Results are sorted critical-first, then by repo and number. Secret scanning returns metadata only: the secret value GitHub sends back is dropped and never leaves the broker.
93
-
94
- ```json
95
- {
96
- "scanned": {},
97
- "scope": { "dependabot": "org", "codeql": "per-repo", "secrets": "per-repo" },
98
- "counts": { "dependabot": { "high": 1 }, "codeql": {}, "secrets": {} },
99
- "alerts": [
100
- { "repo": "CLDMV/gh-broker", "type": "dependabot", "number": 19, "severity": "medium", "summary": "…", "url": "https://github.com/CLDMV/gh-broker/security/dependabot/19", "createdAt": "2026-10-02T20:10:30Z", "package": "ip-address", "ecosystem": "npm", "manifest": "package-lock.json", "scope": "runtime", "ghsa": "GHSA-j6r3-76f7-8jcv", "vulnerableRange": "<= 10.7.0", "patchedVersion": "10.7.1" }
101
- ],
102
- "disabled": { "codeql": 140, "secrets": 140 }
103
- }
104
- ```
105
-
106
- **"Open" is the default-branch view.** An alert's state tracks the default branch, so one already fixed on `next` but not yet released still reads as open until the release lands.
107
-
108
- **Fan-out safety** works like `report_unlabeled`'s: hitting `maxRepos`, `alertLimit`, `maxApiCalls` or GitHub's rate limit returns what was found so far with `truncated: true` and a `truncatedReason` (`"max-repos"` | `"max-alerts"` | `"max-api-calls"` | `"rate-limited"`). A type that was never reached is left out of `counts` rather than reported as zero. A permission or feature-disabled `403`/`404` from an alert endpoint is never mistaken for a rate limit — only a `403` that carries a `Retry-After`, an exhausted `x-ratelimit-remaining`, or a rate-limit/abuse message is.
109
-
110
- ---
111
-
112
- ## `report_release_prs`
113
-
114
- Every open release PR for an owner (or one repo), and whether each one's per-version changelog file is already on the release branch. Built for mass updates: it tells an agent which repos have a release waiting and which of those still need a changelog written. Read-only, `mode: "read"`, `custom: true`.
115
-
116
- ```json
117
- {
118
- "owner": "cldmv",
119
- "onlyMissing": true
120
- }
121
- ```
122
-
123
- | Param | Type | Default | Notes |
124
- |---|---|---|---|
125
- | `owner` | string | — (required) | Selects the read credential. |
126
- | `repo` | string | — | Omit to search every repo the owner has. |
127
- | `onlyMissing` | boolean | `false` | List only PRs that are not `ready`. `summary` still counts all of them. |
128
- | `analyze` | boolean | `false` | Add an `analysis` block per release PR and a `verdicts` tally. About 5 more REST calls per release PR, so raise `maxApiCalls` for a whole org. With `onlyMissing`, "missing" then means "verdict other than `done`". |
129
- | `maxApiCalls` | number | 300 (hard cap 1000) | Hard ceiling on total REST calls for one invocation. |
130
-
131
- A release PR is an open PR carrying the `release` label, which the v4 release automation applies to both the `next → master` and the `hotfixes → master` PR. One search call covers the whole owner; each PR found then costs one call for its head/base and up to two to look for the changelog.
132
-
133
- The version is read from the `release: vX.Y.Z - …` title. The changelog is looked up **at the PR's head commit** (not the default branch), because the file lands on `next` before the release PR merges. Both `docs/changelog/v<major>/vX.Y.Z.md` and `docs/changelogs/v<major>/vX.Y.Z.md` are checked. Each PR gets one `status`:
134
-
135
- | `status` | Meaning |
136
- |---|---|
137
- | `ready` | The changelog file exists (`changelogPath` says where). |
138
- | `needs-changelog` | The version is known but no changelog file exists (`checkedPaths` lists what was looked up). |
139
- | `unknown-version` | The title has no `release: vX.Y.Z`, so there is nothing to look up. |
140
-
141
- ```json
142
- {
143
- "scanned": { "releasePrs": 2 },
144
- "summary": { "ready": 1, "needsChangelog": 1, "unknownVersion": 0 },
145
- "releasePrs": [
146
- { "repo": "CLDMV/gh-broker", "pull": 91, "url": "https://github.com/CLDMV/gh-broker/pull/91", "title": "release: v0.4.3 - …", "version": "0.4.3", "head": "next", "base": "master", "draft": false, "status": "needs-changelog", "checkedPaths": ["docs/changelog/v0/v0.4.3.md", "docs/changelogs/v0/v0.4.3.md"] }
147
- ]
148
- }
149
- ```
150
-
151
- **`analyze: true`.** Each release PR gains `analysis`: `openPrs` (everything open against its release branch — `next`, or `hotfixes` — as `doc`, `dependency`, `feature` or `draft`; a doc PR's title version is read), `freshness` when a changelog exists, a `verdict`, and a one-line `reason`.
152
-
153
- `freshness.status` compares the changelog with the PRs that actually shipped in the release (read from the release PR's commits, first line of each; docs PRs and the version bump are ignored):
154
-
155
- | `freshness.status` | Meaning |
156
- |---|---|
157
- | `current` | The heading carries the release's version and every shipped PR is mentioned. |
158
- | `minor-gap` | Every user-facing PR is mentioned; only `ci` / `chore` / `style` / `test` PRs are not (`missingInternal`). |
159
- | `stale` | At least one user-facing PR is not mentioned (`missingUserFacing`). |
160
- | `version-drift` | The changelog's heading names a different version than the release PR's current title. |
161
- | `thin` | Looks like a stub (very short, or no Overview section). |
162
-
163
- `readmeMentionsVersion` reports whether the README's What's New block names the version; it does not change the status.
164
-
165
- The `verdict` is the release-docs decision table:
166
-
167
- | `verdict` | When |
168
- |---|---|
169
- | `done` | A changelog exists and is `current` or `minor-gap`. |
170
- | `in-flight` | It needs work and a doc PR is already open against the release branch (`docVersionMismatch` is set if that PR names a different version). |
171
- | `wait` | It needs work and feature/fix PRs are still open against the release branch. |
172
- | `gray` | It needs work and only dependency PRs or drafts are open. |
173
- | `write` | No changelog exists and nothing is open against the release branch. |
174
- | `update` | A changelog exists but is stale, drifted or thin, and nothing is open against the release branch. |
175
- | `inspect` | The release PR title has no readable version. |
176
-
177
- The release PR's version can change while it is open (more PRs landing on `next` can raise the bump), so a `ready` changelog is only as current as the title it was matched against — re-run the report after `next` changes. Truncation follows the same pattern as the other report verbs; the extra reasons are `"max-results"` (more than one page of release PRs matched — GitHub's search returns 100 per page) and `"incomplete-search"` (GitHub reported the search timed out).
178
-
179
- ---
180
-
181
- ## `report_ci_status`
182
-
183
- CI health for a PR, or every open PR in a repo, at a verbosity the caller chooses. Read-only, `mode: "read"`, `custom: true`.
184
-
185
- ```json
186
- {
187
- "owner": "cldmv",
188
- "repo": "droidsock",
189
- "pull": 35,
190
- "verbosity": 3
191
- }
192
- ```
193
-
194
- | Param | Type | Default | Notes |
195
- |---|---|---|---|
196
- | `owner`, `repo` | string | — (required) | |
197
- | `pull` | number | — | Omit to scan every open PR in the repo — only PRs with a failure are included in the result. |
198
- | `verbosity` | `0`-`3` | `0` | See tiers below. |
199
- | `logLines` | number | 200 (hard cap 2000) | Tier-3 only: tail lines per failing job's log. |
200
- | `maxPulls` | number | 50 (hard cap 200) | Only relevant when `pull` is omitted. |
201
- | `maxApiCalls` | number | 250 (hard cap 1000) | |
202
-
203
- ### Verbosity tiers
204
-
205
- | Tier | Adds |
206
- |---|---|
207
- | 0 (default) | `summary: { passed, failed, pending }` only |
208
- | 1 | Which run/job failed: `workflow`, `job`, `runId`, `runUrl`, `conclusion` |
209
- | 2 | + which step in that job failed, with `durationMs` |
210
- | 3 | + a tail of the failing job's log (`logTail`) |
211
-
212
- Detail beyond tier 0 is fetched **only for runs that are actually failing** — a healthy PR costs exactly one API call regardless of the requested verbosity.
213
-
214
- **Classification.** A run, job, or step with `status !== "completed"` is `"pending"`. A completed one is `"failed"` unless its `conclusion` is `success`, `neutral`, or `skipped` — those three are the only non-blocking outcomes. Notably, a `cancelled` job (e.g. a matrix sibling killed after another leg failed) counts as **failed**, not skipped, matching how GitHub's own UI treats it as needing attention.
215
-
216
- ### Real example (tier 3, `CLDMV/droidsock#35`)
217
-
218
- A dependency-bump PR with a genuine `npm ERESOLVE` peer-dependency conflict:
219
-
220
- ```json
221
- {
222
- "pull": 35,
223
- "summary": { "passed": 6, "failed": 1, "pending": 0 },
224
- "failures": [
225
- { "workflow": "🧪 CI Tests & Build", "job": "Build & Test (24)", "runId": 34787050719, "conclusion": "cancelled", "steps": [{ "name": "Build and Test", "number": 2, "conclusion": "cancelled", "durationMs": 16000 }] },
226
- { "workflow": "🧪 CI Tests & Build", "job": "Build & Test (22.12)", "runId": 34787050719, "conclusion": "failure", "steps": [{ "name": "Build and Test", "number": 2, "conclusion": "failure", "durationMs": 8000 }], "logTail": "npm error ERESOLVE could not resolve\nnpm error While resolving: @cldmv/slothlet@3.15.1\nnpm error Found: typescript@7.0.2\n…\nnpm error peerOptional typescript@\"^6.0.3\" from @cldmv/slothlet@3.15.1\n…" }
227
- ]
228
- }
229
- ```
230
-
231
- Note the first entry (a `cancelled` matrix leg) has no `logTail` even at tier 3 — the hosted MCP's `get_job_logs` (see below) only has log content for jobs whose conclusion is exactly `"failure"`, not `"cancelled"`. A lookup miss like this yields no `logTail` on that entry, never an error for the whole report.
232
-
233
- ### Tier 3's log fetch: `get_job_logs`, not a hand-rolled REST call
234
-
235
- Tier 3 does not call GitHub's job-logs REST endpoint directly. It calls the hosted MCP's own `get_job_logs` tool (already allowlisted, plain proxied) with `failed_only: true` and the failing run's `run_id` — which returns every failing job's log **in that run** in a single call, already tailed to `logLines`. One call per failing **run**, not per failing job. This is dispatched from `router.ts`'s `makeJobLogsFetcher`, which both `report_ci_status` and the `pull_request_read` augmentation (below) share.
236
-
237
- Because this goes through the hosted MCP rather than a direct REST call, it is **not** counted by `report_ci_status`'s own `CallBudget`/rate-limit-floor (that machinery is REST-response-header-based, and an upstream-forwarded MCP call exposes no such headers). This is an accepted gap, not an oversight — the fan-out is already bounded by "one call per actually-failing run," so it can't grow unboundedly regardless.
238
-
239
- ---
240
-
241
- ## `pull_request_read` + `ciVerbosity` (augmented)
242
-
243
- A normal `pull_request_read` call (`method: "get"`) can carry CI status inline by adding `ciVerbosity`:
244
-
245
- ```json
246
- {
247
- "owner": "cldmv",
248
- "repo": "droidsock",
249
- "pullNumber": 35,
250
- "method": "get",
251
- "ciVerbosity": 1
252
- }
253
- ```
254
-
255
- `ciVerbosity` (0-3, same tiers as `report_ci_status`) and `logLines` are gh-broker's own extension on top of the hosted MCP's real `pull_request_read` schema — they're stripped before the call is forwarded, since the upstream schema doesn't know them. The response comes back with a `ci` field appended, computed from the PR's head sha already present in the forwarded response (no redundant PR lookup):
256
-
257
- ```json
258
- {
259
- "number": 35,
260
- "title": "deps: bump typescript from 6.0.3 to 7.0.2",
261
- "...": "…every other real pull_request_read field, untouched…",
262
- "ci": { "pull": 35, "summary": { "passed": 6, "failed": 1, "pending": 0 }, "failures": [ "…same shape as report_ci_status…" ] }
263
- }
264
- ```
265
-
266
- **Opt-in only.** Omitting `ciVerbosity` makes this call byte-identical to a plain forward — augmenting every `pull_request_read` by default would add an unrequested API call (and cost) to every ordinary PR read. Only `method: "get"` is augmentable; any other method (`get_diff`, `get_files`, …) forwards untouched even if `ciVerbosity` is present.
267
-
268
- **Best-effort.** If the upstream response isn't the JSON-text shape expected — a format gh-broker doesn't control — the original result is returned unmodified rather than thrown. A shape mismatch degrades to "no CI data added," never a broken PR read.
269
-
270
- ---
271
-
272
- ## `issue_labels`
273
-
274
- Edit the labels on one issue or PR, touching only the labels named. Write, `mode: "write"`, `custom: true`, runs on the write PAT (Issues: write).
275
-
276
- The hosted MCP's `issue_write` takes `labels` as a **replacement**: every label not in the list is removed. Changing one status label that way silently drops the priority, area and type labels next to it. Reading the set and writing the merged set back has a race, since a label someone else adds between the two calls is lost. `issue_labels` uses GitHub's per-label endpoints instead, so nothing outside the call is touched. The broker also appends a warning to the advertised `issue_write` description pointing here.
277
-
278
- ```json
279
- {
280
- "owner": "cldmv",
281
- "repo": "gh-broker",
282
- "issue_number": 117,
283
- "swap": { "prefix": "status: ", "to": "status: implemented" }
284
- }
285
- ```
286
-
287
- | Param | Type | Default | Notes |
288
- |---|---|---|---|
289
- | `owner` | string | — (required) | Selects the write credential. |
290
- | `repo` | string | — (required) | |
291
- | `issue_number` | integer | — (required) | An issue or a pull-request number. PRs use the same endpoints. |
292
- | `add` | string \| string[] | — | Append one or more labels (`POST …/issues/{n}/labels`, server-side append). Labels already on the issue are a no-op. |
293
- | `remove` | string \| string[] | — | Remove one or more labels (`DELETE …/issues/{n}/labels/{name}` each, using the spelling already on the issue). A label the issue doesn't have is skipped: no request, not an error. |
294
- | `swap` | `{ prefix, to }` | — | Remove every current label whose name starts with `prefix`, then add `to` (one label or several). A `to` label already on the issue is left in place rather than removed and re-added. |
295
- | `set` | string \| string[] | — | Replace the whole set (`PUT …/issues/{n}/labels`). Only as an explicit choice; cannot be combined with `add`, `remove` or `swap`. An empty `set` is refused, so it can never clear every label. |
296
- | `create_missing` | boolean | `false` | Create add-side labels that don't exist in the repo instead of refusing the call. |
297
- | `color` | string | `ededed` | 6-digit hex color (with or without `#`) for labels created by `create_missing`. Anything else is refused. |
298
-
299
- `add`, `remove` and `swap` combine freely in one call. Naming the same label on both sides is refused, and a label on the add side is never removed by a `swap` prefix match (it stays put rather than being deleted and re-added).
300
-
301
- **Strict arguments.** The handler enforces the schema itself: an unknown key (a typo such as `remvoe`), a non-integer `issue_number`, a non-string `owner`/`repo`, a non-boolean `create_missing`, an empty list or a bad `color` refuses the call before any request. Each list holds at most 100 labels (GitHub's per-issue limit), and so does the add side as a whole.
302
-
303
- **Write order and partial failure.** Writes run in this order: create missing labels, one POST for the whole add side, then the DELETEs. Adding before removing means a failure part-way never leaves the issue with fewer labels than it should have; a `swap` whose POST is rate-limited keeps its old status label. If any write fails, the error names what was applied and what was not, and keeps the HTTP status:
304
-
305
- ```
306
- issue_labels: failed part-way on cldmv/gh-broker#117: API rate limit exceeded. Applied: nothing. Not applied: added [status: verified]; removed [status: in progress].
307
- ```
308
-
309
- A failure before the first write (the read of the issue's labels, or a label lookup) is passed through unchanged; nothing was written.
310
-
311
- **Unknown labels are refused.** Every name the call would add (`add`, `swap.to`, `set`) is looked up in the repo first. If any is missing and `create_missing` is not set, the call fails with the missing names and **nothing is written**. Names to remove are not checked, since removing an absent label is already a no-op. Matching is case-insensitive, as GitHub's is, and the repo's own spelling is what gets applied.
312
-
313
- **Result.** Always the issue's resulting label set, plus what this call changed:
314
-
315
- ```json
316
- {
317
- "issue": "cldmv/gh-broker#117",
318
- "labels": ["type: enhancement", "priority: medium", "status: implemented"],
319
- "added": ["status: implemented"],
320
- "removed": ["status: not started"],
321
- "created": []
322
- }
323
- ```
324
-
325
- `added` lists only labels this call asked for that were not already there; a label someone else added meanwhile shows in `labels` but not in `added`.
326
-
327
- **Calls.** One read of the issue's current labels (which also fails a wrong issue number before anything is written), one lookup per add-side label, one create per missing label with `create_missing`, at most one POST, and one DELETE per label actually on the issue. With the 100-label caps a call makes at most a few hundred requests. `swap` and `remove` work from that first read, so a matching label added by someone else in the moments between that read and the DELETEs is not removed; nothing outside the call is ever affected.
328
-
329
- ---
330
-
331
- ## `npm_scaffold`
332
-
333
- Create a new npm package and register its GitHub Actions trusted publisher, so the repo's own publish workflow can release it through OIDC from then on. Write, `mode: "write"`, `custom: true`. It needs the optional `npm` integration (set up by the installer, see [deploy/README.md](../deploy/README.md#npm-integration-npm_scaffold)); without it, or while its token is not stored, the broker does not expose the tool at all (it is left out of the tool list, a call is refused like an unlisted tool, and startup logs the reason).
334
-
335
- npm only accepts a trusted publisher on a package that already exists, and that first publish needs a human's 2FA. The broker holds an npm granular access token created **without** 2FA bypass, and each call carries a one-time password the user reads from their authenticator. The token alone cannot write anything; the OTP is the human's approval for that one call. Ask the user for each code right before the call that uses it, since codes expire in about 30 seconds.
336
-
337
- ```json
338
- { "owner": "cldmv", "repo": "new-thing", "package": "@cldmv/new-thing", "otp": "123456" }
339
- ```
340
-
341
- | Param | Type | Default | Notes |
342
- |---|---|---|---|
343
- | `owner` | string | — (required) | A configured owner. Its **read** PAT does the GitHub reads; the verb never writes to GitHub. |
344
- | `repo` | string | — (required) | The repo whose `package.json` names the package. |
345
- | `package` | string | — (required) | Lowercase scoped name, e.g. `@cldmv/new-thing`. Must be under a scope in `config.npm.scopes`. |
346
- | `otp` | string | — (required) | 6–8 digit one-time password. Never logged or returned. |
347
- | `workflow` | string | `publish.yml` | The repo's publish workflow file in `.github/workflows`; the trusted publisher names it. |
348
-
349
- **Guardrails, checked before any npm write.**
350
-
351
- - The scope is allowed by `config.npm.scopes`.
352
- - The repo exists under exactly the owner and name given (a renamed or transferred repo, which GitHub answers with a redirect, is refused), isn't archived, its `package.json` on the default branch names exactly this package and is not `"private": true`, and `.github/workflows/<workflow>` exists there.
353
- - The package does not exist on the registry yet. A package that exists is refused unless it is this verb's own placeholder from an earlier call (a single `0.0.0-stage` version marked `scaffoldedBy: "gh-broker"`), which is how an interrupted call resumes; the placeholder's recorded repository must be the one in this call. The lookup uses `?write=true`, so it reads the registry's primary copy rather than a possibly stale cache. The verb can never publish a version of a real package.
354
-
355
- **What it writes.**
356
-
357
- 1. **Placeholder publish** (`PUT /<package>`, the request `npm publish` sends). The broker builds the tarball itself, so the agent supplies no files: a `package.json` (name, `0.0.0-stage`, the repo's `license` or `UNLICENSED`, repository, homepage, the `scaffoldedBy` marker) and a README pointing at the repo. Access is `public` for a public repo and `restricted` for a private one, the rule the CI publish uses too.
358
- 2. **Trusted publisher** (`POST /-/package/<package>/trust`, the request `npm trust github <package> --repo <owner>/<repo> --file <workflow> --allow-publish` sends). Permission `createPackage` is npm's internal name for publish.
359
-
360
- **One code per write, so two calls.** npm requires a one-time password for every write, and a code works once. The first call publishes the placeholder and returns `status: "needs_otp"` with `done: ["publish"]` and `remaining: ["trust"]`, without sending the spent code again. Ask the user for the next code (after the authenticator changes) and call again: the publish is skipped, since the placeholder now exists, and the trusted publisher is added. A refused or expired code also returns `needs_otp` with what is left, and when npm answers that the package already has a trusted publisher (HTTP 409), the verb reads the existing configuration: if it is this repo and workflow the call is done, otherwise it fails naming what is configured (and if npm wants another code just to show it, it says so and points at the package's access page).
361
-
362
- **Result.**
363
-
364
- ```json
365
- {
366
- "status": "done",
367
- "package": "@cldmv/new-thing",
368
- "repository": "CLDMV/new-thing",
369
- "workflow": "publish.yml",
370
- "access": "public",
371
- "done": ["trust"],
372
- "skipped": ["publish"],
373
- "version": "0.0.0-stage",
374
- "url": "https://www.npmjs.com/package/@cldmv/new-thing",
375
- "trustId": "…",
376
- "message": "@cldmv/new-thing exists with a trusted publisher for CLDMV/new-thing (.github/workflows/publish.yml); its next release can publish through OIDC."
377
- }
378
- ```
379
-
380
- That is the second call's result; `skipped` lists the steps an earlier call already did. Any other registry or GitHub failure fails the call with npm's or GitHub's own message; neither the OTP nor the token ever appears in a result, an error or the audit log.
381
-
382
- **Calls.** GitHub: one repo read and two file reads (read PAT). npm: one package lookup, at most one publish and one trust request.
383
-
384
- ---
385
-
386
- ## `resolve_review_thread` / `reply_review_thread`
387
-
388
- Resolve/unresolve, or reply inside, a pull-request review thread (e.g. addressing a Copilot review comment) — gaps the hosted MCP has no tool for. Both run on the **write** PAT (`Pull requests: write`), via the `resolveReviewThread`/`unresolveReviewThread`/`addPullRequestReviewThreadReply` GraphQL mutations.
389
-
390
- ```json
391
- { "owner": "cldmv", "threadId": "PRRT_kwDO…", "resolve": true }
392
- ```
393
-
394
- `threadId` is the review-thread **node id**, taken verbatim from `pull_request_read`'s `get_review_comments` method (each returned thread carries it as `id`). `resolve_review_thread` defaults to resolving; pass `resolve: false` to unresolve. `reply_review_thread` takes the same `threadId` plus a `body` (Markdown).
395
-
396
- ---
397
-
398
- ## `ghcr_push`
399
-
400
- Push a container image to `ghcr.io/<owner>/<image>:<tag>` from a `docker save` archive, without the agent ever holding a registry credential. Write, `mode: "write"`, `custom: true`. It runs on the owner's **GHCR credential** from `config.ghcr` (a classic PAT with `write:packages`), not on the owner's GitHub PATs: GHCR does not accept fine-grained PATs. The three `ghcr_*` tools are only exposed when `config.ghcr` is present and at least one of its owners' credentials is stored; otherwise they are left out of the tool list, refused like an unlisted tool, and startup logs why.
401
-
402
- ```sh
403
- # as the agent
404
- docker save your-image:1.4.0 -o /srv/gh-broker/ghcr-drop/your-image-1.4.0.tar
405
- chmod g+r /srv/gh-broker/ghcr-drop/your-image-1.4.0.tar
406
- ```
407
-
408
- ```json
409
- { "owner": "your-org", "image": "your-image", "tag": "1.4.0", "archive": "your-image-1.4.0.tar" }
410
- ```
411
-
412
- | Param | Type | Default | Notes |
413
- |---|---|---|---|
414
- | `owner` | string | — (required) | Must be configured in `config.ghcr.owners` (registry credential + allowlist), with its credential stored. No `config.owners` entry or GitHub PAT is needed. |
415
- | `image` | string | — (required) | The name under `ghcr.io/<owner>/`, e.g. `your-image`. Lowercase OCI path syntax. Must match one of `config.ghcr.owners.<owner>.images`. |
416
- | `tag` | string | — (required) | OCI tag grammar: `[A-Za-z0-9_][A-Za-z0-9._-]{0,127}`. |
417
- | `archive` | string | — (required) | Path to the archive inside `config.ghcr.dropDir`, absolute or relative to it. |
418
-
419
- The call validates everything, exchanges the PAT for a registry token, checks a protected tag, and then **returns at once** with a job id; the upload itself runs in the background (images are 1–3 GB). Poll [`ghcr_push_status`](#ghcr_push_status).
420
-
421
- ```json
422
- {
423
- "owner": "your-org",
424
- "image": "ghcr.io/your-org/your-image",
425
- "tag": "1.4.0",
426
- "archive": "/srv/gh-broker/ghcr-drop/your-image-1.4.0.tar",
427
- "archiveBytes": 1288490188,
428
- "format": "oci-layout",
429
- "job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11",
430
- "state": "queued",
431
- "createdAt": "2026-10-31T18:02:11.412Z"
432
- }
433
- ```
434
-
435
- **Archive formats.** Both outputs of `docker save` are read: the OCI image layout written by Docker 25+ (`index.json` + `blobs/sha256/…`, also what `docker buildx build --output type=oci` writes) and the older docker-archive (`manifest.json` + `<id>/layer.tar`). An archive must hold exactly one single-platform image; buildx attestation manifests are ignored. Layers that are already gzip- or zstd-compressed are pushed byte-for-byte, and so is the manifest when nothing had to change. Layers stored uncompressed (what `docker save` writes for the classic image store) are gzipped while they upload, and the manifest is then written as an OCI image manifest.
436
-
437
- **Push flow.** The OCI distribution API, driven from Node with no docker socket and no extra binaries: for each blob a `HEAD` (skip it when the registry already has it), otherwise `POST` an upload, `PATCH` the bytes, and `PUT` with the digest; then `PUT` the manifest under the tag. To make the `HEAD` check work for layers it compresses, the broker gzips each such layer once to learn its digest before uploading it, so re-pushing an unchanged image uploads nothing. That costs a second pass of CPU, but nothing is buffered in memory or written to disk. An expired registry token mid-push triggers one fresh token exchange.
438
-
439
- **Refusals, all before any upload.**
440
-
441
- | Check | Refused when |
442
- |---|---|
443
- | Arguments | an unknown key, a missing or mistyped field, an invalid image name or tag |
444
- | Owner | not in `config.ghcr.owners` |
445
- | Image | not on the owner's `images` allowlist (`*` matches within one path segment, never across `/`) |
446
- | Archive path | outside `dropDir` (checked on the path text before anything is opened) |
447
- | Archive file | a symlink, reached through a symlinked directory that leads outside `dropDir`, a hard link (`nlink > 1`), a FIFO, device or directory, empty, larger than `maxArchiveBytes`, or not readable by the broker |
448
- | Archive content | not an image archive, several images, an unsupported or foreign layer, a blob that is missing or doesn't match its digest or size |
449
- | Credential | the token exchange fails (expired PAT, missing `write:packages`) |
450
- | Release tag | `protectReleaseTags` is on, the tag looks like `1.2.3` / `v1.2.3`, and it already exists. Checked at submit and again right before the manifest `PUT`, so two queued pushes of the same new release tag can't both land |
451
-
452
- **Queue.** Jobs run one at a time in submission order. At most 8 can be queued or running; a ninth push is refused until one finishes. Job state is kept in memory only, so a broker restart forgets every job. A push interrupted that way leaves nothing tagged, because the tag only moves with the final manifest `PUT`.
453
-
454
- **Audit.** Two audit lines per push, besides the router's usual one: `queued` (job id, tag, archive path, size, format, layer count) and the outcome (job id, tag, manifest digest, size, blobs uploaded and skipped, archive path, or the error).
455
-
456
- **What it can't do.** Talk to any registry but `ghcr.io`, push outside `ghcr.io/<owner>/<allowlisted image>`, delete a package or version, change visibility, or read a file outside the drop directory. The broker can't delete the archive afterwards either (the drop directory belongs to the agent), so clean it up yourself.
457
-
458
- ---
459
-
460
- ## `ghcr_push_status`
461
-
462
- Progress of a `ghcr_push` job, then its result. Read, `mode: "read"`, `custom: true`. No network call.
463
-
464
- ```json
465
- { "owner": "your-org", "job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11" }
466
- ```
467
-
468
- Omit `job` to list this owner's recent jobs (the newest 50 finished jobs are kept, plus everything queued or running). A job id from another owner reads as unknown.
469
-
470
- ```json
471
- {
472
- "job": "4c0f2a8e-61b1-4f3e-9b0e-5d1a7e9b2c11",
473
- "state": "succeeded",
474
- "image": "ghcr.io/your-org/your-image",
475
- "tag": "1.4.0",
476
- "progress": { "phase": "done", "layersTotal": 9, "blobsUploaded": 4, "blobsSkipped": 6, "bytesTotal": 431209876, "bytesSent": 118220311 },
477
- "result": { "digest": "sha256:9f2c…", "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 431209876, "blobsUploaded": 4, "blobsSkipped": 6 }
478
- }
479
- ```
480
-
481
- `state` is `queued`, `running`, `succeeded` or `failed` (then `error` says why). `progress.phase` moves through `preparing` (computing digests of layers that will be compressed), `uploading`, `manifest` and `done`. `bytesTotal` covers every blob the image references; `bytesSent` counts only what was actually uploaded.
482
-
483
- ---
484
-
485
- ## `ghcr_tags`
486
-
487
- List the tags of an allowlisted image, e.g. to check a push landed. Read, `mode: "read"`, `custom: true`. Runs on the GHCR credential with a pull-only registry token.
488
-
489
- ```json
490
- { "owner": "your-org", "image": "your-image" }
491
- ```
492
-
493
- ```json
494
- { "image": "ghcr.io/your-org/your-image", "exists": true, "tags": ["1.3.2", "1.4.0", "latest"] }
495
- ```
496
-
497
- `exists: false` with an empty list means the repository hasn't been pushed yet. The same allowlist as `ghcr_push` applies, so this can't be used to probe other images.
498
-
499
- ---
500
-
501
- ## GHCR configuration and threat model
502
-
503
- ```json
504
- "ghcr": {
505
- "dropDir": "/srv/gh-broker/ghcr-drop",
506
- "protectReleaseTags": true,
507
- "owners": {
508
- "your-org": { "username": "your-login", "credential": "cred:your-org-packages", "images": ["your-image", "your-image-worker-*"] }
509
- }
510
- }
511
- ```
512
-
513
- | Key | Default | Notes |
514
- |---|---|---|
515
- | `dropDir` | — (required) | Absolute, normalized, at least two levels deep, and not under `/etc`, `/run`, `/home`, `/tmp`, `/root` or other system paths. The unit runs with `ProtectHome=true` and `PrivateTmp=true`, so `/srv` is the natural place. The installer creates it `2750 <agent>:gh-broker`. |
516
- | `protectReleaseTags` | `true` | Refuse to overwrite an existing tag matching `^v?\d+\.\d+\.\d+$`. |
517
- | `maxArchiveBytes` | 16 GiB | Largest archive accepted. |
518
- | `owners.<o>.username` | — (required) | GitHub login the classic PAT belongs to; sent as the Basic-auth user in the token exchange. |
519
- | `owners.<o>.credential` | — (required) | `cred:<name>` ref to a classic PAT with `write:packages` only. The credential itself is optional: while no owner's credential is stored the `ghcr_*` tools are not exposed, and a call for an owner whose credential is missing is refused. |
520
- | `owners.<o>.images` | — (required) | Allowlist of image names under `ghcr.io/<o>/`. `*` matches `[a-z0-9._-]*` within one segment. |
521
-
522
- Unknown keys anywhere in the block are refused. The block is operator data, not shipped policy: `config.example.json` carries none of it. Set it with `--ghcr-owner <org> --ghcr-username <login> --ghcr-image <glob>` (plus `--ghcr-drop-dir`, `--ghcr-max-archive-bytes`, `--[no-]protect-release-tags`) or with a `ghcr` block in an installer `--config` file, which is the way to configure several owners. The installer keeps the deployed block on upgrade. See [`deploy/README.md`](../deploy/README.md#configuring-the-install).
523
-
524
- **Threat model.** The agent is untrusted with credentials but trusted to decide what to push within the allowlist.
525
-
526
- - *Credential exfiltration.* The PAT is sent only in the Basic-auth header of `https://ghcr.io/token`. The registry token is sent only to `https://ghcr.io`: every URL the registry returns (upload `Location`, pagination `Link`) is refused unless it is on that origin, and redirects are never followed. The registry host is a constant, not configuration. Every error message passes through a scrubber that replaces both secret values and anything shaped like an auth header or a GitHub token, and registry error bodies are reduced to their `code: message` pairs. Results, audit lines and log lines are built from the same scrubbed strings.
527
- - *Confused-deputy file reads.* The broker can read files the agent can't, including its own credentials. Pushing such a file as a "layer" would put it in a registry the agent can pull from. So the archive path is checked as text first, then opened once with `O_NOFOLLOW | O_NONBLOCK`, and the open descriptor is checked: a regular file with a single link, whose real path (`/proc/self/fd/<n>`) is inside the real drop directory. Every later read is a positional read on that descriptor. Links inside the tar are resolved against the tar's own members only.
528
- - *Reach.* Push and list only, only to allowlisted images of configured owners. No delete, no visibility changes. The PAT should carry `write:packages` and nothing else (`write:packages` implies `read:packages`). `delete:packages` must not be granted.
529
- - *Resource abuse.* Archive size, tar member count, JSON member size, layer count and the job queue are all bounded.
530
- - *Release integrity.* With `protectReleaseTags`, a published `1.2.3` can't be replaced, so a compromised agent can't swap a released image. Mutable tags such as `latest` can still move; keep the allowlist tight.
531
-
532
- ---
533
-
534
- ## Extending this: adding a new custom verb or augmented tool
535
-
536
- - **Custom verb**: implement in `src/verbs/<name>.ts` (export the function + a `*_SCHEMA` JSON Schema constant), add a `case` in `router.ts`'s `dispatchCustom`, advertise it in `catalog.ts`'s `if (rule.custom)` branch, and add a `{ "name": ..., "mode": ..., "custom": true }` entry to `config.example.json`.
537
- - **Augmented tool**: add a `case` in `router.ts`'s `dispatchAugmented` that forwards via `this.upstream.forward(...)` and post-processes the result, layer the extra schema properties onto the real fetched schema in `catalog.ts` (see the `pull_request_read` branch for the pattern), and mark the allowlist entry `"augment": true` instead of `"custom": true` — the two are mutually exclusive.
538
- - If the new verb needs data another proxied tool already provides (like `report_ci_status`'s tier 3 needing `get_job_logs`), give `dispatchCustom`/`dispatchAugmented` access to `this.upstream` and inject a fetcher function into the verb rather than reimplementing the underlying REST call — see `router.ts`'s `makeJobLogsFetcher` for the pattern.