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
5 changes: 5 additions & 0 deletions .github/workflows/cao.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"version": 1,
"gh-aw-version": "v1.0.1",
"control-plane": {
"token-broker": {
"copilot-requests": {
"workflows": []
}
},
"scope": {
"allowed-owners": [
"githubnext",
Expand Down
38 changes: 37 additions & 1 deletion .github/workflows/shared/cao.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,41 @@
"queue": { "type": "object", "description": "Strict bounded QueueOptions contract validated by the gateway at startup; see RFC-033." }
}
},
"tokenBroker": {
"type": "object",
"description": "Credential authorization only, independent of global rollout scope; does not grant write or live capabilities. Omission disables the broker.",
"additionalProperties": false,
"required": ["copilot-requests"],
"properties": {
"copilot-requests": {
"type": "object",
"additionalProperties": false,
"required": ["workflows"],
"properties": {
"workflows": {
"type": "array",
"description": "Exact approved workflow identities. An empty list authorizes nothing.",
"maxItems": 128,
"uniqueItems": true,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["repository-id", "owner-id", "workflow-ref", "workflow-sha"],
"properties": {
"repository-id": { "type": "string", "pattern": "^[1-9][0-9]{0,19}(?![\\s\\S])" },
"owner-id": { "type": "string", "pattern": "^[1-9][0-9]{0,19}(?![\\s\\S])" },
"workflow-ref": {
"type": "string",
"pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9._-]*/\\.github/workflows/[A-Za-z0-9_-][A-Za-z0-9._-]*\\.ya?ml@refs/(?:heads|tags)/(?!.*\\.lock(?:/|$))[A-Za-z0-9_-]+(?:[./][A-Za-z0-9_-]+)*(?![\\s\\S])"
},
"workflow-sha": { "type": "string", "pattern": "^[a-f0-9]{40}(?![\\s\\S])" }
}
}
}
}
}
}
},
"controlPlane": {
"type": "object",
"additionalProperties": false,
Expand All @@ -678,7 +713,8 @@
"operations": { "$ref": "#/$defs/controlOperations" },
"publishing": { "$ref": "#/$defs/publishing" },
"marketplace": { "$ref": "#/$defs/marketplace" },
"webhook-gateway": { "$ref": "#/$defs/webhookGateway" }
"webhook-gateway": { "$ref": "#/$defs/webhookGateway" },
"token-broker": { "$ref": "#/$defs/tokenBroker" }
}
},
"targetCampaign": {
Expand Down
36 changes: 35 additions & 1 deletion .github/workflows/shared/policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export { AuthorityError as PolicyError } from "./authority.mjs";

const SCHEMA_URI = "https://raw.githubusercontent.com/githubnext/gh-cao/main/.github/workflows/shared/cao.schema.json";
const ROOT_KEYS = ["$schema", "version", "gh-aw-version", "control-plane", "target-authority"];
const CONTROL_KEYS = ["scope", "activity", "inventory", "web", "defaults", "campaigns", "workflows", "operations", "publishing", "marketplace", "webhook-gateway"];
const CONTROL_KEYS = ["scope", "activity", "inventory", "web", "defaults", "campaigns", "workflows", "operations", "publishing", "marketplace", "webhook-gateway", "token-broker"];
const TOKEN_BROKER_WORKFLOW_KEYS = ["repository-id", "owner-id", "workflow-ref", "workflow-sha"];
const TOKEN_BROKER_ID_PATTERN = /^[1-9][0-9]{0,19}(?![\s\S])/;
const TOKEN_BROKER_SHA_PATTERN = /^[a-f0-9]{40}(?![\s\S])/;
const TOKEN_BROKER_WORKFLOW_REF_PATTERN = /^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*\/\.github\/workflows\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.ya?ml@refs\/(?:heads|tags)\/(?!.*\.lock(?:\/|$))[A-Za-z0-9_-]+(?:[./][A-Za-z0-9_-]+)*(?![\s\S])/;
const SCOPE_KEYS = ["allowed-owners", "allowed-repositories"];
const ACTIVITY_KEYS = ["repositories", "public-only"];
const INVENTORY_KEYS = ["max-scan-repositories", "cell-count", "cell-index", "batch-size", "batch-index"];
Expand Down Expand Up @@ -294,6 +298,36 @@ function validateControlPlane(control) {
if ("publishing" in control) validatePublishing(control.publishing);
if ("marketplace" in control) validateMarketplace(control.marketplace);
if ("webhook-gateway" in control) validateWebhookGateway(control["webhook-gateway"], control.scope);
if ("token-broker" in control) validateTokenBroker(control["token-broker"]);
}

function validateTokenBroker(broker) {
const path = "control-plane.token-broker";
assertMapping(broker, path);
assertKeys(broker, ["copilot-requests"], path);
const requests = broker["copilot-requests"];
assertMapping(requests, `${path}.copilot-requests`);
assertKeys(requests, ["workflows"], `${path}.copilot-requests`);
const workflows = requests.workflows;
if (!Array.isArray(workflows) || workflows.length > 128) {
throw new PolicyError(`${path}.copilot-requests.workflows must contain at most 128 entries`);
}
const identities = new Set();
for (const [index, workflow] of workflows.entries()) {
const entryPath = `${path}.copilot-requests.workflows[${index}]`;
assertMapping(workflow, entryPath);
assertKeys(workflow, TOKEN_BROKER_WORKFLOW_KEYS, entryPath);
for (const key of ["repository-id", "owner-id"]) {
assertString(workflow[key], `${entryPath}.${key}`, TOKEN_BROKER_ID_PATTERN);
}
assertString(workflow["workflow-ref"], `${entryPath}.workflow-ref`, TOKEN_BROKER_WORKFLOW_REF_PATTERN);
assertString(workflow["workflow-sha"], `${entryPath}.workflow-sha`, TOKEN_BROKER_SHA_PATTERN);
const identity = JSON.stringify(TOKEN_BROKER_WORKFLOW_KEYS.map(key => workflow[key]));
if (identities.has(identity)) {
throw new PolicyError(`${path}.copilot-requests.workflows must contain unique entries`);
}
identities.add(identity);
}
}

function validateWebhookGateway(gateway, scope) {
Expand Down
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,21 @@ state and available history, not every missed event or expired artifact. There
is no memory disk journal, automatic failover, or dual-write path.
`specs/server-operational-storage.md` specifies these guarantees.

The separately opt-in organization Copilot requests broker
(`server/internal/tokenbroker/`, `POST /api/token-broker`) exchanges GitHub
Actions OIDC identities for dedicated organization App installation tokens with
only `copilot_requests: read`. Its protected
`control-plane.token-broker.copilot-requests.workflows` policy pins immutable
owner/repository IDs, workflow reference and revision; signed job context
isolates issuance and invalidation. It never grants rollout, admission, or
repository-write authority and does not implement Copilot tasks. Private keys
are read from Azure Key Vault using managed identity, never from policy or
caller inputs. This initial HTTPS, process-owned, explicitly single-replica
profile keeps a bounded process-local credential registry for revocation;
crashes can lose cleanup state until upstream expiry, bounded to one hour.
Platform/external listeners and overlapping replicas are unsupported. Live
Copilot inference, renewal and cleanup acceptance remains operator-owned.

The optional hosted activation-admission service (`server/internal/admission/`)
implements gh-aw's separate OIDC-only `POST /api/admission` contract, never CAO
rollout authority. Explicit server configuration selects a protected, atomically
Expand Down
3 changes: 3 additions & 0 deletions CODEBASE.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ components:
actions_credentials:
path: activity/authentication.mjs
responsibility: Actions-only credential profiles, owner-scoped naming and bounded Activity collection planning; native gh cao setup auth exclusively owns operator provisioning
copilot_requests_broker:
path: server/internal/tokenbroker/
responsibility: opt-in HTTPS OIDC exchange for dedicated organization App copilot_requests read tokens; protected cao.json pins immutable owner/repository IDs and workflow reference/revision, signed job context isolates issuance/invalidation, Azure Key Vault managed identity supplies private keys; grants no rollout, admission, repository-write or Copilot task authority; bounded process-local revocation registry requires one explicitly acknowledged process replica, rejects platform/external listeners and overlapping replicas, and can lose cleanup after crashes until upstream expiry bounded to one hour; live inference/renewal/cleanup acceptance remains operator-owned
dashboard:
path: dashboard/
responsibility: publish evidence and build the shared operational dashboard UI for either Pages or the hosted Go server
Expand Down
97 changes: 97 additions & 0 deletions server/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,103 @@ normative [hosted CLI actions specification](../specs/server-hosted-cli.md).

## Supported security boundary

### Optional Copilot requests broker

The opt-in `/api/token-broker` service has a separate Actions OIDC boundary;
dashboard sessions, API bearers and admission decisions never authorize
issuance. Its protected `cao.json` allowlist pins immutable owner/repository
IDs and exact workflow references and revisions. It checks signed execution
and job evidence, denies fork/pull-request and reusable execution, and supports
only `copilot-request` with the trusted `github` provider. Unsupported tasks do
not trigger minting.

A dedicated organization App's private key is read from Azure Key Vault using
managed identity, never from policy or caller inputs. GitHub installation
identity, organization ownership and least-privilege `copilot_requests: read`
are checked before minting. Issuance is billable organization access, not
read-only financial behavior. GitHub Copilot organization budgets must be
configured separately; CAO admission budgets are not a broker spending cap.

Request sizes, upstream responses, timeouts, in-flight issuance, retained
credentials and rate subjects are bounded. Redirects and caller-selected
upstream destinations are forbidden. Never capture broker bodies, OIDC tokens,
App JWTs, Key Vault secrets or returned credentials in logs, traces or proxies.
Failures have empty no-store responses without upstream diagnostics.

The initial broker requires one explicitly acknowledged process replica.
Job invalidation and graceful shutdown revoke tracked tokens, but a crash loses
the process-local revocation registry. Installation tokens can remain valid
until their actual expiry, bounded to one hour. No durable cleanup,
multi-replica operation, Azure Functions hosting, or integrated-deployment
certification is claimed.

#### Broker enrollment and setup

The organization permission is **Copilot Requests: Read-only**
(`copilot_requests: read`); despite its name it authorizes billable AI requests.
Use a dedicated organization-installed App, not a personal/enterprise
installation or the collector App. Review the
[GitHub organization App access documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/administer-copilot/manage-for-organization/manage-app-copilot-access)
and enable organization spending controls before activation.

The checked-in `control-plane.token-broker.copilot-requests.workflows` list in
`cao.json` is empty: no workflow is enrolled by this change. Each enrollment
requires exact `repository-id`, `owner-id`, `workflow-ref`, and `workflow-sha`
strings. The reference includes the repository, workflow filename and branch/tag,
for example `acme/project/.github/workflows/triage.lock.yml@refs/heads/main`;
the SHA is the 40-character lowercase commit SHA of that workflow revision.
Wildcards are not supported. Protect the mounted policy from untrusted writes
and serve the reviewed policy snapshot corresponding to enrolled workflow
revisions. Update the pin only after reviewing a new revision. Broker
authorization is independent of admission, rollout mode and repository writes.

Configure these server environment settings; none contains a private key:

| Setting | Required value |
| --- | --- |
| `CAO_TOKEN_BROKER_ENABLED` | `true` (otherwise disabled) |
| `CAO_TOKEN_BROKER_SINGLE_PROCESS` | `true`, acknowledging one process/replica and volatile cleanup state |
| `CAO_TOKEN_BROKER_URL` | Exact public `https://HOST/api/token-broker`, also the OIDC audience |
| `CAO_TOKEN_BROKER_APP_ID` | Dedicated GitHub App's numeric ID |
| `CAO_TOKEN_BROKER_INSTALLATION_ID` | Organization installation's numeric ID |
| `CAO_TOKEN_BROKER_OWNER_ID` | Organization's immutable numeric ID |
| `CAO_TOKEN_BROKER_KEY_VAULT_SECRET_URL` | `https://VAULT.vault.azure.net/secrets/SECRET`, optionally with a version |
| `CAO_TOKEN_BROKER_MANAGED_IDENTITY_CLIENT_ID` | Optional user-assigned managed identity client ID |
| `CAO_POLICY_PATH` | Protected control-repository `cao.json` path |

Store the App's PEM private key as an Azure Key Vault **secret**, provisioned by
an operator outside the server. Grant the server's managed identity secret-read
access only to that secret (for example, Azure RBAC Key Vault Secrets User at
the narrowest supported scope). App Service managed identity and Azure IMDS
are supported; the runtime never provisions a vault, writes keys, or accepts a
PEM through environment variables, workflow inputs, or request bodies.
An unversioned secret URL reads the current version on each mint, supporting
rotation without persisting keys in CAO. Use a private endpoint/firewall where
appropriate and audit secret reads without capturing values.

Run through hosted HTTPS with the reviewed allowed host and one process-owned
listener. Azure Functions, externally owned listeners, and multiple replicas
are rejected for this initial profile. Do not autoscale, overlap deployments,
or enable broker request/response-body capture in a reverse proxy or APM system.
Only explicitly trusted push, schedule and manual-dispatch execution is
supported; pull requests, reusable workflows, environment-based subjects and
identities lacking signed `check_run_id` job evidence fail closed.
Cleanup uses gh-aw's fresh-OIDC `invalidate` request to revoke only that job's
issued tokens; graceful shutdown also attempts revocation. A crash can lose
cleanup state while GitHub tokens remain usable until their actual expiry
(at most one hour). This is not a durable/multi-replica broker.

For compatible gh-aw releases, opt in with `token-broker.copilot-request: true`
and set `GH_AW_TOKEN_BROKER_URL` to that exact endpoint. Do not also set
`permissions.copilot-requests: write`. As of the upstream experimental
[broker specification](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/specs/token-broker-specification.md),
bounded threat-detection/evals consumers are wired; main-agent inference still
requires renewable AWF credential support. Existing compiled workflows are not
changed here. Before claiming integrated support, test real GitHub OIDC
exchange, authorized Copilot inference, renewal, key rotation and job-scoped
invalidation with your selected gh-aw version and organization entitlements.
Offline tests or a token-shaped response do not establish that acceptance.

The supported deployment is:

- one trusted operator;
Expand Down
16 changes: 16 additions & 0 deletions server/internal/admission/admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ func (f *fixture) token(t *testing.T, claims jwt.MapClaims) string {

type policyFunc func(context.Context, Identity) (Allowances, error)

func TestVerifierExposesSignedBrokerJobClaimsWithoutChangingAdmissionProfile(t *testing.T) {
f := newFixture(t)
f.claims["check_run_id"] = "987654321"
identity, err := f.verifier.Verify(context.Background(), f.token(t, f.claims))
if err != nil || identity.CheckRunID != "987654321" || identity.EnvironmentPresent {
t.Fatal("signed job claim was not preserved")
}
key := identity.Key()
f.claims["check_run_id"] = "987654322"
f.claims["environment"] = "production"
identity, err = f.verifier.Verify(context.Background(), f.token(t, f.claims))
if err != nil || identity.CheckRunID != "987654322" || !identity.EnvironmentPresent || identity.Key() != key {
t.Fatal("broker claim extraction changed admission identity semantics")
}
}

func (f policyFunc) Evaluate(ctx context.Context, i Identity) (Allowances, error) { return f(ctx, i) }

type decisionsFunc func(context.Context, string, *Allowances) (Allowances, error)
Expand Down
4 changes: 4 additions & 0 deletions server/internal/admission/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type Identity struct {
Issuer, Subject, Repository, RepositoryID, OwnerID string
RunID, RunAttempt, Ref, Event, Actor, ActorID string
WorkflowRef, WorkflowSHA string
CheckRunID string
EnvironmentPresent bool
}

func (i Identity) Key() string {
Expand Down Expand Up @@ -134,7 +136,9 @@ func (v *Verifier) Verify(ctx context.Context, raw string) (Identity, error) {
RunID: get("run_id"), RunAttempt: get("run_attempt"), Ref: get("ref"),
Event: get("event_name"), Actor: get("actor"), ActorID: get("actor_id"),
WorkflowRef: get("workflow_ref"), WorkflowSHA: get("workflow_sha"),
CheckRunID: get("check_run_id"),
}
_, i.EnvironmentPresent = claims["environment"]
if !repository.MatchString(i.Repository) || i.Subject == "" || i.Ref == "" || i.Event == "" ||
!clean(i.Actor, 100) || !decimal.MatchString(i.RepositoryID) ||
!decimal.MatchString(i.OwnerID) || !decimal.MatchString(i.ActorID) ||
Expand Down
6 changes: 6 additions & 0 deletions server/internal/server/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,14 @@ func NewAzureFunctionsHandlerFromEnv(ctx context.Context, siteDirectory, dashboa
_ = database.Close()
return nil, err
}
brokerConfig, err := tokenBrokerConfigFromEnv()
if err != nil {
_ = database.Close()
return nil, err
}
app, err := New(ctx, store, Config{
Admission: admissionConfig,
TokenBroker: brokerConfig,
Database: database,
Audit: auditLog,
DatabaseQueriesPath: databaseQueries,
Expand Down
7 changes: 7 additions & 0 deletions server/internal/server/hosted.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,14 @@ func newHostedAppWithPolicy(
_ = database.Close()
return nil, err
}
brokerConfig, err := tokenBrokerConfigFromEnv()
if err != nil {
_ = database.Close()
return nil, err
}
config := Config{
Admission: admissionConfig,
TokenBroker: brokerConfig,
Database: database,
Audit: auditLog,
HostProfile: host.Profile,
Expand Down Expand Up @@ -186,6 +192,7 @@ func newHostedAppWithPolicy(
},
Logger: logger,
}
config.TokenBrokerSingleProcessConfirmed = os.Getenv("CAO_TOKEN_BROKER_SINGLE_PROCESS") == "true"
app, err := New(ctx, store, config)
if err != nil {
_ = database.Close()
Expand Down
Loading
Loading