From 7ad41dae3c52c369210292c3207bf020a4edcd3a Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Mon, 3 Aug 2026 18:46:20 -0400 Subject: [PATCH 1/4] Prepare repo for open-source release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the open-source readiness audit in #107. Add Apache-2.0 LICENSE. The repo previously had no license, which meant publishing it would not have made it open source — default copyright would apply and nobody could use, modify, or redistribute it. Remove the hardcoded repo1.dso.mil default host. A default host makes misconfiguration silent: a user whose config failed to load still got a working-looking glk pointed at an instance they never chose. It also weakened the pinned-host safety invariant, which is only meaningful when the operator picked the pin. The host is now required and validated at the two request chokepoints (Client.Get, Client.getStream) rather than at startup, so offline commands — init, config, doctor, version, install-skills, install-hooks — keep working. That ordering matters: doctor is what you run to diagnose a missing host. grec checkout gained the same guard; without it, an unconfigured checkout built a hostless clone URL (https:///group/project.git) and handed it to git. doctor now reports a missing host as a problem rather than printing an empty value with "(source: default)". Remove gitlab-kiosk-prd.md and docs-local/ — internal product and ops notes framed around a specific engagement. ARCHITECTURE.md already carries the design rationale a contributor needs, and now documents why there is no default host. Fix packaging for unauthenticated users: the Homebrew formula and Makefile tap URL used SSH, which only worked for someone with push access. Both use HTTPS now, the formula declares its license, and the README drops the GOPRIVATE instructions and a hardcoded personal checkout path. Point the work-items search help at docs.gitlab.com instead of a host-specific /help URL, and neutralize the skill file's framing. Crow-Session: E3CF07BB-A433-4ABA-9A23-2A3FB3B61671 Co-Authored-By: Claude --- .envrc.example | 4 +- ARCHITECTURE.md | 18 +- Formula/gitlab-kiosk.rb | 5 +- LICENSE | 202 +++++++ Makefile | 2 +- README.md | 34 +- ...macos-go-test-approval-repro-2026-04-13.md | 186 ------ gitlab-kiosk-prd.md | 561 ------------------ glkcli/config_test.go | 16 +- glkcli/doctor.go | 22 +- glkcli/doctor_test.go | 49 +- glkcli/init.go | 5 +- glkcli/work_items.go | 2 +- greccli/checkout.go | 3 + greccli/checkout_test.go | 23 + greccli/locate_test.go | 4 +- internal/config/config.go | 6 +- internal/config/config_test.go | 8 +- internal/gitlab/client.go | 16 +- internal/gitlab/client_test.go | 26 + skill/SKILL.md | 8 +- skill/SKILL.md.tmpl | 8 +- 22 files changed, 397 insertions(+), 811 deletions(-) create mode 100644 LICENSE delete mode 100644 docs-local/macos-go-test-approval-repro-2026-04-13.md delete mode 100644 gitlab-kiosk-prd.md diff --git a/.envrc.example b/.envrc.example index 89d6066..dd45e11 100644 --- a/.envrc.example +++ b/.envrc.example @@ -3,8 +3,8 @@ # Optional: GitLab token for rate-limit relief (public-only access still enforced) # export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx -# Optional: Go private module proxy bypass -# export GOPRIVATE=github.com/radiusmethod/* +# Optional: GitLab host, overriding gitlab.host in config.toml +# export GITLAB_HOST=gitlab.com # Optional repo-local shell helpers. # If copied to `.envrc`, this makes `got ...` available as the signed `go test` helper. source_env_if_exists "$PWD/shell/go-tools.sh" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index de40381..5a9fcc0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,8 +29,6 @@ Two Go binaries from one repo (`cmd/glk/main.go`, `cmd/grec/main.go`), invoked via `Bash(glk *)` and `Bash(grec *)`, discovered by Claude via Skill files. Raw HTTP to GitLab API (no glab dependency). -Full PRD: `gitlab-kiosk-prd.md` - ### Directory layout ``` @@ -63,7 +61,7 @@ internal/index/ # repo index + spider (grec only) - **Read-only** — Only HTTP GET requests. The binary has no codepath for any other HTTP method. GraphQL queries use GET with query parameters (the GraphQL spec allows GET for queries; mutations require POST, which the client structurally cannot send). `grec checkout` runs `git clone --depth 1` which is also read-only against the remote. - **Public-only** — Verify `visibility == "public"` before returning any project data. This is the durable safety promise. - **Known-safe cache is allowlist-only** — `glk` persists short-lived SQLite snapshots of objects it has already proved public: namespace ids plus descendant public projects. It never writes a durable denylist of private or internal objects. Unknown stays unknown; stale or failed refreshes fail closed. -- **Pinned host** — All HTTP requests go to the configured GitLab host (default: `https://repo1.dso.mil`). Redirects to other hosts are rejected. +- **Pinned host** — All HTTP requests go to the explicitly configured GitLab host. There is no default host; an unconfigured `glk` refuses to make network calls. Redirects to other hosts are rejected. - **No credentials by default** — No auth headers unless a token is explicitly configured for rate-limit relief. The tool enforces public-only regardless of token scope. ### Naming @@ -97,7 +95,7 @@ MCP tool schemas are injected into Claude's context on every turn — 10 tool de ### Why raw HTTP instead of wrapping glab -The `glab` CLI is a general-purpose GitLab client that wants to send credentials by default. Wrapping it would mean: (1) fighting its auth system to enforce our no-credentials invariant, (2) requiring `glab` as a runtime dependency on every machine, and (3) shelling out from Go to a CLI just to make HTTP GET requests — three layers of wrapping for something that's just `GET https://host/api/v4/...`. With raw `net/http`, the safety invariants are structural: the host is hardcoded in code, the method is hardcoded, and there are no credential headers to leak. +The `glab` CLI is a general-purpose GitLab client that wants to send credentials by default. Wrapping it would mean: (1) fighting its auth system to enforce our no-credentials invariant, (2) requiring `glab` as a runtime dependency on every machine, and (3) shelling out from Go to a CLI just to make HTTP GET requests — three layers of wrapping for something that's just `GET https://host/api/v4/...`. With raw `net/http`, the safety invariants are structural: the host is pinned to the configured value on every request, the method is hardcoded, and there are no credential headers to leak. ### Why an overly-privileged token is safe here @@ -175,3 +173,15 @@ Hints are registered as closures during `RunE` and flushed after `ExecuteC` retu 3. **Out-of-band** — Hints go to stderr with a `btw:` prefix. Stdout stays clean for machine-readable output. Silence is the common case. When adding a `btw` hint, place it immediately before the expected agent failure. Prefer concrete next commands over abstract advice. A second call to `Register` on the same command overwrites the previous callback — this is intentional because cobra reuses command objects across test runs. + +### Why there is no default GitLab host + +`glk` originally defaulted `gitlab.host` to `repo1.dso.mil`, the Platform One GitLab instance it was first built against. That default was convenient for one deployment and wrong for every other one, in two ways. + +The practical problem: a default host makes misconfiguration silent. A user whose config file failed to load, or who typo'd the key name, still got a working-looking `glk` — one that quietly queried an instance they never asked for. The failure surfaced as a confusing 404 against an unfamiliar host rather than "you have not configured a host." + +The structural problem: "pinned host" is a safety invariant, and an implicit default weakens it. Pinning is only meaningful when the operator chose the pin. Requiring the host makes the invariant honest — every request goes to a host someone deliberately configured, and there is no ambient value to fall back to. + +The host is therefore required, and validated at the two request chokepoints (`Client.Get` and `Client.getStream`) rather than at startup. Enforcing at the chokepoint means commands that need the network fail with an actionable error, while offline commands — `init`, `config`, `doctor`, `version`, `install-skills`, `install-hooks` — keep working. That ordering matters: `glk doctor` is the command you run to diagnose a missing host, so it must not itself require one. `grec checkout` performs the same check before building a clone URL, since a hostless URL (`https:///group/project.git`) would otherwise reach `git`. + +`repo1.dso.mil` remains a valid, documented example. It is no longer a default. diff --git a/Formula/gitlab-kiosk.rb b/Formula/gitlab-kiosk.rb index 2374ef3..e70da0b 100644 --- a/Formula/gitlab-kiosk.rb +++ b/Formula/gitlab-kiosk.rb @@ -1,10 +1,11 @@ class GitlabKiosk < Formula desc "Read-only GitLab CLI with local repo cache helpers" homepage "https://github.com/radiusmethod/gitlab-kiosk" - url "ssh://git@github.com/radiusmethod/gitlab-kiosk.git", + url "https://github.com/radiusmethod/gitlab-kiosk.git", tag: "v0.34.14", revision: "d6c74a7224fefe1f5b3346e0c8eadb84d1ef0a33" - head "ssh://git@github.com/radiusmethod/gitlab-kiosk.git", branch: "main" + license "Apache-2.0" + head "https://github.com/radiusmethod/gitlab-kiosk.git", branch: "main" depends_on "go" => :build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cce335f --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Radius Method, LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index ca1928e..dedfe8b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ GOLANGCI_LINT_VERSION := v2.9.0 BREW_TAP_NAME := radiusmethod/gitlab-kiosk BREW_FORMULA_NAME := gitlab-kiosk -BREW_TAP_GITHUB_URL := git@github.com:radiusmethod/gitlab-kiosk.git +BREW_TAP_GITHUB_URL := https://github.com/radiusmethod/gitlab-kiosk.git BREW_TAP_LOCAL_PATH := $(CURDIR) .PHONY: build build-glk build-grec install uninstall \ diff --git a/README.md b/README.md index 0c9979b..1e79922 100644 --- a/README.md +++ b/README.md @@ -9,25 +9,23 @@ Two Go binaries that give Claude safe, structured access to public GitLab reposi - **`glk`** — Talks to the GitLab API. Search projects, read issues and MRs, browse files, check pipelines. - **`grec`** — Manages a local repo cache. Index your existing clones, look up paths, shallow-clone new repos. -Both enforce a strict safety model: GET-only HTTP, public-only visibility checks, pinned host, no credentials by default. The default host is `repo1.dso.mil`. +Both enforce a strict safety model: GET-only HTTP, public-only visibility checks, pinned host, no credentials by default. The GitLab host is required — there is no default — so every request goes to an instance you explicitly configured. ## Installation -### With Homebrew from this private repo +### With Homebrew -MVP tap shape: this repo carries its own `Formula/gitlab-kiosk.rb`, and Homebrew builds both binaries locally from source. That avoids macOS Gatekeeper complaints about downloaded unsigned release binaries. - -If your GitHub SSH access is already working: +This repo carries its own `Formula/gitlab-kiosk.rb`, and Homebrew builds both binaries locally from source. That avoids macOS Gatekeeper complaints about downloaded unsigned release binaries. ```sh -brew tap radiusmethod/gitlab-kiosk git@github.com:radiusmethod/gitlab-kiosk.git +brew tap radiusmethod/gitlab-kiosk https://github.com/radiusmethod/gitlab-kiosk.git brew install --build-from-source radiusmethod/gitlab-kiosk/gitlab-kiosk ``` -For local formula iteration from this checkout: +For local formula iteration from a checkout of this repo: ```sh -brew tap radiusmethod/gitlab-kiosk /Users/daniel/radius/gitlab-kiosk +brew tap radiusmethod/gitlab-kiosk "$(pwd)" brew install --build-from-source radiusmethod/gitlab-kiosk/gitlab-kiosk ``` @@ -56,16 +54,11 @@ make brew-untap-local ### From source -This is a private repo, so the Go module proxy can't reach it. Set `GOPRIVATE` to bypass the proxy and pull directly from GitHub: - ```sh -export GOPRIVATE=github.com/radiusmethod/* go install github.com/radiusmethod/gitlab-kiosk/cmd/glk@latest go install github.com/radiusmethod/gitlab-kiosk/cmd/grec@latest ``` -If you use direnv, add the `GOPRIVATE` line to your `.envrc` so it's always set (see `.envrc.example`). - Remove the `go install` copies with: ```sh @@ -78,10 +71,7 @@ Download the binaries for your platform from the [releases page](https://github. ### With mise -Same `GOPRIVATE` requirement as `go install` — mise's Go backend also routes through the module proxy, which can't access private repos: - ```sh -export GOPRIVATE=github.com/radiusmethod/* mise use -g go:github.com/radiusmethod/gitlab-kiosk/cmd/glk mise use -g go:github.com/radiusmethod/gitlab-kiosk/cmd/grec ``` @@ -230,16 +220,18 @@ glk issues big-bang/bigbang --limit 5 ## Configuration -Both binaries read `$XDG_CONFIG_HOME/glk/config.toml` (defaults to `~/.config/glk/config.toml`). +Both binaries read `$XDG_CONFIG_HOME/glk/config.toml` (defaults to `~/.config/glk/config.toml`). Run `glk init` to scaffold one. + +`gitlab.host` is **required** — there is no default host. Set it to the GitLab instance you want to read from (for example `gitlab.com`, or `repo1.dso.mil` for Platform One). Commands that make network calls fail with an actionable error until it is set; `glk doctor` reports it as a problem. ```toml [gitlab] -host = "repo1.dso.mil" +host = "gitlab.com" # Optional: per-host tokens for rate-limit relief. # Public-only access is still enforced regardless of token scope. [gitlab.tokens] -"repo1.dso.mil" = "glpat-xxxxxxxxxxxxxxxxxxxx" +"gitlab.com" = "glpat-xxxxxxxxxxxxxxxxxxxx" [output] limit = 20 @@ -253,7 +245,7 @@ cache_dir = "~/.cache/glk/repos" depth = 1 ``` -The `GITLAB_TOKEN` environment variable overrides all configured tokens. Set `GITLAB_HOST` to override the configured GitLab host. Override the config file path with `GLK_CONFIG`. +The `GITLAB_TOKEN` environment variable overrides all configured tokens. `GITLAB_HOST` sets the GitLab host, overriding the config file. Override the config file path with `GLK_CONFIG`. `glk` also keeps a short-lived known-safe cache at `$XDG_CACHE_HOME/glk/known-safe.sqlite` (defaults to `~/.cache/glk/known-safe.sqlite`). It stores only namespace and project entries already verified public, with a 60-second TTL. @@ -309,4 +301,4 @@ Lefthook runs `go vet`, `go test`, and `golangci-lint` before each push. Same co ## License -See [LICENSE](LICENSE). +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/docs-local/macos-go-test-approval-repro-2026-04-13.md b/docs-local/macos-go-test-approval-repro-2026-04-13.md deleted file mode 100644 index 2df8673..0000000 --- a/docs-local/macos-go-test-approval-repro-2026-04-13.md +++ /dev/null @@ -1,186 +0,0 @@ -# macOS managed execution-policy repro notes - 2026-04-13 - -## Goal - -Reproduce the real operator pain on managed macOS: - -- repeated "`You don't have permission to use the application ...`" popups -- especially during normal Go development -- capture the policy evidence around `syspolicyd`, `FamilyControls`, and `ManagedClient` - -## What was reproduced - -### 1. Rebuilt `glk` triggers the real popup churn - -Unsigned build: - -```sh -go build -o ./bin/glk ./cmd/glk -./bin/glk help-all -``` - -Ad-hoc signed build: - -```sh -go build -o ./bin/glk ./cmd/glk -codesign --force --sign - --identifier com.radiusmethod.glk ./bin/glk -./bin/glk help-all -``` - -Observed on April 13, 2026: - -- real GUI popup: "`You don't have permission to use the application 'glk'`" -- popup repeated even after choosing `Always Allow` -- ad-hoc signing changed the policy-visible identifier but did not stop the popup storm - -Strong log lines from the popup window: - -```text -2026-04-13 15:34:57 CDT syspolicyd Error Domain=GatekeeperPolicyScanError Code=-67018 "Code did not match any currently allowed policy" -2026-04-13 15:34:57 CDT syspolicyd GK evaluateScanResult ... (team: (null)), (id: com.radiusmethod.glk), (bundle_id: NOT_A_BUNDLE) -2026-04-13 15:34:24 CDT mcxalr FCAppIsSigned -- SecStaticCodeCreateWithPath returned error 100013 -``` - -Inference: ad-hoc signing stabilizes `id`, but policy still sees weak identity: - -- `team: (null)` -- `bundle_id: NOT_A_BUNDLE` -- repeated `FCAppIsSigned` failures - -That is enough to explain why `Always Allow` does not appear to stick. - -### 2. Unsigned `go test` shows the unstable identity problem clearly - -Command: - -```sh -go test ./... -count=1 -``` - -Strong log lines: - -```text -2026-04-13 15:31:54 CDT syspolicyd GK evaluateScanResult ... (team: (null)), (id: a.out), (bundle_id: NOT_A_BUNDLE) -2026-04-13 15:31:55 CDT syspolicyd GK evaluateScanResult ... (team: (null)), (id: a.out), (bundle_id: NOT_A_BUNDLE) -``` - -This is the important finding for general Go pain: - -- transient `go test` binaries are seen as `id: a.out` -- every new test binary looks like weak or unstable executable identity -- that matches the real-world approval churn pattern - -### 3. Signed `go test -exec` fixes the unstable identity part - -Wrapper-based run: - -```sh -go test -exec "$PWD/shell/go-test-codesign-exec.sh" ./... -count=1 -``` - -Strong log lines: - -```text -2026-04-13 15:36:12 CDT syspolicyd GK evaluateScanResult ... (team: (null)), (id: com.radiusmethod.glk.go-test), (bundle_id: NOT_A_BUNDLE) -2026-04-13 15:36:13 CDT syspolicyd GK evaluateScanResult ... (team: (null)), (id: com.radiusmethod.glk.go-test), (bundle_id: NOT_A_BUNDLE) -``` - -So the wrapper does work for the narrow thing it is supposed to do: - -- it replaces `id: a.out` -- with a stable custom identifier - -It does **not** give you a team identifier or bundle identity. It is still ad-hoc signing. - -## Important validation: sandbox artifact vs host reality - -One run inside the Codex sandbox failed with: - -```text -httptest: failed to listen on a port: bind: operation not permitted -``` - -That was **not** a host-level macOS policy failure. - -The same signed wrapper run was repeated outside the sandbox and passed: - -```sh -go test -exec "$PWD/shell/go-test-codesign-exec.sh" ./... -count=1 -``` - -Result on the real host: - -```text -ok github.com/radiusmethod/gitlab-kiosk/glkcli -ok github.com/radiusmethod/gitlab-kiosk/internal/gitlab -... -``` - -Conclusion: - -- real host-level problem: popup churn for rebuilt executables like `glk` -- sandbox-only problem: `bind: operation not permitted` during one wrapped test run - -## Tightest practical conclusion - -There are two separate stories: - -1. **Standalone rebuilt binaries** like `./bin/glk` - - ad-hoc signing changes the identifier - - but did not stop the real approval popups on this managed Mac - - next serious fix is a real local signing identity, not ad-hoc signing - -2. **`go test` transient binaries** - - unsigned runs show `id: a.out` - - signed `-exec` wrapper gives a stable identifier - - this is the best reusable mitigation for everyday Go work - -## Reusable workaround for general Go repositories - -Use the checked-in helper in `shell/`: - -```sh -source ./shell/go-tools.sh -got ./... -count=1 -``` - -Or directly: - -```sh -GO_TEST_CODESIGN_ID="local.go-test.myrepo" \ - go test -exec "$PWD/shell/go-test-codesign-exec.sh" ./... -count=1 -``` - -Behavior: - -- each transient test binary is ad-hoc signed just before execution -- the signature uses a stable identifier -- default identifier is derived from the current directory name -- override with `GO_TEST_CODESIGN_ID` when you want a durable name - -## Recommended general-case setup - -For reuse across many repos, put the wrapper somewhere stable such as `~/bin/go-test-codesign-exec` and add a shell function: - -```sh -got() { - GO_TEST_CODESIGN_ID="local.go-test.$(basename "$PWD")" \ - go test -exec "$HOME/bin/go-test-codesign-exec" "$@" -} -``` - -Then use: - -```sh -got ./... -count=1 -``` - -## When this workaround is not enough - -If you still get popup churn after stabilizing the test binary identifier, the next step is not more ad-hoc signing. The next step is: - -- create a local self-signed code-signing certificate -- sign with that identity instead of `--sign -` -- rerun the same A/B with the same log predicates - -That is the likely path to something macOS policy can remember more reliably than ad-hoc signatures. diff --git a/gitlab-kiosk-prd.md b/gitlab-kiosk-prd.md deleted file mode 100644 index dd8c94b..0000000 --- a/gitlab-kiosk-prd.md +++ /dev/null @@ -1,561 +0,0 @@ -# PRD: gitlab-kiosk (`glk`) - -## Summary - -`gitlab-kiosk` is a read-only, public-only CLI tool for browsing `repo1.dso.mil` (Platform One / DoD GitLab) from Claude Code sessions. It replaces a custom MCP server that was blowing out the context window with tool schema overhead. The binary is `glk`. - -Think of it as a stripped-down, safety-hardened, token-efficient `glab` that only does reads against a single public GitLab instance. - ---- - -## Problem - -Claude Code needs frequent read access to `repo1.dso.mil` during Platform One / Big Bang work. The existing approaches all have problems: - -| Approach | Problem | -|----------|---------| -| `Fetch` / `WebFetch` | Requires per-call user approval; general-purpose; can hit any URL | -| `glab api` via Bash | Requires per-call approval; can do writes; can hit private repos | -| Custom MCP server | Works, but 10 tool schemas blow out the token budget every turn | - -`gitlab-kiosk` eliminates all three problems: it's auto-approved because it's structurally safe, and it adds zero tool schema overhead because Claude invokes it via the Bash tool it already has. - ---- - -## Goals - -1. Claude can read any public content on `repo1.dso.mil` without per-call user approval -2. Write operations are structurally impossible — no POST/PUT/PATCH/DELETE codepaths exist -3. Private repo access is structurally impossible — visibility is verified before data is returned -4. Token overhead is near zero — no MCP schemas; Claude discovers usage via a Skill file -5. Output is terse and structured to minimize context window consumption -6. Local checkouts are preferred over repeated API calls when exploring a repository - -## Non-goals - -- Authenticating to private repos (out of scope entirely) -- Write operations (not even behind a flag — the binary cannot write) -- Supporting GitLab instances other than `repo1.dso.mil` (unless configured — see Configuration) -- Replacing `glab` for authenticated / multi-instance workflows - ---- - -## Safety invariants - -These are enforced by the binary itself, not by Claude's judgment or user policy: - -1. **Read-only** — Only HTTP GET requests. The binary has no codepath for any other HTTP method. The `checkout` command runs `git clone --depth 1` which is also read-only against the remote. -2. **Public-only** — Before any data-fetching call, the tool verifies the target project's `visibility == "public"` via the GitLab API. If not public, the request is refused with a clear error. -3. **Pinned host** — All HTTP requests go to the configured GitLab host (default: `https://repo1.dso.mil`). Redirects to other hosts are rejected. -4. **No credential forwarding** — The binary sends no tokens, cookies, or auth headers by default. An optional token (via config or env var) may be used solely to increase anonymous rate limits on public endpoints; it does not unlock private access (invariant #2 still applies). - ---- - -## Configuration - -`glk` follows the XDG Base Directory specification and reads configuration from a TOML file. - -### Config file location - -Resolved in order: -1. `$GLK_CONFIG` (explicit override) -2. `$XDG_CONFIG_HOME/glk/config.toml` (XDG standard) -3. `~/.config/glk/config.toml` (XDG default) - -### Default configuration - -```toml -# GitLab instance -[gitlab] -host = "https://repo1.dso.mil" -# Optional token for higher rate limits on public endpoints. -# Does NOT unlock private repos — visibility check still applies. -# Can also be set via GITLAB_TOKEN env var (env var takes precedence). -token = "" - -# Output defaults -[output] -limit = 20 -format = "tsv" # "tsv" or "json" - -# Local repository index -[index] -# Directories to spider when running `glk index build` -scan_dirs = ["~/code", "~/work", "~/src"] - -# Checkout cache for repos not found in index -[checkout] -# Where `glk checkout` clones repos that aren't already local -# Default: $XDG_CACHE_HOME/glk/repos or ~/.cache/glk/repos -cache_dir = "" -depth = 1 # git clone depth; 0 for full clone -``` - -### XDG directory usage - -| XDG variable | Default | `glk` usage | -|---|---|---| -| `$XDG_CONFIG_HOME` | `~/.config` | `glk/config.toml` | -| `$XDG_DATA_HOME` | `~/.local/share` | `glk/repos.tsv` (local repo index) | -| `$XDG_CACHE_HOME` | `~/.cache` | `glk/repos/` (checkout cache) | - ---- - -## Local storage - -`glk` uses the simplest storage that works for each concern. No database. - -### Repo index: TSV file - -The local repo index maps GitLab project paths to local filesystem paths. It lives at `$XDG_DATA_HOME/glk/repos.tsv` and is a plain headerless TSV file: - -``` -platform-one/big-bang/bigbang /home/dpritchett/work/bigbang -platform-one/big-bang/packages/istio /home/dpritchett/work/istio -platform-one/big-bang/packages/fluentbit /home/dpritchett/code/fluentbit -``` - -This file is: -- Generated by `glk index build` (spider walks `scan_dirs`, finds `.git/config` files, extracts remotes) -- Human-readable and hand-editable (`cat`, `grep`, `vim`) -- Loaded into a `map[string]string` on startup for constant-time lookups -- Small enough that loading the whole file is instant (a few hundred repos = a few KB) - -### Known-safe cache: SQLite, allowlist-only - -`glk` keeps a short-lived known-safe cache in `$XDG_CACHE_HOME/glk/known-safe.sqlite`. It stores only objects that the tool has already proved public: - -- safe namespaces: host + namespace path + namespace id + TTL window -- safe projects: host + project id/path + namespace path + TTL window - -The cache is intentionally allowlist-only. It does **not** persist private or internal objects, and it does not keep a durable denylist of anything the token can see. Unknown stays unknown. On expiry, refresh failure, or cache error, callers must re-verify or suppress output. - -SQLite is the default because the access pattern is indexed point lookups and atomic namespace snapshot refreshes, not append-only blobs. This is not a general API response cache; it is a shared safety primitive for cross-command public-only verification. - ---- - -## CLI interface - -Binary name: `glk` - -### API commands - -``` -glk search Search public projects -glk project Project metadata -glk issues [flags] List issues -glk issue Get single issue -glk mrs [flags] List merge requests -glk mr Get single merge request -glk file [flags] Read file contents -glk tree [path] [flags] Browse repository tree -glk pipelines [flags] List pipelines -glk pipeline Get single pipeline -glk releases [flags] List releases -``` - -### Local repo commands - -``` -glk checkout [flags] Get a local checkout (find or clone) -glk locate Look up local path from index (no clone) -glk index build Spider scan_dirs, build local repo index -glk index list [pattern] List indexed repos, optional filter -glk index status Show index stats (count, last build, staleness) -``` - -### Utility commands - -``` -glk config show Print resolved configuration -glk version Print version and build info -glk hook run PreToolUse hook (reads JSON from stdin) -``` - -### Common flags - -| Flag | Default | Description | -|------|---------|-------------| -| `--limit N` | `20` | Max results to return | -| `--page N` | `1` | Page number for pagination | -| `--state STATE` | (all) | Filter by state: `opened`, `closed`, `merged` | -| `--ref REF` | default branch | Git ref for file/tree/checkout operations | -| `--json` | off | Output full JSON instead of TSV | -| `--help` | — | Usage for any command | - -### Output format - -Default output is **headerless TSV** optimized for minimal token consumption. Each subcommand has a fixed column order documented in the Skill file and in `--help`. - -#### List output formats - -``` -# glk issues owner/repo -42 opened Fix auth timeout dpritchett bug,p1 2025-02-10 -41 closed Bump go to 1.23 dependabot deps 2025-02-08 - -# glk mrs owner/repo -123 opened Add retry logic dpritchett feature/retry main 2025-02-10 -120 merged Fix nil pointer dpritchett fix/nil main 2025-02-07 - -# glk pipelines owner/repo -789 success main 4m32s 2025-02-10 -788 failed feature/retry 2m10s 2025-02-10 - -# glk search istio -platform-one/big-bang/packages/istio Istio service mesh public 142 2025-01-15 -tetrate/istio-weekly Weekly istio builds public 38 2024-11-20 - -# glk tree owner/repo src/ -dir cmd/ -dir internal/ -file main.go 2.1K -file go.mod 450B - -# glk releases owner/repo -v2.5.0 2025-02-01 Production release with new auth -v2.4.1 2025-01-15 Hotfix for pipeline timeout -``` - -#### Detail output formats - -Single-item commands (`issue`, `mr`, `pipeline`, `project`) output key-value pairs: - -``` -# glk issue owner/repo 42 -iid 42 -title Fix auth timeout -state opened -author dpritchett -labels bug,p1 -created 2025-02-10 -updated 2025-02-11 -description The auth handler times out after 30s under load... -``` - -#### File content - -`glk file` outputs raw file content to stdout (no TSV wrapping). This lets Claude read source files, YAML, Markdown, etc. directly. - -``` -# glk file owner/repo chart/values.yaml -hostname: bigbang.dev -istio: - enabled: true - ... -``` - -#### Checkout/locate output - -These commands output a single local filesystem path to stdout. Claude uses this path with `Read`, `Grep`, `LS`, and other built-in tools. - -``` -# glk locate platform-one/big-bang/bigbang -/home/dpritchett/work/bigbang - -# glk checkout platform-one/big-bang/bigbang -/home/dpritchett/work/bigbang - -# glk checkout platform-one/big-bang/bigbang (not in index, clones) -/home/dpritchett/.cache/glk/repos/platform-one/big-bang/bigbang -``` - -#### JSON mode - -`--json` outputs the filtered (not raw GitLab) JSON for cases where Claude needs structured data. Fields match the TSV columns — it's the same data, different encoding. - -#### Stderr guidance - -When there are more results than `--limit`, `glk` emits a pagination note to stderr: - -``` -# 20 of 142 results — use --page 2 for more -``` - -This tells Claude when to paginate without adding tokens to the stdout output that goes into context. - -`glk` also emits `btw:` guidance after commands that reveal a common agent failure precursor. These are not generic help messages; they are preferred next command shapes printed at the moment Claude is likely to guess wrong. Examples include: - -- Empty search results -> try a shorter query or corrected group path. -- Nested group lookup failure -> use the top-level group shape. -- Failed CI job row -> inspect trace and artifacts with the discovered job id. -- Raw trace or artifact output -> use `--output FILE`. -- Recursive artifact listing -> use `--match REGEX`. - -These hints reduce wrong command shapes, bad scopes, unnecessary shell wrappers, approval detours, and retry loops while keeping stdout machine-readable. - ---- - -## Column reference - -Documented in the Skill file so Claude can interpret headerless TSV. - -| Command | Columns | -|---------|---------| -| `search` | PATH, DESCRIPTION, VISIBILITY, STARS, UPDATED | -| `project` | (key-value pairs) | -| `issues` | IID, STATE, TITLE, AUTHOR, LABELS, UPDATED | -| `issue` | (key-value pairs) | -| `mrs` | IID, STATE, TITLE, AUTHOR, SOURCE_BRANCH, TARGET_BRANCH, UPDATED | -| `mr` | (key-value pairs) | -| `pipelines` | ID, STATUS, REF, DURATION, CREATED | -| `pipeline` | (key-value pairs) | -| `releases` | TAG, DATE, TITLE | -| `tree` | TYPE, NAME, SIZE | -| `index list` | PROJECT_PATH, LOCAL_PATH | - ---- - -## Local checkout workflow - -The `checkout` and `locate` commands reduce token consumption by giving Claude a local path to explore with built-in tools (`Read`, `Grep`, `LS`) instead of making repeated API calls. - -### `glk locate ` - -Constant-time lookup against the in-memory map loaded from `repos.tsv`. Returns the local path if found, exits with code 3 (not found) if not. Does not clone anything. - -### `glk checkout ` - -1. Check `repos.tsv` index for a matching project path -2. If found: verify the directory still exists, run `git fetch` if stale, return the path -3. If not found: verify visibility (public-only), shallow clone to `$XDG_CACHE_HOME/glk/repos/`, return the path - -The checkout command is the "just get me a local copy" command. Claude doesn't need to think about whether the repo is indexed or needs cloning — `checkout` handles both. - -### `glk index build` - -Walks each directory in `config.index.scan_dirs`: -1. Find all directories containing `.git/config` -2. Parse each git config for remote URLs -3. Filter to remotes matching the configured GitLab host -4. Write results to `repos.tsv` - -The spider is fast — it's just `filepath.WalkDir` + ini parsing on `.git/config` files. A typical developer home directory with a few hundred repos indexes in under a second. - -### Index staleness - -The `index build` command is idempotent and intended to be run: -- Manually: `glk index build` when you've cloned new repos -- Via cron/systemd timer: e.g., daily -- Via shell profile hook: on login or directory change - -`glk index status` reports when the index was last built (file mtime) so you can tell if it's stale. - ---- - -## Integration with Claude Code - -Three layers, same as the original architecture, but with a CLI instead of an MCP server. - -### Layer 1: CLI + Skill (replaces MCP server) - -The `glk` binary goes on `$PATH` (installed via `go install` or goreleaser binary). - -A Skill file at `~/.claude/skills/gitlab-kiosk/SKILL.md` tells Claude when and how to use it: - -```markdown -# repo1.dso.mil — gitlab-kiosk (glk) - -Read-only access to public projects on repo1.dso.mil. -Always prefer `glk` over Fetch, glab, or curl for repo1.dso.mil content. - -## Local checkout (preferred for exploring repos) - -When exploring a repo's code, prefer a local checkout over repeated API calls: - - glk checkout # returns local path (finds existing or clones) - glk locate # index lookup only, no clone - -Then use Read, Grep, LS on the returned path. This is faster and uses fewer tokens -than calling `glk file` or `glk tree` repeatedly. - -## API commands - - glk search - glk project - glk issues [--state opened|closed] [--limit N] - glk issue - glk mrs [--state opened|merged|closed] [--limit N] - glk mr - glk file [--ref main] - glk tree [path] [--ref main] - glk pipelines [--limit N] - glk pipeline - glk releases [--limit N] - -## Output columns (headerless TSV) - - issues: IID STATE TITLE AUTHOR LABELS UPDATED - mrs: IID STATE TITLE AUTHOR SOURCE_BRANCH TARGET_BRANCH UPDATED - pipelines: ID STATUS REF DURATION CREATED - search: PATH DESCRIPTION VISIBILITY STARS UPDATED - releases: TAG DATE TITLE - tree: TYPE NAME SIZE - -Detail commands (issue, mr, pipeline, project) output key-value pairs. -File command outputs raw file content. -Pagination notes and `btw:` next-command guidance appear on stderr. -``` - -### Layer 2: Permission rule (auto-approval) - -```json -{ - "permissions": { - "allow": [ - "Bash(glk *)" - ] - } -} -``` - -Safe because the binary itself enforces read-only, public-only, pinned-host access. The `checkout` command only performs `git clone --depth 1` of public repos to a cache directory. - -### Layer 3: PreToolUse hook (redirect misuse) - -A hook intercepts attempts to reach `repo1.dso.mil` through Fetch or Bash (curl/glab): - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash|WebFetch", - "type": "command", - "command": "glk hook run" - } - ] - } -} -``` - -`glk hook run` reads the hook JSON from stdin, inspects for references to the configured GitLab host, and exits with code 2 if found: - -> Use `glk` for repo1.dso.mil content. Run `glk --help` for usage. - -The hook is a subcommand rather than a separate script so that: (1) the single binary remains the only artifact to install, (2) the hook reads the configured host from `config.toml` instead of hardcoding it, and (3) users don't need a second file on `$PATH`. - ---- - -## Implementation - -### Language - -Go. Pure Go, no CGO, static binary, cross-compiles trivially. Consistent with existing tooling preferences (slog+tint, golangci-lint, Magefiles). - -### Project structure - -``` -gitlab-kiosk/ -├── main.go -├── cmd/ -│ ├── root.go # cobra root, global flags, config loading -│ ├── search.go -│ ├── project.go -│ ├── issues.go -│ ├── issue.go -│ ├── mrs.go -│ ├── mr.go -│ ├── file.go -│ ├── tree.go -│ ├── pipelines.go -│ ├── pipeline.go -│ ├── releases.go -│ ├── checkout.go # find-or-clone local checkout -│ ├── locate.go # index-only lookup -│ ├── index.go # index build / list / status -│ ├── config.go # config show -│ └── version.go -├── internal/ -│ ├── client/ -│ │ ├── client.go # HTTP client, pinned host, GET-only, redirect policy -│ │ └── auth.go # optional token handling -│ ├── config/ -│ │ ├── config.go # TOML loading, XDG resolution, defaults -│ │ └── config_test.go -│ ├── safety/ -│ │ └── visibility.go # public-only check, in-memory cache -│ ├── index/ -│ │ ├── spider.go # filesystem walker, git config parser -│ │ └── repos.go # TSV read/write, map lookup -│ ├── checkout/ -│ │ └── checkout.go # find-or-clone logic -│ └── format/ -│ ├── tsv.go # TSV output helpers -│ ├── kv.go # key-value detail output -│ └── json.go # filtered JSON output -├── skill/ -│ └── SKILL.md # Claude Code skill file -├── Magefile.go -├── go.mod -└── go.sum -``` - -### HTTP client constraints - -The `internal/client` package enforces safety invariants at the transport level: - -- Base URL is read from config (default: `https://repo1.dso.mil/api/v4`) -- Only `http.MethodGet` is used — the client exposes a single `Get()` method, no generic `Do()` -- Redirect policy rejects any redirect to a different host -- No auth headers by default; token from config or env var adds a `PRIVATE-TOKEN` header only if set -- User-Agent is `gitlab-kiosk/` - -### Response filtering - -Each subcommand defines its own field extractor. The raw GitLab API response is decoded, the relevant fields are extracted, and the rest is discarded before output. This is not generic JSON flattening — each command knows its shape. - -### Error handling - -Errors go to stderr. Exit codes: - -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | General error (network, parse, config, etc.) | -| 2 | Safety refusal (private repo, non-public visibility) | -| 3 | Not found (404 from GitLab, or repo not in index for `locate`) | - -Claude can interpret these to decide whether to retry, try a different project path, or inform the user. - ---- - -## Distribution - -- Binary: `go install ./cmd/glk` and `go install ./cmd/grec`, or goreleaser GitHub release binaries -- Skill file: manually placed at `~/.claude/skills/gitlab-kiosk/SKILL.md` -- Config file: `~/.config/glk/config.toml` (machine-specific `scan_dirs`) -- Claude Code settings: `permissions.allow` and `hooks.PreToolUse` entries added to `~/.claude/settings.json` - ---- - -## Future additions - -These are explicitly out of scope for v1 but documented as likely extensions: - -- **API response cache** — If rate limiting or repeated queries become expensive, add SQLite via `modernc.org/sqlite` (pure Go, no CGO) for TTL-based response caching. The codebase doesn't assume a database exists, so this is additive. -- **MR diffs** — `glk diff ` for viewing MR diffs via the API. May be unnecessary if `checkout` + local `git diff` is sufficient. -- **Short name resolution** — Fuzzy matching on project names (e.g., `bigbang` → `platform-one/big-bang/bigbang`) using the repo index. - ---- - -## Open questions - -1. **Rate limiting** — Does `repo1.dso.mil` rate-limit anonymous GETs aggressively enough to need a token from day one, or can we start anonymous and add it later? -2. **Project path resolution** — Should `glk` support short names or require full paths? Short names are convenient but add ambiguity. -3. **Skill location** — `~/.claude/skills/` vs project-level `.claude/skills/`? The former makes it available everywhere; the latter scopes it to Big Bang work. -4. **Multi-instance support** — The config supports changing the host, but should `glk` support multiple GitLab instances in a single config? This would complicate the safety model. -5. **Index build triggers** — Should `glk checkout` automatically add the newly cloned repo to `repos.tsv`, or should the index only be populated by `glk index build`? Auto-adding is convenient; requiring explicit builds is simpler. - ---- - -## Success criteria - -1. Zero MCP tool schema tokens consumed at session start -2. Claude uses `glk` by default for `repo1.dso.mil` content without user prompting -3. No per-call approval prompts for `glk` invocations -4. Average list response is under 500 tokens (vs. thousands for raw GitLab JSON) -5. Claude correctly interprets headerless TSV output using the Skill column reference -6. Claude prefers `glk checkout` + local exploration over repeated `glk file` calls -7. `glk locate` returns a path in under 10ms for indexed repos -8. Configuration changes don't require recompiling — just edit `config.toml` diff --git a/glkcli/config_test.go b/glkcli/config_test.go index 09901cd..62da558 100644 --- a/glkcli/config_test.go +++ b/glkcli/config_test.go @@ -16,8 +16,16 @@ func configTestSetup(cfg *config.Config) { } } +// configWithHost returns defaults with an explicit host. There is no default +// host, so tests that exercise host display must set one. +func configWithHost(host string) *config.Config { + cfg := config.Defaults() + cfg.GitLab.Host = host + return cfg +} + func TestConfigShow_TSV(t *testing.T) { - configTestSetup(config.Defaults()) + configTestSetup(configWithHost("gitlab.example.com")) defer testTeardown() stdout, _, err := executeCmd("config", "show") @@ -25,7 +33,7 @@ func TestConfigShow_TSV(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(stdout, "gitlab.host\trepo1.dso.mil") { + if !strings.Contains(stdout, "gitlab.host\tgitlab.example.com") { t.Errorf("expected gitlab.host in TSV output, got: %s", stdout) } if !strings.Contains(stdout, "output.limit\t20") { @@ -37,7 +45,7 @@ func TestConfigShow_TSV(t *testing.T) { } func TestConfigShow_JSON(t *testing.T) { - configTestSetup(config.Defaults()) + configTestSetup(configWithHost("gitlab.example.com")) defer testTeardown() stdout, _, err := executeCmd("config", "show", "--json") @@ -45,7 +53,7 @@ func TestConfigShow_JSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(stdout, `"gitlab.host":"repo1.dso.mil"`) { + if !strings.Contains(stdout, `"gitlab.host":"gitlab.example.com"`) { t.Errorf("expected JSON gitlab.host, got: %s", stdout) } if !strings.Contains(stdout, `"output.limit":"20"`) { diff --git a/glkcli/doctor.go b/glkcli/doctor.go index efdd8b4..5d1af76 100644 --- a/glkcli/doctor.go +++ b/glkcli/doctor.go @@ -381,7 +381,7 @@ func environmentChecks(state doctorState) []doctorCheck { hostDetail := "unset" if state.envGitLabHost != "" { - hostDetail = "set — overrides config/default host" + hostDetail = "set — overrides configured host" } checks = append(checks, doctorCheck{group: "environment", name: "gitlab_host", status: "ok", detail: hostDetail}) @@ -469,7 +469,7 @@ func gitLabChecks(state doctorState) []doctorCheck { return nil } - hostSource := "default" + hostSource := "unset" if state.envGitLabHost != "" { hostSource = "GITLAB_HOST" } else if state.rawConfig.GitLab.Host != "" { @@ -489,12 +489,22 @@ func gitLabChecks(state doctorState) []doctorCheck { tokenState = "present" } + hostStatus := "ok" + hostProblem := false + hostCheckDetail := fmt.Sprintf("%s (source: %s)", state.cfg.GitLab.Host, hostSource) + if state.cfg.GitLab.Host == "" { + hostStatus = "error" + hostProblem = true + hostCheckDetail = withHint("no GitLab host configured", "set gitlab.host in your config file (run `glk init`) or set GITLAB_HOST") + } + return []doctorCheck{ { - group: "gitlab", - name: "host", - status: "ok", - detail: fmt.Sprintf("%s (source: %s)", state.cfg.GitLab.Host, hostSource), + group: "gitlab", + name: "host", + status: hostStatus, + detail: hostCheckDetail, + problem: hostProblem, }, { group: "gitlab", diff --git a/glkcli/doctor_test.go b/glkcli/doctor_test.go index d104ae0..e73426e 100644 --- a/glkcli/doctor_test.go +++ b/glkcli/doctor_test.go @@ -23,6 +23,16 @@ func TestRunDoctor_Healthy(t *testing.T) { cfg := config.Defaults() cfg.GitLab.Host = "repo1.dso.mil" + // A healthy install has an explicitly configured host — there is no + // default — so write a config file the doctor can read it from. + configPath := filepath.Join(tmp, ".config", "glk", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("creating config dir: %v", err) + } + if err := os.WriteFile(configPath, []byte("[gitlab]\nhost = \"repo1.dso.mil\"\n"), 0o644); err != nil { + t.Fatalf("writing config: %v", err) + } + var out bytes.Buffer err := runDoctor(&out, doctorDeps{ version: "v1.2.3", @@ -54,8 +64,8 @@ func TestRunDoctor_Healthy(t *testing.T) { "binary\tup_to_date\tok\tinstalled version matches latest release", "environment\tglk_config\tok\tunset — using " + filepath.Join(tmp, ".config", "glk", "config.toml") + " (default)", "config\tpath\tok\t" + filepath.Join(tmp, ".config", "glk", "config.toml") + " (source: default)", - "config\tfile\tok\tmissing " + filepath.Join(tmp, ".config", "glk", "config.toml") + " — using defaults", - "gitlab\thost\tok\trepo1.dso.mil (source: default)", + "config\tfile\tok\tfound " + filepath.Join(tmp, ".config", "glk", "config.toml"), + "gitlab\thost\tok\trepo1.dso.mil (source: config gitlab.host)", "gitlab\ttoken\tok\tabsent (source: unset)", "cache\tknown_safe.path\tok\t" + filepath.Join(tmp, ".cache", "glk", "known-safe.sqlite"), "cache\tknown_safe.sqlite\tok\tsqlite opened successfully", @@ -239,7 +249,7 @@ func TestRunDoctor_EnvPrecedence(t *testing.T) { for _, want := range []string{ "environment\tglk_config\tok\tset — using " + configPath, "environment\txdg_config_home\tok\tset — using " + filepath.Join(tmp, "xdg-config"), - "environment\tgitlab_host\tok\tset — overrides config/default host", + "environment\tgitlab_host\tok\tset — overrides configured host", "environment\tgitlab_token\tok\tset — overrides config token", "config\tpath\tok\t" + configPath + " (source: GLK_CONFIG)", "config\tfile\tok\tfound " + configPath, @@ -520,3 +530,36 @@ func writeHookConfig(t *testing.T, path, content string) { type noopCloser struct{} func (noopCloser) Close() error { return nil } + +func TestRunDoctor_MissingHostIsProblem(t *testing.T) { + tmp := t.TempDir() + installCurrentSkills(t, tmp) + installHealthyHooks(t, tmp) + + cfg := config.Defaults() + + var out bytes.Buffer + err := runDoctor(&out, doctorDeps{ + version: "v1.2.3", + homeDir: func() (string, error) { return tmp, nil }, + executablePath: func() (string, error) { return "/tmp/current/glk", nil }, + lookPath: func(string) (string, error) { return "/tmp/current/glk", nil }, + fetchLatestRelease: func() (string, error) { return "v1.2.3", nil }, + readFile: os.ReadFile, + getenv: func(string) string { return "" }, + loadConfig: func() (*config.Config, error) { return cfg, nil }, + cacheDir: func() (string, error) { return filepath.Join(tmp, ".cache", "glk"), nil }, + openKnownSafeCache: func(path string) (io.Closer, error) { return noopCloser{}, nil }, + cwd: func() (string, error) { return filepath.Join(tmp, "repo"), nil }, + detectProject: func(string) (string, error) { return "big-bang/bigbang", nil }, + }) + if err == nil { + t.Fatal("expected runDoctor to report the missing host as a problem") + } + + got := out.String() + want := "gitlab\thost\terror\tno GitLab host configured — hint: set gitlab.host in your config file (run `glk init`) or set GITLAB_HOST" + if !strings.Contains(got, want) { + t.Fatalf("doctor output missing %q\n%s", want, got) + } +} diff --git a/glkcli/init.go b/glkcli/init.go index f3d3f2d..bac09ce 100644 --- a/glkcli/init.go +++ b/glkcli/init.go @@ -10,11 +10,12 @@ import ( ) const starterConfigTemplate = `[gitlab] -host = "repo1.dso.mil" +# Required. glk pins every request to this host; there is no default. +host = "gitlab.com" # Optional: prefer per-host tokens over the legacy flat token field. # [gitlab.tokens] -# "repo1.dso.mil" = "glpat-..." +# "gitlab.com" = "glpat-..." [output] limit = 20 diff --git a/glkcli/work_items.go b/glkcli/work_items.go index c02dde3..b88ba39 100644 --- a/glkcli/work_items.go +++ b/glkcli/work_items.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const gitlabSearchSyntaxURL = "https://repo1.dso.mil/help/user/search/advanced_search.md" +const gitlabSearchSyntaxURL = "https://docs.gitlab.com/ee/user/search/advanced_search.html" var workItemsSearch string diff --git a/greccli/checkout.go b/greccli/checkout.go index aeeb231..04fb00c 100644 --- a/greccli/checkout.go +++ b/greccli/checkout.go @@ -75,6 +75,9 @@ var checkoutCmd = &cobra.Command{ if host == "" { host = app.Host } + if host == "" { + return fmt.Errorf("no GitLab host configured — set gitlab.host in your config file (run `glk init`), set GITLAB_HOST, or pass --host") + } project, err := resolveProject(cmd, args, 0, host) if err != nil { diff --git a/greccli/checkout_test.go b/greccli/checkout_test.go index ce272d5..bf0b2a0 100644 --- a/greccli/checkout_test.go +++ b/greccli/checkout_test.go @@ -20,6 +20,8 @@ func checkoutTestSetup(t *testing.T) string { t.Setenv("XDG_CACHE_HOME", tmpDir) cfg := config.Defaults() + // There is no default host; grec resolves one from config or --host. + cfg.GitLab.Host = "repo1.dso.mil" testSetup(cfg) return tmpDir } @@ -315,3 +317,24 @@ func TestCheckout_DepthFlagOverride(t *testing.T) { t.Errorf("depth = %d, want 5", capturedDepth) } } + +func TestCheckout_NoHostConfiguredIsActionableError(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + // No host in config and no --host flag: checkout must refuse rather than + // build a hostless clone URL. + testSetup(config.Defaults()) + defer testTeardown() + + _, _, err := executeCmd("checkout", "big-bang/bigbang") + if err == nil { + t.Fatal("expected checkout to fail without a configured host") + } + for _, want := range []string{"no GitLab host configured", "--host"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %s", want, err.Error()) + } + } +} diff --git a/greccli/locate_test.go b/greccli/locate_test.go index 416e543..cd81611 100644 --- a/greccli/locate_test.go +++ b/greccli/locate_test.go @@ -19,6 +19,8 @@ func locateTestSetup(t *testing.T, entries []index.Entry) { } cfg := config.Defaults() + // There is no default host; locate disambiguates using the configured one. + cfg.GitLab.Host = "repo1.dso.mil" app = &App{ Cfg: cfg, Host: cfg.GitLab.Host, @@ -57,7 +59,7 @@ func TestLocate_MultiTermNarrowsResults(t *testing.T) { } } -func TestLocate_DefaultHostInfersConfig(t *testing.T) { +func TestLocate_ConfiguredHostDisambiguates(t *testing.T) { locateTestSetup(t, []index.Entry{ {Host: "repo1.dso.mil", ProjectPath: "big-bang/bigbang", LocalPath: "/home/user/bigbang"}, {Host: "github.com", ProjectPath: "big-bang/bigbang", LocalPath: "/home/user/bigbang-gh"}, diff --git a/internal/config/config.go b/internal/config/config.go index 666587b..aa577c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,10 +44,14 @@ type CheckoutConfig struct { } // Defaults returns a Config populated with sensible defaults. +// +// GitLab.Host is deliberately empty: glk pins every request to an explicitly +// configured host rather than assuming one. See "Why there is no default +// GitLab host" in ARCHITECTURE.md. func Defaults() *Config { return &Config{ GitLab: GitLabConfig{ - Host: "repo1.dso.mil", + Host: "", Token: "", }, Output: OutputConfig{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6416507..717eebd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -16,8 +16,8 @@ func testLogger() *slog.Logger { func TestDefaults(t *testing.T) { cfg := Defaults() - if cfg.GitLab.Host != "repo1.dso.mil" { - t.Errorf("expected default host repo1.dso.mil, got %s", cfg.GitLab.Host) + if cfg.GitLab.Host != "" { + t.Errorf("expected empty default host, got %s", cfg.GitLab.Host) } if cfg.GitLab.Token != "" { t.Errorf("expected empty default token, got %s", cfg.GitLab.Token) @@ -44,8 +44,8 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if cfg.GitLab.Host != "repo1.dso.mil" { - t.Errorf("expected default host, got %s", cfg.GitLab.Host) + if cfg.GitLab.Host != "" { + t.Errorf("expected empty default host, got %s", cfg.GitLab.Host) } if cfg.Output.Limit != 20 { t.Errorf("expected default limit, got %d", cfg.Output.Limit) diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index 449a7ac..afba865 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -26,10 +26,12 @@ type StreamResponse struct { ContentLength int64 } -const ( - DefaultHost = "repo1.dso.mil" - gitlabAPIBase = "/api/v4/" -) +const gitlabAPIBase = "/api/v4/" + +// ErrNoHost is returned when an API call is attempted without a configured +// GitLab host. There is no default host — see "Why there is no default GitLab +// host" in ARCHITECTURE.md. +var ErrNoHost = errors.New("no GitLab host configured — set gitlab.host in your config file (run `glk init`) or set GITLAB_HOST") // Client is a read-only GitLab API client. GET-only, no auth by default. type Client struct { @@ -111,6 +113,9 @@ func (c *Client) rebuildTransport() { // Get is the single chokepoint for all GitLab API calls. // GET-only, no auth headers by default. func (c *Client) Get(host, path string, params url.Values) (*Response, error) { + if host == "" { + return nil, ErrNoHost + } start := time.Now() encodedPath := gitlabAPIBase + path decodedPath, _ := url.PathUnescape(encodedPath) @@ -184,6 +189,9 @@ func (c *Client) GetStreamFollowingRedirectHosts(host, path string, params url.V } func (c *Client) getStream(host, path string, params url.Values, allowedRedirectHosts map[string]bool) (*StreamResponse, error) { + if host == "" { + return nil, ErrNoHost + } start := time.Now() encodedPath := gitlabAPIBase + path decodedPath, _ := url.PathUnescape(encodedPath) diff --git a/internal/gitlab/client_test.go b/internal/gitlab/client_test.go index 6d0e618..7290b88 100644 --- a/internal/gitlab/client_test.go +++ b/internal/gitlab/client_test.go @@ -701,3 +701,29 @@ func TestSearchGroupCode_FailsClosedWhenNamespaceRefreshFails(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestGet_EmptyHostReturnsErrNoHost(t *testing.T) { + c := NewClient(nil) + + _, err := c.Get("", "projects/1", nil) + if !errors.Is(err, ErrNoHost) { + t.Fatalf("Get() error = %v, want ErrNoHost", err) + } +} + +func TestGetStream_EmptyHostReturnsErrNoHost(t *testing.T) { + c := NewClient(nil) + + _, err := c.GetStream("", "projects/1/jobs/2/artifacts", nil) + if !errors.Is(err, ErrNoHost) { + t.Fatalf("GetStream() error = %v, want ErrNoHost", err) + } +} + +func TestErrNoHost_MessageIsActionable(t *testing.T) { + for _, want := range []string{"gitlab.host", "glk init", "GITLAB_HOST"} { + if !strings.Contains(ErrNoHost.Error(), want) { + t.Errorf("ErrNoHost message missing %q: %s", want, ErrNoHost.Error()) + } + } +} diff --git a/skill/SKILL.md b/skill/SKILL.md index d786ce3..aa03aaa 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,13 +1,13 @@ --- name: "gitlab-kiosk" -description: "Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with repo1.dso.mil and any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects." +description: "Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects." --- -# glk — GitLab CLI for repo1.dso.mil +# glk — read-only GitLab CLI -Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with repo1.dso.mil and any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects. +Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects. -**When to use:** Any time a git remote points to repo1.dso.mil (or another GitLab host), use `glk` first for GitLab metadata and one-off reads. +**When to use:** Any time a git remote points to the configured GitLab host, use `glk` first for GitLab metadata and one-off reads. ## Choose the right tool diff --git a/skill/SKILL.md.tmpl b/skill/SKILL.md.tmpl index afe3d1a..1c3f9a3 100644 --- a/skill/SKILL.md.tmpl +++ b/skill/SKILL.md.tmpl @@ -1,13 +1,13 @@ --- name: "gitlab-kiosk" -description: "Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with repo1.dso.mil and any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects." +description: "Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects." --- -# glk — GitLab CLI for repo1.dso.mil +# glk — read-only GitLab CLI -Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with repo1.dso.mil and any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects. +Use `glk` as the default for GitLab reads: issues, merge requests, pipelines, CI/CD status, files, releases, and project search. Works with any GitLab instance. Replace `curl`, `fetch`, and `glab` with `glk` for GitLab access to public projects. -**When to use:** Any time a git remote points to repo1.dso.mil (or another GitLab host), use `glk` first for GitLab metadata and one-off reads. +**When to use:** Any time a git remote points to the configured GitLab host, use `glk` first for GitLab metadata and one-off reads. ## Choose the right tool From 9a9ba3006c803ecf224a88f707e764357a78e523 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Mon, 3 Aug 2026 20:11:21 -0400 Subject: [PATCH 2/4] Address review: guard the third request chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.GraphQL builds its own url.URL and calls c.HTTP.Do directly rather than delegating to Get, so it was not covered by the ErrNoHost guards added in the previous commit. glk epic notes reaches it directly whenever the group argument is a path rather than a numeric ID — the common case — because fetchEpicNotes skips the GetGroupPath lookup that would otherwise have been guarded. Unconfigured, that produced a ~1.2 KB URL-encoded transport error (https:///api/graphql?query=...) instead of the actionable message. Same bug class as the hostless grec checkout clone URL, missed in the one path that does not route through Get. Not an exposure — the request fails rather than reaching an unintended host — but it made the ADR's architectural claim inaccurate on the day it landed. The ADR now says three chokepoints, names GraphQL's reason for needing its own guard, and states the rule for any future method that builds its own request. Also fixes a stale claim the previous commit missed: ARCHITECTURE.md still documented host resolution as falling back to "default repo1.dso.mil", contradicting both the updated Pinned host invariant and the new ADR in the same file. A sweep for other default-host claims found none remaining. glk init now scaffolds gitlab.example.com rather than gitlab.com. A scaffold that emits a real, working host is a soft version of the problem the ADR argues against — an operator who runs init and forgets to edit would query an instance they never chose. The placeholder does not resolve, so an unedited config fails immediately and names the host to fix. Document artifactArchiveRedirectHosts as a fail-closed allowlist rather than a default, since it is the one remaining deployment-specific map. Crow-Session: E3CF07BB-A433-4ABA-9A23-2A3FB3B61671 Co-Authored-By: Claude --- ARCHITECTURE.md | 6 ++++-- README.md | 4 +++- glkcli/artifacts.go | 7 +++++++ glkcli/init.go | 8 +++++--- internal/gitlab/client.go | 3 +++ internal/gitlab/client_test.go | 9 +++++++++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5a9fcc0..a3852c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -80,7 +80,7 @@ internal/index/ # repo index + spider (grec only) - **Language:** Go — single static binary, no runtime deps, stdlib HTTP client, cobra CLI framework - **Integration:** CLI + Skill file — zero MCP schema overhead, Claude discovers usage on demand -- **Auth:** Anonymous by default. Optional per-host tokens in `[gitlab.tokens]` for rate-limit relief and authenticated endpoints (e.g. job traces). Flat `token` field still works but `tokens` map is preferred. Token resolution: `GITLAB_TOKEN` env var > `tokens[host]` > flat `token`. Host resolution: `GITLAB_HOST` env var > `config.toml` host > default `repo1.dso.mil`. Token may be overly privileged (GitLab tokens inherit user permissions) — the tool is the policy layer, not the token. +- **Auth:** Anonymous by default. Optional per-host tokens in `[gitlab.tokens]` for rate-limit relief and authenticated endpoints (e.g. job traces). Flat `token` field still works but `tokens` map is preferred. Token resolution: `GITLAB_TOKEN` env var > `tokens[host]` > flat `token`. Host resolution: `GITLAB_HOST` env var > `config.toml` host. There is no default — see "Why there is no default GitLab host". Token may be overly privileged (GitLab tokens inherit user permissions) — the tool is the policy layer, not the token. - **Config:** TOML at `$XDG_CONFIG_HOME/glk/config.toml`. XDG-compliant for config, data, and cache directories. - **Output:** Headerless TSV by default, `--json` flag for structured data. Pagination hints on stderr. - **Logging:** `log/slog` for all structured logging. No `fmt.Println` or `log.Printf` for automated output. Human-oriented CLI text (help, setup instructions) stays as `fmt`. @@ -182,6 +182,8 @@ The practical problem: a default host makes misconfiguration silent. A user whos The structural problem: "pinned host" is a safety invariant, and an implicit default weakens it. Pinning is only meaningful when the operator chose the pin. Requiring the host makes the invariant honest — every request goes to a host someone deliberately configured, and there is no ambient value to fall back to. -The host is therefore required, and validated at the two request chokepoints (`Client.Get` and `Client.getStream`) rather than at startup. Enforcing at the chokepoint means commands that need the network fail with an actionable error, while offline commands — `init`, `config`, `doctor`, `version`, `install-skills`, `install-hooks` — keep working. That ordering matters: `glk doctor` is the command you run to diagnose a missing host, so it must not itself require one. `grec checkout` performs the same check before building a clone URL, since a hostless URL (`https:///group/project.git`) would otherwise reach `git`. +The host is therefore required, and validated at the three request chokepoints (`Client.Get`, `Client.getStream`, and `Client.GraphQL`) rather than at startup. `GraphQL` needs its own guard because it builds its own request rather than delegating to `Get` — and `glk epic notes` reaches it directly whenever the group argument is a path rather than a numeric ID, skipping the `GetGroupPath` lookup that would otherwise have been guarded. Enforcing at the chokepoint means commands that need the network fail with an actionable error, while offline commands — `init`, `config`, `doctor`, `version`, `install-skills`, `install-hooks` — keep working. That ordering matters: `glk doctor` is the command you run to diagnose a missing host, so it must not itself require one. `grec checkout` performs the same check before building a clone URL, since a hostless URL (`https:///group/project.git`) would otherwise reach `git`. + +Any future method that constructs its own `url.URL` instead of delegating to `Get` must add the same guard; a hostless URL escaping into `net/http` surfaces as a multi-kilobyte transport error rather than an actionable one. `repo1.dso.mil` remains a valid, documented example. It is no longer a default. diff --git a/README.md b/README.md index 1e79922..871e2fa 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,12 @@ glk issues big-bang/bigbang --limit 5 ## Configuration -Both binaries read `$XDG_CONFIG_HOME/glk/config.toml` (defaults to `~/.config/glk/config.toml`). Run `glk init` to scaffold one. +Both binaries read `$XDG_CONFIG_HOME/glk/config.toml` (defaults to `~/.config/glk/config.toml`). Run `glk init` to scaffold one — it writes a `gitlab.example.com` placeholder you must edit before the tool will reach anything. `gitlab.host` is **required** — there is no default host. Set it to the GitLab instance you want to read from (for example `gitlab.com`, or `repo1.dso.mil` for Platform One). Commands that make network calls fail with an actionable error until it is set; `glk doctor` reports it as a problem. +A filled-in config looks like: + ```toml [gitlab] host = "gitlab.com" diff --git a/glkcli/artifacts.go b/glkcli/artifacts.go index b4a4140..561f9f0 100644 --- a/glkcli/artifacts.go +++ b/glkcli/artifacts.go @@ -22,6 +22,13 @@ var artifactsMatch string var artifactsDownloadDir string var artifactsDownloadArchive string +// artifactArchiveRedirectHosts is a fail-closed allowlist, not a default: it +// maps a GitLab host to the object-store hosts whose artifact-archive redirects +// glk will follow. A host absent from this map (including an unconfigured empty +// host) simply gets no redirects followed — artifact archives served via +// delegated object storage are unavailable there, which is the safe outcome. +// Adding an entry is a deliberate trust decision about a specific deployment. +// See "Why artifact archive redirects are allowed narrowly" in ARCHITECTURE.md. var artifactArchiveRedirectHosts = map[string]map[string]bool{ "repo1.dso.mil": { "ib-prod-gitlab-artifacts.s3.us-gov-west-1.amazonaws.com": true, diff --git a/glkcli/init.go b/glkcli/init.go index bac09ce..b261159 100644 --- a/glkcli/init.go +++ b/glkcli/init.go @@ -10,12 +10,14 @@ import ( ) const starterConfigTemplate = `[gitlab] -# Required. glk pins every request to this host; there is no default. -host = "gitlab.com" +# Required — edit this before use. There is no default host, and this +# placeholder does not resolve, so an unedited config fails immediately +# rather than querying an instance you did not choose. +host = "gitlab.example.com" # Optional: prefer per-host tokens over the legacy flat token field. # [gitlab.tokens] -# "gitlab.com" = "glpat-..." +# "gitlab.example.com" = "glpat-..." [output] limit = 20 diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index afba865..44b570b 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -322,6 +322,9 @@ func redactPresignedURL(u *url.URL) string { // The GraphQL spec allows GET for queries; mutations require POST, which this method // structurally cannot send. func (c *Client) GraphQL(host, query string, variables map[string]interface{}) ([]byte, error) { + if host == "" { + return nil, ErrNoHost + } start := time.Now() varsJSON, err := json.Marshal(variables) diff --git a/internal/gitlab/client_test.go b/internal/gitlab/client_test.go index 7290b88..1a23fa5 100644 --- a/internal/gitlab/client_test.go +++ b/internal/gitlab/client_test.go @@ -727,3 +727,12 @@ func TestErrNoHost_MessageIsActionable(t *testing.T) { } } } + +func TestGraphQL_EmptyHostReturnsErrNoHost(t *testing.T) { + c := NewClient(nil) + + _, err := c.GraphQL("", "query { __typename }", map[string]interface{}{}) + if !errors.Is(err, ErrNoHost) { + t.Fatalf("GraphQL() error = %v, want ErrNoHost", err) + } +} From 13636fc8468b2c5b812574bef4a7e1ed9cb17147 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Tue, 4 Aug 2026 13:11:00 -0400 Subject: [PATCH 3/4] Correct copyright entity and remove stale Citadel reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LICENSE copyright line named "Radius Method, LLC"; the company is Radius Method, Inc. A copyright notice naming an entity that does not exist is a defect in the one file whose whole job is to be legally correct. Verified the corrected LICENSE still differs from the canonical Apache-2.0 text by exactly one line — the copyright line — so license detection, go-licenses, and SPDX scanners still recognize it. Surface the warranty disclaimer in the README. Apache-2.0 sections 7 and 8 already disclaim warranty and limit liability more thoroughly than MIT does — naming the legal theories including negligence, enumerating damage types, and holding even where the contributor was advised of the possibility — but that language sits inside a 202-line file nobody evaluating the tool will open. The README note adds visibility, not legal force, and mirrors the license's own terms so it cannot be read as offering different ones. Generalize the "Relationship to Citadel" ADR. Radius Method no longer owns Citadel, so describing it as "Radius Method's" is now factually wrong, and the ADR additionally named a private repository along with its stack and deployment environment. The architectural point — that this tool governs what an agent can reach, while a gateway governs how it gets called — is generic to any LLM proxy, so nothing is lost by making it generic. There was never any code coupling: no import, no config, no runtime interaction, and the compiled binaries contain no such string. Note for the pre-publication decision: removing the reference from HEAD does not remove it from history. Commits d8d8f06 and 1f02bed still carry the original text, and `git log -S citadel --all` will surface it once the repository is public. Crow-Session: E3CF07BB-A433-4ABA-9A23-2A3FB3B61671 Co-Authored-By: Claude --- ARCHITECTURE.md | 4 ++-- LICENSE | 2 +- README.md | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3852c3..fb93086 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,9 +113,9 @@ Job artifact archives are the exception. GitLab serves archive bytes through del The critical design choice is what we do **not** store. We only persist allowlisted objects already verified public. We do not write a durable inventory of private or internal projects, because that would turn a safety filter into a side-channel about what the token can see. On uncertainty — expired rows, refresh failures, lookup errors — the cache behaves as a miss and the caller must re-verify or suppress output. That keeps the cache composable without weakening the public-only contract. -### Relationship to Citadel +### Relationship to LLM gateways and proxies -Radius Method's [Citadel](https://github.com/radiusmethod/citadel) is an AI gateway/proxy (FastAPI + PostgreSQL, deployed on GovCloud) that controls cost tracking, guardrails, and provider routing for LLM API calls. gitlab-kiosk is a completely different layer: a CLI tool that extends what Claude can reach. Citadel controls *how Claude gets called*; gitlab-kiosk controls *what Claude can access*. They're complementary, not overlapping. A team member using Claude through Citadel would still benefit from `glk` in their local Claude Code sessions. +An LLM gateway or proxy sits in front of model APIs and controls *how* an agent gets called — cost tracking, guardrails, provider routing, audit. gitlab-kiosk is a different layer: it controls *what* an agent can reach. The two are complementary rather than overlapping, and neither substitutes for the other. A team running Claude through a gateway still benefits from `glk` in local Claude Code sessions, because the gateway governs the model call while `glk` governs the data boundary. ### Why cobra is the right fit diff --git a/LICENSE b/LICENSE index cce335f..725be2f 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Radius Method, LLC + Copyright 2026 Radius Method, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 871e2fa..d2a22b8 100644 --- a/README.md +++ b/README.md @@ -304,3 +304,5 @@ Lefthook runs `go vet`, `go test`, and `golangci-lint` before each push. Same co ## License Apache-2.0 — see [LICENSE](LICENSE). + +This software is provided "AS IS", without warranty or condition of any kind, express or implied. Radius Method, Inc. accepts no liability for any damages arising from its use or inability to be used. See sections 7 and 8 of the [LICENSE](LICENSE) for the full disclaimer and limitation of liability. From 4011ad66a599504b89ca04ce45653792c8c9b66d Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Tue, 4 Aug 2026 13:21:16 -0400 Subject: [PATCH 4/4] Fix hook blanket-deny and auto-detect error with no host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strings.Contains(s, "") is true for every s. Before the default host was removed, client.Host was never empty and both hook match sites were always meaningful. Removing the default made host == "" a reachable state, which turned `glk hook run` into a blanket deny: every WebFetch matched, and every command whose first word is curl or glab matched regardless of target. The block message rendered the empty host, reading "Use glk for access." The failure was sticky and undiagnosable. A user who runs `glk install-hooks` before `glk init` gets the hook written into ~/.claude/settings.json, and every WebFetch and curl in their session is rejected with exit 2 and a message naming no host. That is the same silent-misconfiguration mode this branch set out to remove, relocated from the API client into the hook — and a safety control that blocks everything is one users disable, which turns an availability problem into an exposure one. The hook now fails open with no configured host, matching the file's existing convention for unreadable and malformed stdin. Three tests cover it; verified the control still blocks the configured host (exit 2) and still allows unrelated traffic. resolveProject and resolveProjectArgs called DetectProject(client.Host) before any request, so an unconfigured auto-detect reported `no git remote matching host ""` rather than the actionable error. Auto-detection inside a checkout is the documented workflow, so that wrong diagnosis was the first one most users would hit. Both now return ErrNoHost. Audited the remaining non-request consumers of the host value rather than only the request builders. glkcli/job_trace.go collapses its cache path when the host is empty, but nothing can write to the collapsed path — the write happens after a fetch that now fails — so the read misses and falls through to the guarded request; verified end-to-end. greccli/locate.go degrades from exact lookup to fuzzy search, which is correct: grec is local-only and must not require a GitLab host. Broaden the ADR invariant accordingly. The rule that would have caught this is about every consumer of the host value, not only methods that build requests: builders fail closed and loudly, while matchers and detectors fail open or invert silently and need their own decision about which. Update the work-items search URL to the current canonical shape; docs.gitlab.com has dropped the /ee/ prefix and .html suffix. Crow-Session: E3CF07BB-A433-4ABA-9A23-2A3FB3B61671 Co-Authored-By: Claude --- ARCHITECTURE.md | 2 +- glkcli/hook.go | 9 ++++++ glkcli/hook_test.go | 50 ++++++++++++++++++++++++++++++++++ glkcli/project_resolve.go | 9 ++++++ glkcli/project_resolve_test.go | 22 +++++++++++++++ glkcli/work_items.go | 2 +- 6 files changed, 92 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fb93086..bc9d027 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -184,6 +184,6 @@ The structural problem: "pinned host" is a safety invariant, and an implicit def The host is therefore required, and validated at the three request chokepoints (`Client.Get`, `Client.getStream`, and `Client.GraphQL`) rather than at startup. `GraphQL` needs its own guard because it builds its own request rather than delegating to `Get` — and `glk epic notes` reaches it directly whenever the group argument is a path rather than a numeric ID, skipping the `GetGroupPath` lookup that would otherwise have been guarded. Enforcing at the chokepoint means commands that need the network fail with an actionable error, while offline commands — `init`, `config`, `doctor`, `version`, `install-skills`, `install-hooks` — keep working. That ordering matters: `glk doctor` is the command you run to diagnose a missing host, so it must not itself require one. `grec checkout` performs the same check before building a clone URL, since a hostless URL (`https:///group/project.git`) would otherwise reach `git`. -Any future method that constructs its own `url.URL` instead of delegating to `Get` must add the same guard; a hostless URL escaping into `net/http` surfaces as a multi-kilobyte transport error rather than an actionable one. +Any code that reads the configured host must handle the empty case explicitly — not just methods that build requests. The two failure shapes differ: a request builder that skips the guard emits a hostless URL (`https:///…`) and a multi-kilobyte transport error instead of an actionable one, while a *matcher* that skips it silently inverts. `glk hook run` learned this the hard way: `strings.Contains(s, "")` is true for every `s`, so an unconfigured hook blocked every `WebFetch` and every `curl`/`glab` command rather than none. Request builders fail closed and loudly; matchers and detectors fail open or wrongly, and need their own decision about which. The hook fails open, because a safety control that blocks everything is one users disable. `repo1.dso.mil` remains a valid, documented example. It is no longer a default. diff --git a/glkcli/hook.go b/glkcli/hook.go index da5de54..db81e2f 100644 --- a/glkcli/hook.go +++ b/glkcli/hook.go @@ -45,6 +45,15 @@ Install as a Claude Code PreToolUse hook: toolName := jsonStr(event, "tool_name") host := client.Host + // Fail open when no host is configured. There is no default host, so + // this is a reachable state — and both match paths below use + // strings.Contains, which returns true for an empty needle. Without + // this guard the hook would block every WebFetch and every curl/glab + // command regardless of target. + if host == "" { + return nil + } + switch toolName { case "Bash": command := jsonNested(event, "tool_input", "command") diff --git a/glkcli/hook_test.go b/glkcli/hook_test.go index ca8c6e4..095b69d 100644 --- a/glkcli/hook_test.go +++ b/glkcli/hook_test.go @@ -1,11 +1,15 @@ package glkcli import ( + "bytes" + "log/slog" "net/http" "strings" "testing" + "github.com/radiusmethod/gitlab-kiosk/internal/config" "github.com/radiusmethod/gitlab-kiosk/internal/exitcode" + "github.com/radiusmethod/gitlab-kiosk/internal/gitlab" ) func TestHookRun_BlocksBashCurl(t *testing.T) { @@ -179,3 +183,49 @@ func containsHookBlockError(err error, target **exitcode.HookBlockError) bool { } return false } + +// testSetupNoHost installs a client with no configured host — the reachable +// default state now that there is no default host. +func testSetupNoHost() { + client = &Client{ + GL: gitlab.NewClient(slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))), + Host: "", + Cfg: config.Defaults(), + } +} + +func TestHookRun_NoHostAllowsUnrelatedWebFetch(t *testing.T) { + testSetupNoHost() + defer testTeardown() + + input := `{"tool_name":"WebFetch","tool_input":{"url":"https://example.com/docs"}}` + rootCmd.SetIn(strings.NewReader(input)) + + if _, _, err := executeCmd("hook", "run"); err != nil { + t.Fatalf("hook must fail open with no configured host, got: %v", err) + } +} + +func TestHookRun_NoHostAllowsUnrelatedCurl(t *testing.T) { + testSetupNoHost() + defer testTeardown() + + input := `{"tool_name":"Bash","tool_input":{"command":"curl https://example.com/api"}}` + rootCmd.SetIn(strings.NewReader(input)) + + if _, _, err := executeCmd("hook", "run"); err != nil { + t.Fatalf("hook must fail open with no configured host, got: %v", err) + } +} + +func TestHookRun_NoHostAllowsUnrelatedGlab(t *testing.T) { + testSetupNoHost() + defer testTeardown() + + input := `{"tool_name":"Bash","tool_input":{"command":"glab mr list"}}` + rootCmd.SetIn(strings.NewReader(input)) + + if _, _, err := executeCmd("hook", "run"); err != nil { + t.Fatalf("hook must fail open with no configured host, got: %v", err) + } +} diff --git a/glkcli/project_resolve.go b/glkcli/project_resolve.go index 9bd39f0..7745aac 100644 --- a/glkcli/project_resolve.go +++ b/glkcli/project_resolve.go @@ -3,6 +3,7 @@ package glkcli import ( "fmt" + "github.com/radiusmethod/gitlab-kiosk/internal/gitlab" "github.com/radiusmethod/gitlab-kiosk/internal/gitremote" "github.com/spf13/cobra" ) @@ -15,6 +16,10 @@ func resolveProject(cmd *cobra.Command, args []string, position int) (string, er return args[position], nil } + if client.Host == "" { + return "", gitlab.ErrNoHost + } + project, err := gitremote.DetectProject(client.Host) if err != nil { return "", fmt.Errorf("no project argument provided and auto-detection failed: %w", err) @@ -34,6 +39,10 @@ func resolveProjectArgs(cmd *cobra.Command, args []string, expectedTotal int) (s return args[0], args[1:], nil } + if client.Host == "" { + return "", nil, gitlab.ErrNoHost + } + project, err := gitremote.DetectProject(client.Host) if err != nil { return "", nil, fmt.Errorf("no project argument provided and auto-detection failed: %w", err) diff --git a/glkcli/project_resolve_test.go b/glkcli/project_resolve_test.go index 9b111e7..5876f4c 100644 --- a/glkcli/project_resolve_test.go +++ b/glkcli/project_resolve_test.go @@ -2,9 +2,11 @@ package glkcli import ( "bytes" + "errors" "strings" "testing" + "github.com/radiusmethod/gitlab-kiosk/internal/gitlab" "github.com/radiusmethod/gitlab-kiosk/internal/gitremote" "github.com/spf13/cobra" ) @@ -92,3 +94,23 @@ func TestResolveProject_ExplicitArgAtPosition1(t *testing.T) { t.Errorf("project = %q, want %q", project, "big-bang/bigbang") } } + +func TestResolveProject_NoHostReturnsErrNoHost(t *testing.T) { + testSetupNoHost() + defer testTeardown() + + _, err := resolveProject(rootCmd, []string{}, 0) + if !errors.Is(err, gitlab.ErrNoHost) { + t.Fatalf("resolveProject() error = %v, want ErrNoHost", err) + } +} + +func TestResolveProjectArgs_NoHostReturnsErrNoHost(t *testing.T) { + testSetupNoHost() + defer testTeardown() + + _, _, err := resolveProjectArgs(rootCmd, []string{"50001"}, 2) + if !errors.Is(err, gitlab.ErrNoHost) { + t.Fatalf("resolveProjectArgs() error = %v, want ErrNoHost", err) + } +} diff --git a/glkcli/work_items.go b/glkcli/work_items.go index b88ba39..7229b27 100644 --- a/glkcli/work_items.go +++ b/glkcli/work_items.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const gitlabSearchSyntaxURL = "https://docs.gitlab.com/ee/user/search/advanced_search.html" +const gitlabSearchSyntaxURL = "https://docs.gitlab.com/user/search/advanced_search/" var workItemsSearch string