Add github Tool (gh CLI) with per-user identity delegation - #90
Conversation
Closes #86: create a Tool with the GH CLI preinstalled, authenticated via the integration-gateway's OAuth device-flow identity delegation (ADR 0022), and wire it up in prod. - controllers/core-controller: ToolRunSpec gains SecretEnv (mirroring AgentRunSpec.SecretEnv), ToolSpec gains IdentityProviders (mirroring AgentSpec), and the ToolRun reconciler now merges per-invocation secretEnv over the Tool's static secretEnv. - apps/agent-orchestrator: ToolRunLauncher gains the same per-invocation identity-secretEnv mechanism AgentRunLauncher already had (creates a short-lived k8s Secret, references it from ToolRunSpec.secretEnv, patches ownerReferences for GC); CrdToolRegistry reads Tool.spec.identityProviders; graph.ts's runTool gates the container-tool branch on identityProviders exactly like the existing agent-backed-tool/delegateToAgent gates. - tools/github: new self-contained Tool container with gh CLI preinstalled (pinned release + checksum verification), a command allowlist (src/allowlist.ts), and the standard messaging/callback contract. - charts/community-components: new Tool/ServiceAccount templates, values.yaml githubTool block (PAT or identityLink.enabled), enabled in values-production.yaml reusing the identity-link gateway already on for opencodeSweAgent. - .github/workflows/release.yml: github image added to the build/publish matrix. - docs: new ADR 0027, docs/orchestrator.md and docs/security.md updated, tools/github/README.md, root README.md tool table.
b435359 to
789e274
Compare
# Conflicts: # .github/workflows/release.yml # charts/community-components/values-production.yaml # charts/community-components/values.yaml # docs/adr/README.md
There was a problem hiding this comment.
Review
Overview
This PR extends the per-user identity delegation mechanism (ADR 0022) from Agent/AgentRun to container Tool/ToolRun (new ToolRunSpec.SecretEnv, ToolSpec.IdentityProviders, ToolRunLauncher Secret-creation logic, graph.ts's runTool identity gate on the jobTemplate branch), then adds a new github Tool (tools/github/) as the reference implementation — a container running an allowlisted gh CLI command, authenticated as the calling user's own linked GitHub identity rather than a shared bot/PAT. Solid scope, and the "extend the existing mechanism one CRD kind further" framing is well-justified by the ADR's audit of where ADR 0022 was and wasn't wired up.
Correctness
graph.ts's new container-Tool identity gate bypasses the provider-aware gateway resolver (bug).
apps/agent-orchestrator/src/agent/graph.ts around line 1314-1320 (the new tool.jobTemplate branch):
if (!deps.identityLinkGateway || !state.identity) { ... }
const provider = tool.identityProviders[0]!;
const existing = await deps.identityLinkGateway.getToken(provider, state.identity.subject);This hardcodes deps.identityLinkGateway directly. Every other identity-gate call site in this same file — delegateToAgent (line 971) and the pre-existing agent-backed-tool branch (line 1232) — instead calls identityGatewayFor(provider, deps), which routes "claude" to deps.claudeAuthGateway and everything else to deps.identityLinkGateway (line 498). PROVIDER_ENV_VAR already maps both github and claude (line 485), so the codebase clearly anticipates a container Tool eventually declaring identityProviders: ["claude"]. When that happens, this new branch will call the GitHub-specific gateway's getToken("claude", ...) instead of deps.claudeAuthGateway, silently doing the wrong thing (most likely: always reporting "requires linking your claude account" even for a user who has linked Claude, since the wrong client is queried). It's dormant today because the only container Tool that declares identityProviders is github, but it's a real correctness gap relative to the pattern this same PR's ADR explicitly says it's mirroring. Suggest swapping in identityGatewayFor(provider, deps) here too, for consistency and to close the gap before a second provider is added.
Test coverage
The tools/github/ package only ships src/allowlist.test.ts (7 tests, matching the PR description's count). src/github.ts (subprocess spawn, timeout/kill handling, error wrapping), src/config.ts (env parsing/fallbacks), src/index.ts (the actual pipeline: validate → exec → emit, JSON vs. text code-fencing, error-code mapping), and src/security/redact.ts (the token-redaction regexes) have zero tests. Given this tool's whole job is spawning a subprocess with a live credential and scrubbing that credential out of anything that echoes back, I'd expect at least a few unit tests on redact()/clip() (do the ghp_/gho_/etc. patterns actually match real-shaped tokens? does Bearer <token> get redacted?) and on runGh's timeout-kill path, since those are the parts most likely to silently regress.
Minor / nits
tools/github/README.md(~line 1419 of the diff) says the token is injected viaToolRunSpec.secretEnv"(ADR 0025)" — that should be ADR 0028 (0025 is the unrelated "triage agent starting-work comment" ADR). Everywhere else in this PR correctly cites 0022/0027/0028.src/security/redact.ts'sSECRET_PATTERNShas both/gh[opsu]_[A-Za-z0-9]{20,}/gand a separate/ghr_[A-Za-z0-9]{20,}/gright below it — could just be/gh[oprsu]_[A-Za-z0-9]{20,}/g(addrto the character class) and drop the second pattern. Purely cosmetic.ToolRunLauncher.launch()creates the per-invocation identity Secret before creating theToolRunCR, and only attaches anownerReference(for GC) after the CR is created. IfcreateNamespacedCustomObjectitself fails (the CR is rejected, a transient k8s error, etc.), the${name}-identitySecret containing the live token is left behind with no owner and no cleanup path. This exactly mirrorsAgentRunLauncher's existing, unchanged behavior (same order of operations there), so it's pre-existing risk being extended rather than a new regression — but worth a follow-up (e.g. a cleanup on CR-creation failure, or a TTL/reconciler sweep for orphaned-identitySecrets) now that a second launcher shares the pattern.
Security
The core design is sound: no shared bot credential, spawn (never a shell string) so the allowlisted argv can't be reinterpreted, no persisted gh config, redaction on the failure path, and the explicit allowlist-not-blocklist stance on top-level commands (correctly reasoned as defense-in-depth rather than the primary boundary, since the real boundary is the delegated human's own GitHub permissions). The Go/TS/Helm plumbing for secretEnv/identityProviders looks consistent with the existing AgentRunSpec/AgentSpec shapes it's mirroring, and the new Go controller test (toolrun_controller_test.go) correctly verifies ToolRun-level secretEnv wins over the Tool's static entry.
Overall: good, well-documented extension of an existing pattern. The identityGatewayFor bypass is the one thing I'd want fixed before this lands, since it undermines the provider-agnostic design the surrounding code (and this PR's own ADR) calls out as the point of that helper.
# Conflicts: # docs/adr/README.md
Fixes the review feedback on the github Tool / tool-level identity delegation change: - graph.ts runTool container-Tool branch no longer hard-codes deps.identityLinkGateway. It now resolves credentials through the same AuthorizationService.resolveLinkedCredentials() the agent-backed-tool branch uses, so gateway selection is provider-aware (claude -> claudeAuthGateway, not the GitHub-only identityLinkGateway) and the credential subject is keyed identically (canonical principal for cross-entry-point providers). This also fixes a merge fallout where the branch referenced PROVIDER_ENV_VAR, which main had moved out of graph.ts. - New graph.test.ts case proves a container Tool with identityProviders:["claude"] routes to claudeAuthGateway and launches with the Claude token, never the GitHub gateway's. - tools/github: add the missing unit tests the review called out -- redact()/clip() (every gh token prefix, Bearer/token headers, redact-before- truncate), runGh() (stdout, non-zero exit, the SIGKILL timeout path, token env wiring), and config env parsing/fallbacks. - redact.ts: collapse the gh[opsu]_ + ghr_ patterns into one gh[oprsu]_. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NShiscsWYo4PM1hFpp1J4
|
Thanks for the thorough review — addressed the feedback in Correctness — Test coverage — Nits:
All npm workspace |
The validate-crds workflow asserts every template under templates/ appears in the full-catalog render; the new github Tool and its ServiceAccount rendered nothing (githubTool disabled by default), so CI failed. Enable it with identityLink so the identityProviders path is exercised too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MhoD3i7rnXfT1ogRXiK57
The AuthorizationService extraction (ADR 0030 §1) that landed on main while
this PR was in review left runTool with the same credential-resolution +
four-way error-mapping block duplicated verbatim across its two tool-launch
branches: the agent-backed branch (from main's refactor) and the container-tool
branch this PR added. The two copies differed only in indentation and the
not-linked hint text.
Collapse both onto a single `resolveToolIdentitySecretEnv` helper that calls
`authorization.resolveLinkedCredentials` and maps the four failure kinds,
parameterizing only the caller-specific "who to link with" hint via a
`linkHint(provider)` builder ("this agent" for the agent-backed branch; "an
agent that uses <provider> identity linking" for the container branch, which has
no backing agent to chat). The container branch keeps its
`identityProviders`-present guard, since a container Tool that declares no
providers needs no caller identity at all.
Behavior is unchanged: `resolveLinkedCredentials` already treats an empty
provider list as a no-op `resolved`, and every asserted error substring
("requires linking your <provider> account", "no identity-link gateway is
configured", ...) is preserved. Full agent-orchestrator suite green (463 tests),
typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AiNx48kN87UszfNMv6unk
|
Does this PR need refactoring after the recent OAuth-delegation changes? Short answer: it was already correct — but there was one duplication worth removing, now pushed as I checked this PR against the delegation code currently on
Verification: If you'd rather keep the two branches spelled out inline, this commit is safe to drop — it's purely a DRY pass, not a correctness fix. 🤖 Generated with Claude Code |
Summary
Closes #86 ("Create a GitHub tool").
Adds a new
githubTool (a containerTool/ToolRun, not an Agent) with the GitHub CLI (gh) preinstalled, and everything needed to wire it up in production, authenticated as the calling user's own linked GitHub identity via the integration-gateway's OAuth Device Flow delegation (ADR 0022) — not a shared bot/PAT.Why this needed more than a new tool container
Auditing the existing per-user GitHub identity delegation mechanism (ADR 0022) showed it was wired exclusively through
Agent/AgentRun—ToolRunSpechad no per-invocationsecretEnvat all,ToolSpechad noidentityProviders, the TSToolRunLauncherhad no Secret-creation logic, andgraph.ts'srunToolonly checked identity for agent-backed tools. So this PR extends that mechanism one CRD kind further (ADR 0025) before adding the tool itself:controllers/core-controller:ToolRunSpec.SecretEnv(mirrorsAgentRunSpec.SecretEnv),ToolSpec.IdentityProviders(mirrorsAgentSpec), andToolRunReconciler.buildJobnow calls the already-genericmergeSecretEnv— regenerated CRDs/deepcopy viamake manifests generate.apps/agent-orchestrator:ToolRunLaunchergains the same per-invocation identity-secretEnvmechanismAgentRunLauncheralready had (creates a short-lived<name>-identityk8s Secret, references it fromToolRunSpec.secretEnv, patchesownerReferencesfor GC);CrdToolRegistryreadsTool.spec.identityProviders;graph.ts'srunToolgates the container-tool branch onidentityProvidersexactly like the existing agent-backed-tool/delegateToAgentgates.toolrun_controller_test.gomerge test; TS:toolrun-launcher.test.ts,crd-tool-registry.test.ts,graph.test.ts).The tool itself (
tools/github/)ghCLI preinstalled via a pinned release binary + sha256 checksum verification (same pattern astools/kubectl-readonly's pinnedkubectl).ghcommand line (e.g."issue view 86 --repo owner/repo --json title,body"); output:gh's own stdout, delivered over the standard callback/messaging contract.src/allowlist.ts) excludesauth/api/config/secret/ssh-key/etc. entirely and a handful of irreversible-ish subcommands (repo delete,issue delete/transfer,workflow run, ...) even within an otherwise-allowed command — see the file header andtools/github/README.mdfor the full rationale, including why (unlikekubectl-readonly) flags/values aren't additionally restricted: the real authorization boundary here is the delegated human's own GitHub permissions, not this tool's RBAC.GH_CONFIG_DIRpoints at the container's writable/tmp; the token lives only in-process, sourced from a per-run k8s Secret garbage-collected with itsToolRun.Prod wiring
charts/community-components: newtemplates/tool-github.yaml+templates/serviceaccount-github.yaml,values.yamlgithubToolblock (static PAT oridentityLink.enabled), enabled invalues-production.yamlreusing the identity-link gateway already turned on foropencodeSweAgent— no new gateway/App config needed..github/workflows/release.yml:githubimage added to the build/publish/deploy matrix (path filter, image matrix entry, deploy trigger).Docs
New ADR (
docs/adr/0025-...md),docs/orchestrator.mdanddocs/security.mdupdated,tools/github/README.md, rootREADME.mdtool table/layout.Testing
controllers/core-controller:go build ./...,go vet ./...,gofmt -l .clean;go test ./internal/controller/...passes (including the new merge test).npm run typecheck --workspacesandnpm run test --workspacespass (301 → 309 orchestrator tests; 7 newgithubtool tests; newtoolrun-launcher/crd-tool-registrytests).helm lint/helm templateverified for bothvalues.yamlandvalues-production.yaml, withgithubTool.enabled/identityLink.enabledon and off.ghinvocation was not exercised (no Docker daemon in this sandbox) — the Dockerfile mirrorstools/kubectl-readonly's proven pinned-binary-with-checksum pattern.