feat(phase-4): WS5 — GitHub Actions OIDC token exchange and trust policies

- POST /oidc/token: GitHub OIDC JWT exchange (bootstrap + agent-scoped modes)
- POST/GET/DELETE /oidc/trust-policies: trust policy CRUD with enforcement
- DB migration 022: oidc_trust_policies table with provider/repo/branch/agent_id
- GitHub Actions: register-agent and issue-token actions with full READMEs
- Trust policy enforcement rejects token exchanges not matching registered policies
- Bootstrap mode issues agents:write token for new agent registration without agentId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-04-02 10:37:39 +00:00
parent 89c99b666d
commit fefbf1e3ea
15 changed files with 1432 additions and 18 deletions

110
.github/actions/issue-token/README.md vendored Normal file
View File

@@ -0,0 +1,110 @@
# sentryagent/issue-token
Issues a SentryAgent.ai OAuth2 Bearer token for an existing agent from a GitHub
Actions workflow.
No long-lived API credentials are required. The action uses a GitHub-issued OIDC
token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`.
The returned access token is automatically masked with `core.setSecret()` so it
never appears in plaintext in workflow logs.
## Prerequisites
### 1. Register the agent
The agent must already exist in SentryAgent.ai. If you need to create the agent
in CI, use [`sentryagent/register-agent@v1`](../register-agent/README.md) first.
### 2. Configure an OIDC Trust Policy for the agent
A trust policy linking the repository to the specific agent must be registered:
```bash
curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \
-H "Authorization: Bearer <your-admin-token>" \
-H "Content-Type: application/json" \
-d '{
"provider": "github",
"repository": "org/your-repo",
"branch": "main",
"agentId": "<agent-uuid>"
}'
```
Omit `branch` to allow any branch to issue tokens for this agent.
### 3. Grant `id-token: write` permission
The workflow must have permission to request a GitHub OIDC token:
```yaml
permissions:
id-token: write
contents: read
```
## Inputs
| Input | Required | Description |
|-------|----------|-------------|
| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) |
| `agent-id` | Yes | UUID of the agent for which to issue an access token |
## Outputs
| Output | Description |
|--------|-------------|
| `access-token` | Short-lived Bearer token. Masked in all log output. |
| `expires-at` | ISO 8601 timestamp indicating when the token expires. |
## Example workflow
```yaml
name: Deploy with Agent Token
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Issue SentryAgent access token
id: token
uses: sentryagent/issue-token@v1
with:
api-url: https://idp.sentryagent.ai
agent-id: ${{ vars.SENTRY_AGENT_ID }}
- name: Call authenticated API
run: |
curl -H "Authorization: Bearer ${{ steps.token.outputs.access-token }}" \
https://my-service.example.com/deploy
```
## Troubleshooting
**HTTP 403 — Trust policy violation**
No trust policy exists for this repository + agent combination. Register a trust
policy using the Prerequisites steps above.
**HTTP 403 — Branch not permitted**
A trust policy exists but specifies a branch constraint that does not match the
current workflow's branch. Add a policy for the current branch, or remove the
branch constraint to allow all branches.
**Failed to obtain a GitHub OIDC token**
Ensure `id-token: write` is set in the workflow's `permissions` block.
**Token expires too quickly**
The default token TTL is set by the SentryAgent.ai server configuration. Check
`expires-at` and re-issue a token before it expires if your workflow is long-running.
## Full documentation
[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions)

153
.github/actions/issue-token/action.js vendored Normal file
View File

@@ -0,0 +1,153 @@
/**
* issue-token GitHub Action script.
*
* Flow:
* 1. Request a GitHub OIDC token via @actions/core.getIDToken()
* 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token
* 3. Set outputs: access-token (masked) and expires-at (ISO 8601)
*
* The access token is immediately registered with core.setSecret() so it never
* appears in plaintext in workflow logs.
*
* Error handling:
* - OIDC exchange failures emit a clear message with a link to the trust policy setup docs
*/
'use strict';
const core = require('@actions/core');
const { HttpClient } = require('@actions/http-client');
/**
* Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token for a specific agent.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken().
* @param {string} agentId - UUID of the agent for which to issue a token.
* @returns {Promise<{ accessToken: string; expiresIn: number }>} The access token and its TTL in seconds.
* @throws {Error} If the exchange fails, with a message including trust policy setup instructions.
*/
async function exchangeOIDCToken(apiUrl, oidcToken, agentId) {
const client = new HttpClient('sentryagent-issue-token/1.0');
const url = `${apiUrl}/api/v1/oidc/token`;
const body = JSON.stringify({
provider: 'github',
token: oidcToken,
agentId,
});
let response;
try {
response = await client.post(url, body, {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` +
`Check that the api-url input is correct and the API is reachable.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode === 403) {
throw new Error(
'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' +
'This usually means no trust policy has been registered for this repository.\n\n' +
'To fix this, register a trust policy by calling:\n' +
` POST ${apiUrl}/oidc/trust-policies\n` +
' Body: { "provider": "github", "repository": "org/repo", "agentId": "<agent-id>" }\n\n' +
'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error_description ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(
`OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` +
'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
let tokenData;
try {
tokenData = JSON.parse(rawBody);
} catch {
throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`);
}
if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) {
throw new Error('OIDC token exchange response did not include an access_token.');
}
const expiresIn = typeof tokenData.expires_in === 'number' ? tokenData.expires_in : 3600;
return { accessToken: tokenData.access_token, expiresIn };
}
/**
* Computes an ISO 8601 expiry timestamp from a TTL in seconds.
*
* @param {number} expiresInSeconds - Number of seconds until the token expires.
* @returns {string} ISO 8601 timestamp string.
*/
function computeExpiresAt(expiresInSeconds) {
return new Date(Date.now() + expiresInSeconds * 1000).toISOString();
}
/**
* Main entry point for the issue-token GitHub Action.
*
* @returns {Promise<void>}
*/
async function run() {
try {
// Read inputs
const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, '');
const agentId = core.getInput('agent-id', { required: true });
core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`);
let oidcToken;
try {
oidcToken = await core.getIDToken(apiUrl);
} catch (err) {
throw new Error(
'Failed to obtain a GitHub OIDC token. ' +
"Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" +
'Example:\n' +
'permissions:\n' +
' id-token: write\n' +
' contents: read\n\n' +
`Underlying error: ${err instanceof Error ? err.message : String(err)}\n` +
'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
core.info(`Exchanging GitHub OIDC token for SentryAgent.ai access token (agent: ${agentId})...`);
const { accessToken, expiresIn } = await exchangeOIDCToken(apiUrl, oidcToken, agentId);
// Mask the token immediately — must happen before any logging or output
core.setSecret(accessToken);
const expiresAt = computeExpiresAt(expiresIn);
core.setOutput('access-token', accessToken);
core.setOutput('expires-at', expiresAt);
core.info(`Access token issued successfully. Expires at: ${expiresAt}`);
} catch (err) {
core.setFailed(err instanceof Error ? err.message : String(err));
}
}
run();

37
.github/actions/issue-token/action.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: 'SentryAgent Issue Token'
description: >
Issues a SentryAgent.ai OAuth2 access token for an agent using GitHub OIDC
token exchange. No long-lived API credentials required. The issued access
token is automatically masked in GitHub Actions logs via core.setSecret().
author: 'SentryAgent.ai'
branding:
icon: 'key'
color: 'blue'
inputs:
api-url:
description: >
Base URL of the SentryAgent.ai AgentIdP API.
Example: https://idp.sentryagent.ai
required: true
agent-id:
description: >
The UUID of the agent for which to issue an access token.
Obtain this from the register-agent action output or from the API.
required: true
outputs:
access-token:
description: >
A short-lived Bearer access token for the specified agent.
The token value is masked in all GitHub Actions log output.
expires-at:
description: >
ISO 8601 timestamp indicating when the access token expires.
Use this to decide when to re-issue a fresh token.
runs:
using: 'node20'
main: 'action.js'

View File

@@ -0,0 +1,96 @@
# sentryagent/register-agent
Registers a new AI agent in SentryAgent.ai from a GitHub Actions workflow.
No long-lived API credentials are required. The action uses a GitHub-issued OIDC
token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`, then
calls `POST /agents` to create the agent.
## Prerequisites
### 1. Configure an OIDC Trust Policy
Before this action can exchange tokens, a trust policy must be registered in
SentryAgent.ai for the repository that will run the workflow.
```bash
curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \
-H "Authorization: Bearer <your-admin-token>" \
-H "Content-Type: application/json" \
-d '{
"provider": "github",
"repository": "org/your-repo",
"branch": "main"
}'
```
Omit `branch` to allow any branch to register agents from this repository.
### 2. Grant `id-token: write` permission
The workflow must have permission to request a GitHub OIDC token:
```yaml
permissions:
id-token: write
contents: read
```
## Inputs
| Input | Required | Description |
|-------|----------|-------------|
| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) |
| `agent-name` | Yes | Unique name (email format) for the new agent |
| `agent-description` | No | Human-readable description of the agent's purpose |
## Outputs
| Output | Description |
|--------|-------------|
| `agent-id` | UUID of the newly registered agent. Use in subsequent steps to issue tokens or manage credentials. |
## Example workflow
```yaml
name: Register Agent
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
register:
runs-on: ubuntu-latest
steps:
- name: Register SentryAgent
id: register
uses: sentryagent/register-agent@v1
with:
api-url: https://idp.sentryagent.ai
agent-name: my-ci-agent@acme.com
agent-description: CI agent for the acme/my-repo build pipeline
- name: Print agent ID
run: echo "Registered agent ${{ steps.register.outputs.agent-id }}"
```
## Troubleshooting
**HTTP 403 — Trust policy not configured**
Register a trust policy for this repository first. See the Prerequisites section above.
**Failed to obtain a GitHub OIDC token**
Ensure `id-token: write` is set in the workflow's `permissions` block.
**Agent registration failed with HTTP 401**
The OIDC token exchange succeeded but the returned access token was rejected by
`POST /agents`. Check that the SentryAgent.ai API version matches and the
bootstrap token has `agents:write` scope.
## Full documentation
[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions)

200
.github/actions/register-agent/action.js vendored Normal file
View File

@@ -0,0 +1,200 @@
/**
* register-agent GitHub Action script.
*
* Flow:
* 1. Request a GitHub OIDC token via @actions/core.getIDToken()
* 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token
* 3. Register a new agent via POST /agents using the access token
* 4. Set the `agent-id` output
*
* Error handling:
* - OIDC exchange failures emit a clear message with a link to the trust policy setup docs
* - Agent registration failures surface the API error message
*/
'use strict';
const core = require('@actions/core');
const { HttpClient, BearerCredentialHandler } = require('@actions/http-client');
/**
* Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken().
* @returns {Promise<string>} The SentryAgent.ai access token.
* @throws {Error} If the exchange fails, with a message including trust policy setup instructions.
*/
async function exchangeOIDCToken(apiUrl, oidcToken) {
const client = new HttpClient('sentryagent-register-agent/1.0');
const url = `${apiUrl}/api/v1/oidc/token`;
const body = JSON.stringify({
provider: 'github',
token: oidcToken,
});
let response;
try {
response = await client.post(url, body, {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` +
`Check that the api-url input is correct and the API is reachable.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode === 403) {
throw new Error(
'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' +
'This usually means no trust policy has been registered for this repository.\n\n' +
'To fix this, register a trust policy by calling:\n' +
` POST ${apiUrl}/oidc/trust-policies\n` +
' Body: { "provider": "github", "repository": "org/repo", "agentId": "<agent-id>" }\n\n' +
'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error_description ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(
`OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` +
'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
let tokenData;
try {
tokenData = JSON.parse(rawBody);
} catch {
throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`);
}
if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) {
throw new Error('OIDC token exchange response did not include an access_token.');
}
return tokenData.access_token;
}
/**
* Registers a new agent via POST /agents.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} accessToken - A valid SentryAgent.ai Bearer access token.
* @param {string} agentName - Email (unique name) for the new agent.
* @param {string} agentDescription - Optional description stored as the owner field.
* @returns {Promise<string>} The UUID of the newly registered agent.
* @throws {Error} If the API returns a non-2xx response.
*/
async function registerAgent(apiUrl, accessToken, agentName, agentDescription) {
const auth = new BearerCredentialHandler(accessToken);
const client = new HttpClient('sentryagent-register-agent/1.0', [auth]);
const url = `${apiUrl}/api/v1/agents`;
const payload = {
email: agentName,
agentType: 'custom',
version: '1.0.0',
capabilities: [],
owner: agentDescription || agentName,
deploymentEnv: 'production',
};
let response;
try {
response = await client.post(url, JSON.stringify(payload), {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai agents endpoint at ${url}.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(`Agent registration failed with HTTP ${statusCode}: ${detail}`);
}
let agentData;
try {
agentData = JSON.parse(rawBody);
} catch {
throw new Error(`Agent registration returned non-JSON response: ${rawBody}`);
}
if (typeof agentData.agentId !== 'string' || agentData.agentId.length === 0) {
throw new Error('Agent registration response did not include an agentId.');
}
return agentData.agentId;
}
/**
* Main entry point for the register-agent GitHub Action.
*
* @returns {Promise<void>}
*/
async function run() {
try {
// Read inputs
const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, '');
const agentName = core.getInput('agent-name', { required: true });
const agentDescription = core.getInput('agent-description') || '';
core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`);
let oidcToken;
try {
oidcToken = await core.getIDToken(apiUrl);
} catch (err) {
throw new Error(
'Failed to obtain a GitHub OIDC token. ' +
"Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" +
'Example:\n' +
'permissions:\n' +
' id-token: write\n' +
' contents: read\n\n' +
`Underlying error: ${err instanceof Error ? err.message : String(err)}\n` +
'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
core.info('Exchanging GitHub OIDC token for SentryAgent.ai access token...');
const accessToken = await exchangeOIDCToken(apiUrl, oidcToken);
core.info(`Registering agent: ${agentName}`);
const agentId = await registerAgent(apiUrl, accessToken, agentName, agentDescription);
core.setOutput('agent-id', agentId);
core.info(`Agent registered successfully. agent-id: ${agentId}`);
} catch (err) {
core.setFailed(err instanceof Error ? err.message : String(err));
}
}
run();

View File

@@ -0,0 +1,39 @@
name: 'SentryAgent Register Agent'
description: >
Registers a new agent in SentryAgent.ai using GitHub OIDC token exchange.
No long-lived API credentials required — the GitHub Actions OIDC token is
exchanged for a short-lived SentryAgent.ai access token to call POST /agents.
author: 'SentryAgent.ai'
branding:
icon: 'shield'
color: 'blue'
inputs:
api-url:
description: >
Base URL of the SentryAgent.ai AgentIdP API.
Example: https://idp.sentryagent.ai
required: true
agent-name:
description: >
Unique name (email) for the agent being registered.
Must be a valid email address format used as the agent identity.
required: true
agent-description:
description: >
Optional human-readable description of the agent's purpose.
Stored as the agent owner field.
required: false
default: ''
outputs:
agent-id:
description: >
The UUID of the newly registered agent.
Use in subsequent steps to issue tokens or manage credentials.
runs:
using: 'node20'
main: 'action.js'