Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
26 changes: 19 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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
Expand All @@ -82,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`.
Expand All @@ -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

Expand All @@ -115,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

Expand Down Expand Up @@ -175,3 +173,17 @@ 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 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 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.
5 changes: 3 additions & 2 deletions Formula/gitlab-kiosk.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading