-
Notifications
You must be signed in to change notification settings - Fork 37
Description
Status: Proposed
Date: 2025-12-05
Deciders: Platform Team, Security Team
Technical Story: Migrate amber-issue-handler.yml from GitHub Actions to platform-native execution
Context and Problem Statement
The Amber background agent currently runs via GitHub Actions (.github/workflows/amber-issue-handler.yml). When a GitHub issue receives an amber:* label or is triggered via /amber execute, the workflow spins up an ubuntu-latest runner, installs Claude Code CLI, executes the agent with --dangerously-skip-permissions, and interacts with GitHub via GITHUB_TOKEN.
This approach has significant security concerns related to permission scope.
The Permission Problem
The workflow declares these permissions:
permissions:
contents: write
issues: write
pull-requests: write
id-token: writeThe GITHUB_TOKEN provided to the workflow has repository-wide scope. The spawned Claude agent can comment on or close any issue in the repository, not just the triggering issue. It can modify any pull request (and possibly approve if the GHA setting is checked that allows that). It can push code to any unprotected branch.
This violates the principle of least privilege. A task triggered by issue #42 should only be able to interact with issue #42 and its resulting PR.
Beyond the permission scope, there are additional concerns. The agent runs in a shared GHA runner with no container isolation. The ANTHROPIC_API_KEY and GITHUB_TOKEN are available in the runner environment. Actions taken by Claude appear as github-actions[bot], not tied to the triggering user. And there's an irony worth noting: the Ambient Code Platform exists to orchestrate exactly this type of task, yet Amber runs outside it.
Decision Drivers
The key factors influencing this decision are: enforcing least privilege so agents only access resources relevant to their task; enabling credential scoping so GitHub tokens can be narrowly scoped per-issue or per-PR; dogfooding our own platform for our own automation; maintaining an audit trail that links agent actions to the humans who triggered them; providing resource isolation by running agent workloads in isolated containers; and leveraging the runner infrastructure we've already built.
Considered Options
Option 1: Keep GHA, Reduce Token Scope. Modify the workflow to use fine-grained PATs or GitHub App installation tokens scoped to specific resources. This requires minimal changes and stays in the GHA ecosystem, but GitHub doesn't support per-resource token scoping. It would require a custom GitHub App with complex permission logic.
Option 2: Platform-Native Execution. Convert Amber from a GHA workflow to a platform-native integration. A lightweight GHA workflow or GitHub App webhook receives the trigger event, creates an AgenticSession CR via the platform API, and the platform operator spawns a runner pod with task-scoped credentials. Results are reported back to the specific issue/PR via a scoped GitHub token. This enables full credential scoping and container isolation, though it adds moving parts.
Option 3: GitHub App with Resource-Specific Tokens. Build a dedicated GitHub App that mints short-lived, resource-scoped tokens for each Amber task. This achieves proper credential scoping within the GitHub ecosystem, but requires significant development effort and still runs outside the platform.
Decision Outcome
Chosen option: Option 2 (Platform-Native Execution).
This aligns with ADR-0001's Kubernetes-native architecture. It enables proper credential scoping by adding a broker mode to the existing ambient-content sidecar that validates operations against session-defined contexts. It provides container isolation for agent execution. It creates an audit trail linking sessions to triggering users. And it demonstrates the platform's capability by using it for our own needs.
Proposed Architecture
flowchart TD
event[GitHub Issue Event] --> webhook[Webhook Handler<br/>GHA or GitHub App]
webhook -->|"POST /api/.../sessions<br/>(with repos + attachedContexts)"| api[Platform Backend API]
api -->|"Creates AgenticSession CR"| operator[Operator]
operator -->|"Spawns Pod"| pod
subgraph pod[Agent Pod]
runner[Claude Code Runner]
content[ambient-content<br/>:8080 UI / :8081 Broker]
end
runner -->|"All git/forge ops<br/>via localhost:8081"| content
content -->|"Validated writes only"| external[GitHub / JIRA / etc.]
runner -->|"Updates"| status[Session Status in CR]
Dual-Port Content Service Architecture
GitHub's API does not support per-resource token scoping. Installation tokens and fine-grained PATs are repository-scoped at best—there's no way to mint a token that can only push to a specific branch. The solution is to extend the existing ambient-content sidecar with a dual-port architecture.
The platform already runs an ambient-content container alongside each runner. It provides file operations and git endpoints (/content/github/push, /content/git-sync, etc.) that the frontend/backend use to interact with the workspace. Currently, the runner bypasses this service and uses direct git credentials.
The challenge: The content service currently serves the UI/backend, which operate with full trust—the backend already validated RBAC before making requests. Agent operations require scope validation, but mixing these trust models in a single endpoint would be error-prone.
The solution: Run the same ambient-content binary with two ports:
- Port 8080 (UI mode): Existing behavior, no scope checks. Used by frontend/backend.
- Port 8081 (broker mode): Scope validation enabled. Used by runner for all git and forge operations.
This follows the existing pattern—the backend already uses CONTENT_SERVICE_MODE for different behaviors. Both modes share the same forge abstraction layer and credential handling.
flowchart LR
subgraph pod[Agent Pod]
subgraph content[ambient-content binary]
ui_port[":8080 (UI mode)<br/>No scope checks"]
broker_port[":8081 (broker mode)<br/>Scope validation"]
end
runner[Claude Code Runner<br/>no git credentials]
end
backend[Backend/UI] -->|"Existing endpoints"| ui_port
runner -->|"POST /broker/..."| broker_port
broker_port -->|"Validates against<br/>repos + attachedContexts"| decision{Allowed?}
decision -->|Yes| external[GitHub / GitLab /<br/>Forgejo / JIRA]
decision -->|No| reject[403 Forbidden]
ui_port -.->|"Direct access<br/>(already RBAC'd)"| external
broker_port -.->|"Uses stored<br/>credentials"| external
This requires:
- Remove git credentials from runner - no
GITHUB_TOKENenv var - Runner calls broker port - use endpoints like
POST /broker/git/{forgeUrl}/push - Broker validates scope - check operation against
reposandattachedContexts - Content service holds credentials - injected via
X-GitHub-Tokenheader from operator
Attached Contexts
The content service validates operations against attached contexts—the set of resources the agent is allowed to touch. Git repository contexts are derived automatically from the existing repos configuration (see ADR-0003), while additional contexts like issues and external services are specified via attachedContexts.
Git contexts from repos. The content service derives permissions from the repos[].output configuration:
spec:
repos:
- input:
url: "https://github.com/ambient-code/platform"
branch: "main"
output:
type: "fork"
targetBranch: "amber/issue-42-autofix"
createPullRequest: trueThis implies permissions: push to amber/issue-42-autofix, create PR. The content service reads repos and constructs the allowed git operations automatically—no separate git context needed.
Additional contexts via attachedContexts. For resources beyond the cloned repos (issues, external services), use attachedContexts:
spec:
repos:
- input:
url: "https://github.com/ambient-code/platform"
branch: "main"
output:
targetBranch: "amber/issue-42-autofix"
createPullRequest: true
# Note: write permissions (comment, post, etc.) imply read
attachedContexts:
# Specific issue
- type: git-issue
url: https://github.com/ambient-code/platform/issues/42
permissions: [comment, edit-own, minimize-own]
# All issues in a repo (useful for triage bots, report generators)
- type: git-issue
url: https://github.com/ambient-code/platform/issues
permissions: [add, edit-own]
# External issue tracker
- type: jira
url: https://jira.example.com/browse/PROJ-123
permissions: [comment]
# Messaging
- type: slack
url: https://slack.com/app/ambient-code/channels/C0123456
permissions: [post]
# Custom/user-provided handler
- type: custom
handler: my-internal-service-handler
url: https://internal.example.com/resource/123
permissions: [write]Pluggable context handlers. The content service routes requests based on the context type. The git-issue type handles forge issues/PRs. Additional types (jira, slack, etc.) can be added over time, and organizations can provide their own handlers for custom types via the handler field.
Issue URL scoping. Issue contexts support both specific issues (/issues/42) and repo-wide (/issues). Repo-wide contexts allow operations on any issue in that repository, subject to permissions.
Issue permissions:
add- create new issues or commentscomment- add comments to existing issues (impliesaddfor comments, not issues)edit-own- edit issues/comments created by this session or the bot identityminimize-own- minimize (hide) comments created by this session or the bot identity
The edit-own and minimize-own permissions are enforced by tracking which resources were created through this session, or by querying the forge API to verify authorship before proxying.
The attached contexts are:
- Derived from
reposfor git push/PR operations - Specified via
attachedContextsfor issues, external services, and custom resources - Editable in the UI (for interactive sessions)
- Stored in the AgenticSession CR
- Read by the content service at startup
An agent working on multiple repositories simply has multiple entries in repos (per ADR-0003). The content service validates each git operation against the repo's configured output branch and permissions.
Seamless Git Experience
The broker port (:8081) provides scoped git operations. The runner calls the broker with the forge URL encoded in the path:
POST /broker/git/github.com%2Fambient-code%2Fplatform/push
{
"repoPath": "platform",
"branch": "amber/issue-42-autofix",
"commitMessage": "fix: resolve issue #42"
}
The broker:
- Decodes the forge URL from the path
- Validates the repository and branch against
reposconfiguration - Verifies the target branch matches
repos[].output.targetBranch - Performs the push using credentials from
X-GitHub-Tokenheader - Returns success/failure to the caller
The runner's workspace has no git credentials configured. Any direct git push attempt fails. The runner must use the broker endpoints, which enforce scope.
For convenience, the runner provides a git-push wrapper script that calls the broker, so agents can use familiar git-like commands while all pushes are mediated.
Generalizing Beyond Git
The broker is forge-agnostic. It detects the forge type from the URL encoded in the path and translates requests to the appropriate API:
flowchart LR
runner[Runner] -->|"POST /broker/issues/{forgeUrl}/comment"| broker[Broker :8081]
broker --> detect{Detect Forge<br/>from URL}
detect -->|"github.com"| gh[GitHub API]
detect -->|"gitlab.com"| gl[GitLab API]
detect -->|"codeberg.org"| fg[Forgejo API]
detect -->|"git.example.com"| cfg[Configured Forge Type]
Forge detection works by matching the host against known forges (github.com, gitlab.com, codeberg.org) or looking up the host in a configuration map for self-hosted instances. The service can also auto-detect by probing the /api/v1 (Forgejo/Gitea) vs /api/v4 (GitLab) endpoints.
Multi-forge repos configuration. The repos array works the same regardless of forge:
repos:
# GitHub
- input:
url: "https://github.com/ambient-code/platform"
branch: "main"
output:
targetBranch: "feature-branch"
createPullRequest: true
# GitLab
- input:
url: "https://gitlab.com/ambient-code/platform"
branch: "main"
output:
targetBranch: "feature-branch"
createMergeRequest: true # GitLab terminology
# Forgejo / Gitea (e.g., Codeberg)
- input:
url: "https://codeberg.org/ambient-code/platform"
branch: "main"
output:
targetBranch: "feature-branch"
createPullRequest: true
# Self-hosted (forge type from config)
- input:
url: "https://git.mycompany.com/team/project"
branch: "main"
output:
targetBranch: "hotfix"
attachedContexts:
# Issues use attachedContexts
- type: git-issue
url: https://github.com/ambient-code/platform/issues/42
permissions: [comment, edit-own, minimize-own]Broker endpoints (port 8081) for git, issue, and PR operations. The forge URL is encoded in the path for consistent routing:
# Push to a branch
POST /broker/git/github.com%2Fambient-code%2Fplatform/push
{"repoPath": "platform", "branch": "...", "commitMessage": "..."}
# Add comment to an issue
POST /broker/issues/github.com%2Fambient-code%2Fplatform%2Fissues%2F42/comment
{"body": "Fixed in commit abc123"}
# Edit own comment
PATCH /broker/issues/github.com%2Fambient-code%2Fplatform%2Fissues%2F42/comment/123456
{"body": "Updated message"}
# Create a pull request
POST /broker/git/github.com%2Fambient-code%2Fplatform/pull-request
{"title": "Fix issue #42", "head": "feature-branch", "base": "main"}
The broker:
- Decodes the forge URL from the path to extract host, owner, repo, resource type, and ID
- Validates the target against
reposandattachedContexts - Looks up credentials for that host (via
X-GitHub-Tokenheader or mounted secrets) - Translates to the appropriate forge API
| Broker Endpoint | GitHub | GitLab | Forgejo |
|---|---|---|---|
POST /broker/git/{forgeUrl}/push |
git push |
git push |
git push |
POST /broker/issues/{issueUrl}/comment |
POST /repos/.../comments |
POST /projects/.../notes |
POST /repos/.../comments |
PATCH /broker/issues/{issueUrl}/comment/{id} |
PATCH /repos/.../comments/... |
PUT /projects/.../notes/... |
PATCH /repos/.../comments/... |
POST /broker/issues/{issueUrl}/minimize/{id} |
GraphQL minimizeComment |
N/A | N/A |
POST /broker/git/{forgeUrl}/pull-request |
POST /repos/.../pulls |
POST /projects/.../merge_requests |
POST /repos/.../pulls |
This design leverages the existing content service pattern. The runner communicates with the broker port on ambient-content via localhost. The UI/backend continues to use port 8080 with the existing /content/* endpoints unchanged.
Credential resolution. The content service receives credentials via the X-GitHub-Token header (already implemented) or looks up per-forge credentials from mounted secrets:
# Mounted as /secrets/forge-credentials.yaml
forges:
# Public forges
github.com:
type: github
auth: token
tokenSecretRef: github-token
gitlab.com:
type: gitlab
auth: token
tokenSecretRef: gitlab-token
codeberg.org:
type: forgejo
auth: token
tokenSecretRef: codeberg-token
# Self-hosted instances
github.mycompany.com:
type: github # GitHub Enterprise Server
auth: token
tokenSecretRef: ghes-token
gitlab.mycompany.com:
type: gitlab # Self-hosted GitLab
auth: token
tokenSecretRef: company-gitlab-token
git.mycompany.com:
type: forgejo # Self-hosted Forgejo/Gitea
auth: token
tokenSecretRef: company-forgejo-tokenThe content service treats self-hosted instances identically to their public counterparts—the only difference is the host in the URL and which credential to use. GitHub Enterprise Server, self-hosted GitLab, and self-hosted Forgejo/Gitea all work with their respective API implementations.
Permission mapping handles forge-specific terminology:
| Generic Permission | GitHub | GitLab | Forgejo |
|---|---|---|---|
create-pr |
Create pull request | Create merge request | Create pull request |
comment |
Issue/PR comment | Issue/MR note | Issue/PR comment |
edit-own |
Edit own comment | Edit own note | Edit own comment |
minimize-own |
Minimize comment | N/A (no equivalent) | N/A |
The content service normalizes these differences so agents use a consistent permission model regardless of the underlying forge.
Prior Art
This architecture is similar to what Anthropic uses for Claude Code on the web, where "the git client authenticates to [a proxy] service with a custom-built scoped credential. The proxy verifies this credential and the contents of the git interaction (e.g., ensuring it is only pushing to the configured branch), then attaches the right authentication token before sending the request to GitHub." However, Anthropic's git proxy is not open source—only the sandbox-runtime for filesystem/network isolation is public.
The CyberArk Secretless Broker provides a similar pattern for databases and SSH, injecting credentials without exposing them to the application. Our extended content service applies this concept to git and issue trackers with scope validation.
Implementation Phases
Phase 1: Platform Execution with Audit Trail. Extend AgenticSession with spec.attachedContexts for declaring allowed resources. Build a GitHub webhook receiver (starting as a GHA that calls the platform API). The runner still receives repo-scoped credentials directly—not ideal, but execution is isolated in a container with an audit trail linking actions to triggering users.
Phase 2: Dual-Port Broker Architecture. Add broker mode to the ambient-content sidecar with a second port (:8081). The broker validates push requests against repos[].output.targetBranch and performs the push using credentials it holds. Remove git credentials from the runner—it must use the broker endpoints. The UI/backend continues using port 8080 unchanged. This phase delivers the core security improvement for git operations.
Phase 3: Extended Forge Operations. Add new broker endpoints for issue comments, PR creation, and other forge APIs. Add scope validation against attachedContexts. Add MCP interface for richer agent integration. The webhook handler moves from GHA to a GitHub App for direct webhook receipt. UI support for editing attached contexts on interactive sessions.
Consequences
On the positive side, agents can only interact with resources relevant to their task. Container isolation prevents cross-session interference. The audit trail via AgenticSession CR links actions to triggering users. The platform demonstrates its own capabilities. And we reduce GHA minutes usage since the platform infrastructure is already running.
On the negative side, the architecture becomes more complex. There's the webhook-to-API-to-operator-to-runner chain. The platform must be operational for Amber to function, unlike a standalone GHA workflow. There's additional latency from K8s job scheduling. However, by extending the existing content service rather than adding a new sidecar, we minimize new components.
The main risks are that platform downtime would disable Amber automation entirely, and migration requires careful handling of in-flight issues. The content service already holds credentials via X-GitHub-Token header—scope validation adds security without changing the credential flow.
Implementation Notes
The implementation adds a dual-port mode to the existing ambient-content service and modifies the runner.
Dual-Port Content Service (components/backend/handlers/content.go):
The content service runs on two ports from the same binary:
- Port 8080 (UI mode): Existing
/content/*endpoints, no scope validation. Used by frontend/backend. - Port 8081 (broker mode): New
/broker/*endpoints with scope validation. Used by runner.
This follows the existing pattern—the backend already uses CONTENT_SERVICE_MODE for different behaviors. Add a new mode value (broker) or run both servers simultaneously.
Broker Mode Implementation:
- Read
reposandattachedContextsfrom AgenticSession CR (passed via env var or fetched from K8s API) - Validate push target branch against
repos[].output.targetBranch - Add broker endpoints:
/broker/git/{forgeUrl}/push,/broker/issues/{issueUrl}/comment,/broker/git/{forgeUrl}/pull-request - Validate issue/PR operations against
attachedContexts - Detect forge type (GitHub, GitLab, Forgejo) from URL and translate to appropriate API
- Track comment ownership for
edit-ownandminimize-ownenforcement - Support pluggable handlers for custom context types (JIRA, Slack, etc.)
Shared Components (between UI and broker modes):
- Forge abstraction layer (GitHub, GitLab, Forgejo API clients)
- Credential resolution (X-GitHub-Token header, mounted secrets)
- URL parsing and normalization
Backend API needs an endpoint for Amber session creation that accepts GitHub issue context and constructs the appropriate repos and attachedContexts. The webhook handler validates the GitHub signature and maps issue metadata to session configuration.
Operator already provisions pods with ambient-content sidecar. Changes needed:
- Remove
GITHUB_TOKENenv var from runner container - Expose both ports on the
ambient-contentcontainer (8080 for UI, 8081 for broker) - Pass session scope info to content service (via env var or K8s API access)
- Content service already receives token via
X-GitHub-Tokenheader from operator
Runner uses wrapper scripts that call broker endpoints instead of direct git:
git-pushwrapper callslocalhost:8081/broker/git/{forgeUrl}/push- New
gh-commentwrapper callslocalhost:8081/broker/issues/{issueUrl}/comment - New
gh-prwrapper callslocalhost:8081/broker/git/{forgeUrl}/pull-request - The runner has no direct access to git or forge credentials
An Amber session spec might look like:
apiVersion: vteam.ambient-code/v1alpha1
kind: AgenticSession
metadata:
name: amber-issue-42-autofix
namespace: ambient-code
spec:
prompt: |
# Amber Agent Task: Issue #42
...
# Git repo config (per ADR-0003) - broker derives push/PR permissions
repos:
- input:
url: "https://github.com/ambient-code/platform"
branch: "main"
output:
targetBranch: "amber/issue-42-autofix"
createPullRequest: true
# Additional contexts for issue interaction
attachedContexts:
- type: git-issue
url: https://github.com/ambient-code/platform/issues/42
permissions: [comment, edit-own, minimize-own]
interactive: false
timeout: 30m
triggeringUser: "@developer"
triggeringEvent:
type: github-issue
issueNumber: 42
actionType: auto-fixThe broker maintains state about which comments were created during this session. When the agent requests to edit or minimize a comment, the broker either checks its local state or queries the forge API to verify the comment author matches the configured bot identity before proxying the request.
Key files:
components/backend/handlers/content.go- Add dual-port mode with broker endpoints and scope validationcomponents/backend/handlers/- Amber session creation, webhook handlingcomponents/operator/internal/handlers/sessions.go- Remove runner git credentials, expose both portscomponents/runners/claude-code-runner/- Use broker endpoints (localhost:8081) for git/forge ops.github/workflows/amber-issue-handler.yml- Convert to thin webhook forwarder
Validation
For Phase 1: Amber tasks execute in platform runner pods, session CRs show completion status, and PRs are created successfully. The audit trail links sessions to triggering users.
For Phase 2: the broker correctly enforces git scope—attempts to push to branches outside the repos[].output.targetBranch are rejected. The runner has no direct git credentials. Broker logs show all git operations with session attribution. UI/backend continues to work unchanged on port 8080.
For Phase 3: end-to-end latency is comparable to the current GHA approach (under 5 minutes). The broker handles GitHub API, JIRA, and other services with the same scope enforcement. UI allows editing attached contexts for interactive sessions.
Links
- Related: ADR-0001 (Kubernetes-Native Architecture)
- Related: ADR-0002 (User Token Authentication)
- Related: ADR-0003 (Multi-Repository Support) -
reposconfiguration extended by this ADR - Current workflow:
.github/workflows/amber-issue-handler.yml - Amber documentation:
docs/amber-automation.md - Prior art: Anthropic sandbox-runtime (Apache 2)
- Prior art: CyberArk Secretless Broker (Apache 2)
- Prior art: Claude Code sandboxing blog post
- GitHub Fine-Grained PATs: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens