Repository navigation
Conversation
Firm and agent rules, first match per level, strictest level wins, judge rules with fail-to-human, and a minimal secret-pattern fact helper. Tests port the AA MVP prototype's rules and replay shapes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recording a decision on a pending_approval row sends NOTIFY atryum_invocation_decided in the same transaction; each process LISTENs on one connection and wakes its held call at once. Polling stays as the fallback (every 2s with Postgres; SQLite keeps 250ms polling). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent reports that it cannot finish within its authority (task, boundary, detail). The report is recorded as an invocation of the tool, the event hook sees invocation.stuck_reported, and nothing is authorized or proxied. The tool is named with an underscore, like Atryum's other synthetic tools, because Anthropic rejects dotted tool names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pkg/atryum.WithResolvePending hands the embedder ResolvePending(ctx, tx, id, approve, actor, reason), backed by Service.ResolvePending, the path Approve and Deny now share. store.InTx joins a transaction already in ctx (store.WithTx), so the decision, its event, the event hook and the NOTIFY commit with the embedder's writes. A call that is no longer pending returns an error wrapping ErrNotPending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every matching rule in a level being a judge with no verdict now makes that level human instead of no match, so a deferring firm judge can no longer hand the call to a laxer agent rule. - Floats format without exponent (1e6 was "1e+06" and missed eq/in/prefix rules on amounts); all Go integer, unsigned and float types and numeric strings compare as numbers; in accepts any slice or array. - Compiled glob patterns are cached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catches prefixed key names (DB_PASSWORD=, OPENAI_API_KEY=, aws_secret_access_key), URL credentials, sk- keys, github_pat_ tokens, bare JWTs and PGP private keys. Ignores booleans, nulls, masks, placeholders, values under 8 characters and names that only refer to a secret; AKIA/ASIA is case-sensitive. Input that cannot be inspected counts as a secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Held calls poll every 2s only while the listener is connected, 250ms otherwise (DecisionWaker now reports Connected). - The listening connection is pinged after 10 quiet seconds, so a dead connection is caught in seconds; reconnect warnings log at most once a minute; the re-check after a reconnect is spread over a second. - RecordExecution cancelled moves the row compare-and-set from the status it read, which also announces a pending call leaving pending_approval. - Document that PgBouncer in transaction mode cannot deliver notifications. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stuck report now has its own status, reported, and the event hook sees one invocation.stuck_reported change written with the row (no invocation.created), so embedders and dashboards don't count it as an action. The MCP path's server must be configured, task and boundary are capped at 1 KB and detail at 4 KB, other arguments are dropped, and the agent gets a generic error instead of database text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 open and 10 idle connections, idle ones closed after 5 minutes, each replaced after 30 (it was unbounded, which exhausted connections under load). Overridable with [server.database_pool] and pkg/atryum.WithDBPool; set fields win, the option over the config file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names are split at camelCase, _, -, . and /, and hold a secret only when a secret word is followed by value words alone, so secretName, secretKeyRef, secretRef, existingSecret, tlsSecretName, Reloader annotations and secretary no longer trip facts.contains_secret on ordinary Kubernetes manifests (now a fixture). Also catches aws_secret_access_key VALUE, --password VALUE, mysql -pSECRET, curl -u user:pass and a bare token key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Pending's transaction rules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
database_listen = false skips the Postgres LISTEN connection, for PgBouncer in transaction mode; held calls then poll every 250 ms. The compiled-glob cache starts over past 4096 patterns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bridge sent allow to Anthropic first and then recorded running, debug-logging a refusal, so a start the embedder refused (an expired or used-up lease) still ran. It now records running first and sends allow only if that succeeds; otherwise it records the call cancelled, sends a deny with the reason and logs a warning. A replay re-sends the allow for a call already running, or the deny for one cancelled at start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key used to return whatever invocation held it: another agent sending the same key received that invocation's status, approval and result, and a retry with a different tool or input silently got the old call. Keys are now unique per agent (migration 030 replaces the global unique index with one on COALESCE(agent_id, ''), idempotency_key) and looked up under the identity the invocation is recorded with. A same-agent retry of the same request (path, server, tool, input as JSON) returns the original; a different request returns ErrIdempotencyConflict (HTTP 409) and reveals nothing; another agent's key never finds that invocation. A lost insert race is answered the same way. The Managed Agents bridge looks its keys up under the session's agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock - sameJSON decodes with UseNumber, so inputs differing only in an integer beyond 2^53 no longer count as the same request. - 16 concurrent identical submits by one agent, across two service instances, make one invocation on SQLite and Postgres (fails without lostCreateRace). - Migration 030 builds its Postgres index without CONCURRENTLY; the migration comment and CHANGELOG say so and how to pre-build it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#168 took migrations 030 and 031, so the idempotency migration is now 032 (renamed during the rebase). The runner now also checks each recorded migration's name: a database migrated at AA's old pin records 30 as 030_idempotency_per_agent and would otherwise skip #168's 030 silently. It now fails at startup with an error that says to recreate the database. Every released version kept its name, so released databases pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ToolCall.Auth and Invocation.Auth carry the issuer, the method (jwt, api_key, or no_auth for a self-declared agent ID) and the API key's id, so a decider can tell an IdP token from an Atryum agent key now that #168 admits both. Migration 033 stores them on invocations (auth_issuer, auth_method, auth_key_id); the store now keeps one column list for every invocation query. pkg/atryum exports the Auth alias. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cachafla
force-pushed
the
aa/extension-points
branch
from
October 15, 2026 15:51
e479127 to
4e3d884
Compare
cachafla
force-pushed
the
claude/aa-policy-cf868a
branch
from
October 15, 2026 15:51
76b7285 to
e5a985c
Compare
- Calls from a Managed Agents session the bridge registered are submitted with an identity built from that registration: Method managed_agent, Issuer anthropic:<workspace>, KeyID the session ID. Before, they recorded no_auth, which a decider could not tell from a self-declared agent ID. Request input cannot set it. - Stuck reports record the reporting caller's Auth (it was empty). - The atr_ refusal with agent keys off compares the Bearer scheme case-insensitively, so 'bearer atr_...' no longer passes as anonymous. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #170 (
aa/extension-points). Open-source pieces Agent Authority needs next.pkg/policy: the rule engineAPI exactly as agreed with AA governance:
Action,Cond,Rule,Call,Judge,Result,Evaluate(ctx, firm, agent, call, judge). Added constantsActionAllow,ActionNotify,ActionDeny,ActionHuman,ActionJudge, plusContainsSecret(v any) boolforfacts.contains_secret.|-separated globs;*matches anything; an empty Server matches any server.human. An unknown verdict, meaning""or anything other than allow/deny/human, keeps scanning that level.human.human. A missing field never satisfies a condition.REPLAY_SHAPES, and they agree with the recorded decisions. They also check the k8s v5 draft flips.Wake held calls on any replica (Postgres)
InvocationRepo.UpdateResultFromsendspg_notify('atryum_invocation_decided', id)in the same transaction when it moves a row out ofpending_approval. Each process keeps one pgx connection onLISTEN(store.ListenForDecisions) and reconnects every 5s after an error, waking all its waiters on reconnect. A held call registers before its row goes pending. Polling stays as the fallback: every 2s with Postgres, and every 250ms on SQLite as before. Theponytail:gap comment is gone. The remaining gap, resuming a held call after its process dies, keeps its own comment.atryum_report_stuck(behindWithStuckReports())atryum_report_stuck, notatryum.report_stuck. Anthropic rejects dotted tool names, and Atryum's other synthetic tools already use underscores for this reason. The proxy offers it intools/list, with or without an external decider. A call records an invocation of the tool, and the event hook seesinvocation.createdand theninvocation.stuck_reported. It never reaches the decider or an upstream.taskandboundaryare required.Resolving a held call from an embedder
atryum.WithResolvePending(func(atryum.ResolvePending))hands the embedderResolvePending(ctx, tx *sql.Tx, invocationID string, approve bool, actor, reason string) erroronce the service is built. It is the same compare-and-set pathApproveandDenyuse, and they now delegate toService.ResolvePending. With a non-niltx, the decision, its event row, the event hook and the NOTIFY all join the embedder's transaction and commit with it; a niltxlets Atryum commit on its own. It also keeps theai_escalated_*approval statuses. A call that is no longer pending returns an error wrappingatryum.ErrNotPending. To support this,store.InTxjoins a transaction already in ctx (store.WithTx), andInvocationRepo.Getreads through that transaction.Review round 1 (commits 3d2ad4d to c52d3ab)
pkg/policy. If every matching rule in a level is a judge that gave no verdict, that level's result is nowhuman(Rule = the last deferring judge). Before, the level counted as no match, so a deferring firm judge handed the call to the agent level. This matches Atryum's own engine and the prototype.1e6no longer becomes"1e+06". All Go integer, unsigned and float types and numeric strings compare as numbers,intakes any slice or array, and the coercion rules are documented onCond. Compiled globs are cached, and the docs note thatResult.Rulepoints into the caller's slice.ContainsSecretchanges:DB_PASSWORD=,OPENAI_API_KEY=,aws_secret_access_key), URL credentials,sk-,github_pat_, bare JWTs and PGP keys.AKIA/ASIAis case-sensitive, and input that can't be inspected counts as a secret.DecisionWakerreportsConnected()).RecordExecution cancellednow uses compare-and-set, so it also notifies.reported, and the hook sees onlyinvocation.stuck_reported(noinvocation.created), written in one transaction with the row.[server.database_pool]andatryum.WithDBPool(atryum.DBPool{...}).Review round 2 (d648985, 7486f88, 4b2adc5)
ContainsSecretnow matches key names by whole words, split at camelCase,_,-,.and/. A secret word only counts when the words after it also describe a value (key, access, value…). SosecretName,secretKeyRef,secretRef,existingSecret,tlsSecretName, Reloader annotations,nextPageTokenandsecretaryno longer trigger it. A realistic Deployment fixture must not trigger, and a Secret with astringDatapassword must.aws_secret_access_key VALUE,--password VALUE,mysql -pX,curl -u user:passand a baretokenkey with a long value.ResolvePendingdocs now cover rolling back on any error, staying on Atryum's database and schema, and REPEATABLE READ/SERIALIZABLE. A resolve that loses the race (SQLSTATE 40001 on the compare-and-set) returnsErrNotPending.server.database_listen = falseskips the LISTEN connection, for PgBouncer in transaction mode. Held calls then poll every 250ms.Managed Agents start order (9fa8401, gofmt in 6283bb2)
The bridge now records
runningbefore sending allow to Anthropic.cancelled, and a warning is logged.executing, or the deny for onecancelledat start.invocation.executingmust lead to a deny and never an allow,runningmust be recorded before any allow, and a replay after a failed send must re-send the allow and recordrunningonly once.Idempotency keys scoped per agent (8ea0af7, security)
idempotency_keywith one on(COALESCE(agent_id, ''), idempotency_key).InvokeandSubmitlook the key up under the identity the invocation is recorded with: the verified agent, else theagent_idaSubmitcaller declares, else one anonymous scope.invocation.ErrIdempotencyConflict(HTTP 409) and reveals nothing about the earlier call. Another agent's key never finds that invocation; that agent gets its own.InvokeandSubmit, the declaredagent_idwithout auth, 409 on both HTTP routes, and the index on SQLite and Postgres.UseNumber, so integers beyond 2^53 stay distinct.CONCURRENTLY; the migration comment and CHANGELOG say so and explain how to pre-build it.Rebased onto #168 via the new #170 — 15 October
030_idempotency_per_agentis now032_idempotency_per_agent(Nibz/claude/api keys #168 owns 030 and 031).030_idempotency_per_agent. It now fails at startup with an error that says to recreate it, instead of silently skipping Nibz/claude/api keys #168's 030. Every released version since v0.0.2 kept its names, so released databases pass. Tested on SQLite and Postgres.ToolCall.AuthandInvocation.Auth(atryum.Auth) carry the issuer, the method (jwt,api_key, orno_authfor a self-declared agent ID) and the key ID. Migration033_invocation_authaddsauth_issuer,auth_methodandauth_key_idtoinvocations. Tests cover a JWT, an API key, the no-auth hint, a declaredagent_idand an anonymous caller, checking the decider, the hook and the stored row.aa/pin-76b7285.Review of the rebase (0dbfb68)
Auth{Method: "managed_agent", Issuer: "anthropic:<workspace>", KeyID: <session ID>}. The identity is built from the bridge's own session registration and set on its own context, so no request field can set it. Before, these calls recordedno_auth.Auth.bearerscheme: with agent keys off, theatr_refusal now compares theBearerscheme case-insensitively. Before,bearer atr_…passed as anonymous when no IdP was configured.Testing
go test ./...passes.ErrNotPending.ATRYUM_POSTGRES_TESTS=1, the new two-replica test passes. Replica B now resolves inside its own transaction, and the test checks that the hook got that transaction against local Postgres 16. Its holder polls once an hour, so only the notification can wake it in time. Withpg_notifydisabled, the test fails.🤖 Generated with Claude Code