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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ atryum.toml
docker-compose.override.yml
internal/api/web
releases
integrations/.venv
integrations/.run
integrations/.harness-config
integrations/results
integrations/*.db
integrations/*.db-journal
integrations/*.log
integrations/*.pid
opencode-state
node_modules
ui/node_modules
Expand Down
41 changes: 41 additions & 0 deletions Dockerfile.integrations
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
FROM golang:1.25-bookworm

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

ENV DEBIAN_FRONTEND=noninteractive
ENV PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
python3 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

RUN npm install -g @openai/codex @anthropic-ai/claude-code

WORKDIR /src

COPY ui/package.json ui/package-lock.json ./ui/
RUN cd ui && npm ci

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN cd ui && npm run build \
&& rm -rf ../internal/api/web \
&& mkdir -p ../internal/api/web \
&& cp -R dist/. ../internal/api/web/

ENTRYPOINT ["bash", "/src/integrations/scripts/agent_harness_integration_tests.sh"]
CMD ["matrix", "--only-passing", "--harnesses", "fake-agent", "--targets", "calculator"]
21 changes: 19 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ set shell := ["bash", "-cu"]

config := "./atryum.toml"
release_dir := "releases"
integration_image := "atryum-integrations"

# List justfile targets
default:
Expand Down Expand Up @@ -39,9 +40,11 @@ check: fmt test
build:
CGO_ENABLED=0 go build -o ./atryum ./cmd/atryum

# Remove generated binaries, release artifacts, and built UI assets
# Remove generated binaries, release artifacts, built UI assets, and integration test debris
clean:
rm -rf ./atryum {{release_dir}} ui/dist internal/api/web
rm -rf ./atryum {{release_dir}} ui/dist internal/api/web \
integrations/.venv integrations/.run integrations/.harness-config integrations/results \
integrations/*.db integrations/*.db-journal integrations/*.log integrations/*.pid

# Build local production-like atryum binary with the local UI embedded
build-prod: build-ui build
Expand Down Expand Up @@ -163,3 +166,17 @@ integration-test harness="fake-agent" auth="no-auth" target="calculator":
# Run the full integration matrix (skips unavailable harnesses and placeholder auth)
integration-test-matrix *args:
integrations/scripts/agent_harness_integration_tests.sh matrix --only-passing {{args}}

# Build Docker image for integration tests
integration-docker-build:
docker build -f Dockerfile.integrations -t {{integration_image}} .

# Run integration tests inside the Docker image
integration-docker-test *args:
docker run --rm \
-e OPENAI_API_KEY \
-e CODEX_API_KEY \
-e ANTHROPIC_API_KEY \
-e AMP_API_KEY \
-e XAI_API_KEY \
{{integration_image}} {{args}}
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Atryum mediates three kinds of tool calls:

- **Pre-tool hooks from agent harnesses.** Managed harnesses (Claude Code, Cursor, amp, Pi) and autonomous ones (Microsoft Foundry, custom orchestrators) post their intended tool call to `POST /api/v1/external/invocations` (when the harness executes the tool itself) or `POST /api/v1/invocations` (when Atryum should execute it). The harness blocks on the response and only proceeds if Atryum returns an approved status. In the hook path Atryum never touches the tool — it just answers "may this call happen."
- **Direct MCP proxying.** Agents that speak MCP connect to `POST /mcp/{server}` as their MCP endpoint. Atryum implements the JSON-RPC surface (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`) and proxies calls to the configured upstream — HTTP or stdio. Because Atryum is the MCP client to the upstream, it holds the credentials (OAuth tokens, bearer tokens, custom headers) and the agent never sees them. The same approval engine runs on every `tools/call`.
- **Claude Managed Agents events bridge.** Anthropic's hosted harness runs the agent loop on its own infrastructure and never calls Atryum, so for those sessions Atryum dials *out*: it streams a registered session's [events](https://platform.claude.com/docs/en/managed-agents/events-and-streaming), records the raw session events on a synthetic audit invocation, and — when the session blocks on a tool call (`session.status_idle` / `requires_action`) — runs the normal approval rules and answers Claude with a `user.tool_confirmation` (or `user.custom_tool_result`). Each tool call is also recorded as its own invocation. This gates both built-in and MCP tools. Enable it by declaring one or more `[[managed_agents]]` accounts (each with a `name` and `api_key`) and register sessions via `POST /api/v1/admin/managed-agents/sessions` (set `"account"` to target a specific account when more than one is configured). See `examples/managed-agents/`.
- **Claude Managed Agents events bridge.** Anthropic's hosted harness runs the agent loop on its own infrastructure and never calls Atryum, so for those sessions Atryum dials *out*: it discovers linked Claude sessions, streams their [events](https://platform.claude.com/docs/en/managed-agents/events-and-streaming), records the raw session events on a synthetic audit invocation, and — when the session blocks on a tool call (`session.status_idle` / `requires_action`) — runs the normal approval rules and answers Claude with a `user.tool_confirmation` (or `user.custom_tool_result`). Each tool call is also recorded as its own invocation. This gates both built-in and MCP tools. Enable it by declaring one or more `[[managed_agents]]` accounts (each with a `name`, `workspace`, and `api_key`) and link Claude agents from the Agents UI. Manual session registration remains available at `POST /api/v1/admin/managed-agents/sessions`. See `examples/managed-agents/`.

These paths converge on a single service so rules, audit, and the UI work identically regardless of how the call arrived.

Expand Down Expand Up @@ -73,7 +73,8 @@ Admin (UI and operators):
- `/api/v1/admin/rules`, `/{id}` (including reorder/move)
- `/api/v1/admin/agents`, `/{id}`
- `/api/v1/admin/settings`, `/api/v1/admin/policy`
- `/api/v1/admin/managed-agents/sessions` — register a Claude Managed Agents session for the events bridge to watch (body may include `"account"` to choose which `[[managed_agents]]` entry; returns `501` when no `[[managed_agents]]` account is configured)
- `/api/v1/admin/managed-agents/accounts`, `/managed-agents/agents` — discover configured Anthropic accounts and Claude agents for UI linking
- `/api/v1/admin/managed-agents/sessions` — manually register a Claude Managed Agents session for the events bridge to watch; kept as a debugging escape hatch
- `/api/v1/admin/oauth/callback` — OAuth callback for upstream MCP server connect flows

## Frontend
Expand All @@ -92,6 +93,7 @@ SQLite by default, PostgreSQL optional via `server.database_url`. Both are first
- `invocation_events` — append-only lifecycle events.
- `approval_rules` — the rule engine.
- `agents` — local agent records and their authenticated-ID mappings.
- `managed_agent_bindings` — local Atryum agent to Claude Managed Agent links used for session discovery.
- `managed_agent_sessions` — Claude Managed Agents sessions watched by the events bridge, with each one's event cursor for resume-after-restart.

## Config
Expand All @@ -102,6 +104,7 @@ A single TOML file configures process and bootstrap settings; runtime entities (
[server]
listen_addr = ":8080"
public_base_url = "http://localhost:8080" # browser-facing API URL for OAuth callbacks
atryum_instance = "" # stable metadata identity; defaults to public_base_url
database_path = "./atryum.db" # or set database_url for Postgres
database_url = "" # postgres://, postgresql://, sqlite://, file:, or a SQLite path
log_level = "info"
Expand All @@ -115,10 +118,11 @@ machine_key = ""
machine_secret = ""
connection_timeout_seconds = 5

[[managed_agents]] # optional, repeatable — one per Anthropic account
[[managed_agents]] # optional, repeatable — one per Anthropic account/workspace key
name = "default" # unique label; the session-registration "account" targets it
api_key = "" # Anthropic API key; empty entries are skipped
# env override (single account only): ATRYUM_MANAGED_AGENTS_API_KEY, then ANTHROPIC_API_KEY
workspace = "" # required label when api_key is set; used for display/metadata
api_key = "" # Anthropic API key created in that workspace; empty entries are skipped
# env override (single account only): ATRYUM_MANAGED_AGENTS_API_KEY, then ANTHROPIC_API_KEY; workspace label via ATRYUM_MANAGED_AGENTS_WORKSPACE

[[auth]] # optional — repeatable per authorization server
issuer = "https://keycloak.example/realms/agents"
Expand Down
15 changes: 12 additions & 3 deletions atryum.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ listen_addr = ":8080"
# In local dev this should point at the Atryum API server, not the Vite UI.
# In production/Kubernetes it should be the ingress URL.
public_base_url = "http://localhost:8080"
# Stable identity written into Claude Managed Agent metadata when Atryum links
# to an Anthropic agent. Defaults to public_base_url when omitted.
atryum_instance = ""
# SQLite remains the default. database_path is used when database_url is empty.
database_path = "./atryum.db"
# Optional: select storage provider by URL scheme.
Expand Down Expand Up @@ -57,13 +60,18 @@ request_timeout_seconds = 30
# api_key are skipped; with no usable entry the bridge is disabled. See
# examples/managed-agents/.
#
# Environment overrides for api_key (single-account convenience only):
# Environment overrides for api_key/workspace label (single-account convenience only):
# ATRYUM_MANAGED_AGENTS_API_KEY (highest), then ANTHROPIC_API_KEY. These apply
# only when zero or one [[managed_agents]] entry is configured. With multiple
# entries each must set its own api_key in TOML.
# entries each must set its own api_key in TOML. Set
# ATRYUM_MANAGED_AGENTS_WORKSPACE when using the env-only API key path. The API
# key itself must be created in the target Anthropic workspace; workspace is a
# label used by Atryum for display and ownership metadata, not an Anthropic
# request selector.
[[managed_agents]]
name = "default" # unique label; targeted by the session-registration "account" field
api_key = ""
workspace = "" # required label when api_key is set; Anthropic workspace identifier/name
api_key = "" # Anthropic API key created in that workspace
# Optional tuning (defaults shown):
# base_url = "https://api.anthropic.com"
# poll_interval_millis = 1000
Expand All @@ -74,6 +82,7 @@ api_key = ""
# Add more accounts by repeating the table:
# [[managed_agents]]
# name = "staging"
# workspace = "staging-workspace"
# api_key = ""

# Agent sync and AI evaluation settings (org, record type, charter field)
Expand Down
60 changes: 58 additions & 2 deletions cmd/atryum/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func runServer(args []string) error {
oauthRepo := store.NewOAuthRepoWithDialect(db, dialect)
rulesRepo := store.NewRulesRepoWithDialect(db, dialect)
agentsRepo := store.NewAgentsRepoWithDialect(db, dialect)
managedAgentBindingRepo := store.NewManagedAgentBindingRepoWithDialect(db, dialect)
agentSyncSettingsRepo := store.NewAgentSyncSettingsRepoWithDialect(db, dialect)
llmConfigsRepo := store.NewLLMConfigsRepoWithDialect(db, dialect)

Expand Down Expand Up @@ -186,7 +187,7 @@ func runServer(args []string) error {
// invocation package interfaces without creating import cycles.
var invAgents invocation.AgentLookup
if agentsRepo != nil {
invAgents = &agentsLookupAdapter{repo: agentsRepo}
invAgents = &agentsLookupAdapter{repo: agentsRepo, managedBindings: managedAgentBindingRepo}
}

// Build the evaluator: always create a local evaluator backed by llmConfigsRepo.
Expand Down Expand Up @@ -219,6 +220,7 @@ func runServer(args []string) error {
syncAgentsFn = syncAgents
}
handler := api.NewHandler(service, serverAdmin, policyRegistry, rulesRepo, agentsRepo, agentSyncSettingsRepo, llmConfigsRepo, syncAgentsFn, backendClient, localEvaluator)
handler.SetManagedAgentBindings(managedAgentBindingRepo)

authValidator, err := auth.NewValidator(cfg.Auth, nil)
if err != nil {
Expand Down Expand Up @@ -252,8 +254,12 @@ func runServer(args []string) error {
if ma.APIKey == "" {
continue
}
if ma.Workspace == "" {
return fmt.Errorf("managed_agents workspace label is required for account %q; use an Anthropic API key created in that workspace", emptyDefault(ma.Name, managedagents.DefaultAccountName))
}
acctCfg := managedagents.Config{
Name: ma.Name,
Workspace: ma.Workspace,
BaseURL: ma.BaseURL,
APIKey: ma.APIKey,
PollInterval: time.Duration(ma.PollIntervalMillis) * time.Millisecond,
Expand All @@ -277,6 +283,12 @@ func runServer(args []string) error {
if err != nil {
return fmt.Errorf("configure managed agents bridge: %w", err)
}
instanceName := cfg.Server.AtryumInstance
if instanceName == "" {
instanceName = cfg.Server.PublicBaseURL
}
managedSvc.SetInstanceName(instanceName)
managedSvc.SetBindings(&managedBindingStoreAdapter{repo: managedAgentBindingRepo})
if err := managedSvc.Start(context.Background()); err != nil {
return fmt.Errorf("start managed agents bridge: %w", err)
}
Expand Down Expand Up @@ -351,6 +363,10 @@ func (a *managedSessionStoreAdapter) List(ctx context.Context) ([]managedagents.
return out, nil
}

func (a *managedSessionStoreAdapter) Delete(ctx context.Context, sessionID string) error {
return a.repo.Delete(ctx, sessionID)
}

func (a *managedSessionStoreAdapter) UpdateCursor(ctx context.Context, sessionID, lastEventID string) error {
return a.repo.UpdateCursor(ctx, sessionID, lastEventID)
}
Expand All @@ -367,6 +383,27 @@ func managedSessionToReg(row store.ManagedAgentSession) managedagents.SessionReg
}
}

type managedBindingStoreAdapter struct {
repo *store.ManagedAgentBindingRepo
}

func (a *managedBindingStoreAdapter) List(ctx context.Context) ([]managedagents.AgentBinding, error) {
rows, err := a.repo.List(ctx)
if err != nil {
return nil, err
}
out := make([]managedagents.AgentBinding, 0, len(rows))
for _, row := range rows {
out = append(out, managedagents.AgentBinding{
AgentCUID: row.AgentCUID,
Account: row.Account,
ClaudeAgentID: row.ClaudeAgentID,
ClaudeAgentName: row.ClaudeAgentName,
})
}
return out, nil
}

// managedAuditAdapter bridges the invocation/event repos →
// managedagents.InvocationAuditStore for the synthetic per-session audit row.
type managedAuditAdapter struct {
Expand Down Expand Up @@ -460,11 +497,23 @@ func defaultUserDatabasePath() (string, error) {

// agentsLookupAdapter bridges store.AgentsRepo → invocation.AgentLookup.
type agentsLookupAdapter struct {
repo *store.AgentsRepo
repo *store.AgentsRepo
managedBindings *store.ManagedAgentBindingRepo
}

func (a *agentsLookupAdapter) GetByAgentID(ctx context.Context, agentID string) (invocation.AgentRecord, error) {
rec, err := a.repo.GetByAgentID(ctx, agentID)
if err == nil {
return invocation.AgentRecord{ID: rec.ID, VMCUID: rec.VMCUID, VMOrganizationCUID: rec.VMOrganizationCUID, Charter: rec.Charter}, nil
}
if a.managedBindings == nil {
return invocation.AgentRecord{}, err
}
binding, bindErr := a.managedBindings.GetByClaudeAgentID(ctx, "", agentID)
if bindErr != nil {
return invocation.AgentRecord{}, err
}
rec, err = a.repo.Get(ctx, binding.AgentCUID)
if err != nil {
return invocation.AgentRecord{}, err
}
Expand Down Expand Up @@ -568,6 +617,13 @@ func truthyEnv(name string) bool {
return value == "1" || value == "true" || value == "TRUE" || value == "yes" || value == "YES"
}

func emptyDefault(value, fallback string) string {
if value == "" {
return fallback
}
return value
}

// credentialAdapter bridges store.OAuthRepo into the narrow
// mcp.CredentialStore interface the resolver consumes. Keeps the mcp
// package independent of the concrete OAuthRepo/OAuthCredential types.
Expand Down
37 changes: 24 additions & 13 deletions examples/managed-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,17 @@ Because Atryum answers the harness's own confirmation prompts, it can gate the

### 1. Enable the bridge in `atryum.toml`

Declare one `[[managed_agents]]` table per Anthropic account/workspace. The
`name` is a unique label the session-registration API uses to target a specific
account.
Declare one `[[managed_agents]]` table per Anthropic account/workspace API key.
The `name` is a unique label the session-registration API uses to target a
specific account.

```toml
[[managed_agents]]
name = "default" # unique label; targeted by the registration "account" field
# Anthropic API key. Env overrides (single account only):
workspace = "anthropic-workspace-name-or-id" # display/metadata label
# Anthropic API key created in that workspace. Env overrides (single account only):
# ATRYUM_MANAGED_AGENTS_API_KEY, then ANTHROPIC_API_KEY.
# If using env for the key, set ATRYUM_MANAGED_AGENTS_WORKSPACE too.
api_key = "sk-ant-..."
# Optional tuning (defaults shown):
# base_url = "https://api.anthropic.com"
Expand All @@ -85,13 +87,17 @@ api_key = "sk-ant-..."
# Watch a second account by repeating the table:
# [[managed_agents]]
# name = "staging"
# workspace = "staging-workspace"
# api_key = "sk-ant-..."
```

Entries with an empty `api_key` are skipped; when no account has a usable key
the bridge is disabled and the admin endpoint returns `501`. The
`ATRYUM_MANAGED_AGENTS_API_KEY` / `ANTHROPIC_API_KEY` env overrides apply only
when zero or one `[[managed_agents]]` entry is configured.
when zero or one `[[managed_agents]]` entry is configured. `workspace` is
required whenever `api_key` is set, but it is not sent as an Anthropic request
selector: Anthropic API keys are already workspace-scoped, so use an API key
created in the workspace whose Claude agents you want to list.

### 2. Create an agent whose tools ask for confirmation

Expand All @@ -112,11 +118,16 @@ curl -sS https://api.anthropic.com/v1/agents \
}'
```

Create an environment and a session as usual (see the
[quickstart](https://platform.claude.com/docs/en/managed-agents/quickstart)),
and note the session ID.
Create an environment and sessions as usual (see the
[quickstart](https://platform.claude.com/docs/en/managed-agents/quickstart)).

### 3. Register the session with Atryum
### 3. Link the Claude agent in Atryum

Open the Agents page, edit the Atryum agent you want rules to apply to, and
select the Claude Managed Agent. Atryum writes ownership metadata to the Claude
agent and discovers its sessions automatically.

Manual session registration still exists as an escape hatch:

```bash
curl -sS -X POST http://localhost:8080/api/v1/admin/managed-agents/sessions \
Expand All @@ -129,10 +140,10 @@ curl -sS -X POST http://localhost:8080/api/v1/admin/managed-agents/sessions \
}'
```

Atryum starts watching immediately and resumes watching registered sessions on
restart (the cursor is persisted, so it replays anything missed). Send the
session a user message; blocking tool calls now flow through your Atryum rules
and appear live in the invocations UI.
Atryum starts watching linked sessions as it discovers them and resumes watched
sessions on restart (the cursor is persisted, so it replays anything missed).
Send the session a user message; blocking tool calls now flow through your
Atryum rules and appear live in the invocations UI.

### Approval rules

Expand Down
Loading
Loading