Skip to content

feat(agent-vault): agents that run without credentials - #8000

Open
saifsmailbox98 wants to merge 320 commits into
mainfrom
agent-vault
Open

feat(agent-vault): agents that run without credentials#8000
saifsmailbox98 wants to merge 320 commits into
mainfrom
agent-vault

Conversation

@saifsmailbox98

@saifsmailbox98 saifsmailbox98 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Context

Agent Vault is a new product. Agents run holding no credentials. Their traffic is pointed at a proxy the customer runs, and the proxy attaches the real credential on the way out, only for the hosts that agent is allowed to reach. So an agent can use a secret without ever holding one, and it cannot give itself access to anything more. The proxy and the infisical av commands are in Infisical/cli#386; the two merge together.

Four things make up the product. A connection is one host and one credential — "api.github.com, and this token". An access bundle is a named set of connections, and it is what you grant to a person, a machine identity, or a group. A session is what an agent runs with: a token naming who it belongs to and which bundles it carries. A proxy is the process the customer deploys where their agent traffic leaves.

Dashboard: a new product with four pages — Access Bundles, Sessions, Proxies and Audit Logs — plus its own Access Control. Adding a credential is a short wizard with templates for services we already know, so you pick GitHub and only supply the token. Session tokens and proxy enrollment tokens are each shown once, at creation.

API: endpoints for bundles, connections, grants, sessions and proxies, plus the two the proxy itself calls — one to check in, one to ask what a session may reach right now. That second one is the only place a credential is ever decrypted.

Access: Agent Vault is organization-wide rather than something you create per project, the same shape as PAM. There are two roles, admin and member. An admin runs the product; a member mints sessions over the bundles they have been granted and sees nothing else. A grant is a resource-scoped row in the shared memberships table with a single consumer role, the way PAM stores folder access, so removing someone from the product or the organization, deprovisioning them through SCIM, or deleting a user, identity or group takes their grants with it through the platform's own paths. Deleting a bundle reaps its grant rows in the same transaction and locks the bundle row first, so a grant cannot land mid-delete. The admin who creates a bundle is granted it, but only if they are directly in the product; an admin who is in only through a group reaches it as admin, so no individual grant can outlive its owner's membership.

Revoking works everywhere it should. Revoke a session, remove a grant, remove someone from the product, or deactivate them in the organization, and their agent stops receiving credentials within a minute. There is no separate step to remember.

Docs: an overview, a quickstart, and pages for access bundles, sessions, proxies and the CLI commands.

Two things worth knowing, because they look like oversights and are not. The proxy can reach private networks, including inside your own infrastructure — that is deliberate, since an internal API is one of the main things people want this for, and Uncovered Hosts: Deny is there for a proxy that sits somewhere it could reach more than you intend. And there is no audit event per request: creating, revoking and expiring a session are audited, but a check every minute per session would write a hundred thousand rows a day for a modest deployment, so the proxy logs each credential it attaches instead.

Steps to verify the change

  1. Open Agent Vault from the product switcher.
  2. Access Bundles → Create Access Bundle, then Add Connection. Pick a template and paste any token. The token never comes back on any screen or API response afterwards.
  3. Proxies → Create Proxy, copy the one-time token, and run the command it gives you on the machine that will carry agent traffic.
  4. Sessions → Create Session over that bundle, then start an agent with infisical av run. The request reaches the upstream with the credential attached, and the agent holds nothing.
  5. Point the agent at a host no bundle covers. Under Deny it is refused; under Allow it goes through with no credential.
  6. Revoke the session, or deactivate the person in organization Access Control. The agent stops getting credentials within a minute.

Type

  • Fix
  • Feature
  • Improvement
  • Breaking
  • Docs
  • Chore

Checklist

…field

The checkbox was a second control for one field. The secret box now seeds an
unchanged marker on edit, so a single field carries all three intents: leave it
to keep the stored secret, type over it to replace, clear it to remove a basic
password. Focusing selects the marker rather than deleting it, so tabbing through
the field and moving on cannot wipe a credential, which is the failure mode the
same pattern has in PAM. Switching credential type empties the box, since the
sealed secret belongs to the type being replaced.
… shown

The list already dropped it; the edit form and Review still showed the port the
API appends, so the same host read two ways. The helper moves next to the schema
and both callers use it. Also fixes Review claiming a password would be Removed
when the credential never had one, and stops that no-op from re-sealing the blob
and logging a credential replacement.
… to newest first

Connections was the one Agent Vault table with neither sorting nor a Created
column, while bundles, sessions and proxies all had both. The API now returns
the connection's createdAt, and every column sorts on the same pattern as the
bundles table.

Both tables now open on Created descending rather than Name ascending, since the
row someone just made is the one they came back for.
…ouse empty state

Three things from a design pass.

Every Agent Vault empty state set frame="dashed" alongside className="border",
which the Empty component documents as independent opt-ins, so each drew a CSS
border and an SVG frame over it. The rest of the app uses className="border" in
140 places and never combines the two; these six now do the same.

The Manage Access sheet no longer labels its list Members or counts it, since the
sheet title already says what it manages. The bundle list still shows memberCount,
so no read path went unused.

Creating an access bundle now grants it to whoever created it, in the same
transaction, the way a PAM folder seeds its creator as a member. An admin already
reaches every bundle by role so nothing changes for them today; it matters when
that admin is later demoted, and it stops a bundle you just made from reading
"0 members".
…t accent

The tab strip already switched to av, but every button and badge inside the tabs
was hardcoded to variant="project", which is Secret Manager's colour, so the one
Agent Vault page built on the shared component had a primary action nobody else
in the product has. getProductControlVariant returns project for every other
product, so nothing else moves.
Agent Vault's Access Control page is about to stop sharing the generic one, so it
needs the product-level membership surface PAM already has: list users, groups and
identity members, then add, change role and remove.

The one thing PAM's version does that ours must do differently is the cleanup on
removal. PAM cascades inside the membership table, because its folder and account
grants are RESOURCE_SCOPE rows there. Ours calls agentVaultMembershipCleanupService,
because bundle grants live in their own table and a direct delete here would leave
rows the mint path still honours.

Also adds the last-admin guard we did not have, and batches the role lookup rather
than querying per membership as PAM does.
Modelled on PamAccessControlPage rather than the shared project one: tabs with
counts, a Product Role column, search beside the primary action, and the product
accent throughout.

Members and groups read from the generic project hooks, which carry the names,
emails and pending state the rows need; only identities use our own endpoint,
for the role beside each name. That is the same split PAM makes.

Two things PAM does are deliberately left out. It brands identities it owns and
offers Delete instead of Remove for them, because identities created inside PAM
belong to the PAM project; Agent Vault never creates identities, so removal only
ever takes the membership. And PAM reads a capabilities endpoint for isResourceAdmin,
which means nothing here since bundles carry no roles, so gating stays with
ProjectPermissionCan.
…Agent Vault

Reverts the product-accent helper and drops the nine ProjectType.AgentVault
branches from the shared page and its tabs, now that Agent Vault renders its own.
Cert Manager is the standalone product there again, exactly as before this branch
touched it.
…ginal form

The isCertManager to isStandaloneProduct rename existed only so a second product
could share the page. With Agent Vault on its own, it renames a boolean to itself
across nine shared files and buys nothing. Back to main, so the only file this
branch still touches there is the Agent Vault route.
Users, Groups and Machine Identities, matching the wording the rest of the product
uses for the same three things. The counts go with them, and so do the three list
queries the shell only ran to produce them; each tab already fetches its own.
…te also stales

The Users and Groups tabs read from the generic project hooks, so invalidating only
the Agent Vault keys left a role change or a removal on screen until the page was
reloaded. The mutations now take the project and invalidate getProjectUsers and
getProjectGroupMemberships alongside ours, which is what PAM's do.
Unnests a ternary in removeProductMember and takes the formatter's import order and
line wrapping. make reviewable-api and make reviewable-ui both pass.
Three cases: the full add, promote and remove cycle for a machine identity; that
removal reaps the bundle grants, which live outside the membership table and would
otherwise be missed; and the four guards that keep the product administrable.

Identities are inserted directly as the rest of this spec does, since creating one
through the API is license-gated.
…select

The three add dialogs listed the two roles with no explanation, while the change
dialog explained them inside a select. One ProductRoleField now serves all four,
built on the v3 RadioGroup pattern for rich rows: title, description, and the av
FieldLabel variant so the chosen card carries the product accent.

Two options never justified a select, and the descriptions are the part that
actually decides which one someone wants.
…tion

Neither line answered a question anyone has. Nobody wonders why a list of people
to add leaves out the people already added, and nobody is looking for an identity
Agent Vault could not have created.
Unmatched Hosts became two described choices rather than a bare select, since
Allow and Deny say nothing about what they decide. The column is Uncovered Hosts,
and it and Bypass Hosts carry their explanation on hover.

Bypass hosts no longer arrive prefilled with package registries. Guessing at
someone's traffic put six hosts in a field they never asked for; a placeholder
shows the shape instead.

The poll interval description says what the number decides rather than repeating
the range the input and the API already enforce.

Drops the banner under the table. It restated the Allow description for readers
who had already chosen.
A connection takes precedence, so the field, the column hover and the proxies page
all say that a host an access bundle covers still gets its credential. Also drops
the claim that new proxies start with package registries prefilled, which they no
longer do.
…and api docs

Both still said bypass is evaluated before anything else, which stopped being true
when a connection started taking precedence.
Field, column hover, API description and docs all said it skipped interception.
It no longer does, and the thing it is actually for reads better anyway: hosts every
agent needs that belong to the deployment rather than to one bundle.
The list is an exception to deny, so under allow it has nothing to except and an
input that changes nothing is what made it read as a second access rule. The stored
value is left alone rather than cleared, so switching back to deny brings it back.
Two lines under a number field that most people leave at 60. It rides on the label
now, using the info-icon pattern the app connection forms already use.
DialogContent spaces its own children with gap-6, but this dialog wraps the header,
body and footer in a form, so the form was the only child and the gap spaced
nothing. Header, fields and footer all sat flush; the poll interval input ended two
pixels above the footer border. The form now carries the layout DialogContent
expects. Measured at 24px, matching every other dialog.
…oltip

A bypassed host still receives a credential when an access bundle covers it, so
neither the column hint nor the field description could claim otherwise.
…e browser

The min and max attributes made the browser refuse the submit with its own
bubble, so react-hook-form never ran and the inline FieldError never showed.
Only 3 of the repo's 169 number inputs carry those attributes.
saifsmailbox98 and others added 30 commits September 11, 2026 04:59
The sidebar had no product header: the overview page carried
sidebarTitle "Overview" and no icon, while PAM, KMS and Secrets Scanning
set both to the product. The three concept pages also sat inside a
collapsed Concepts group, so nothing but Quickstart was visible without
expanding it. They are a flat Product reference group now, like the
siblings, and the wrapper group is sentence-cased like theirs.

Access bundles gets its entry point and names the Template step. The
secret-handling paragraph is gone; the edit sheet already shows all of
it. Sessions no longer lists the duration presets, which the dropdown
shows and which would go stale, and points the CLI reader at --ttl with
the format inline. The proxy dialog's submit is Create, so the enrolling
sentence stops naming Create Proxy twice, and the enrollment command
carries --domain the way every dialog snippet does, since the default is
US Cloud.
9008b6f made the session actor columns SET NULL so history outlives
the actor, and resolve went on assuming one id was present. With both
null the actor became { identity, null }, knex compiled the membership
lookups to actorIdentityId IS NULL, and that matched every user row in
the project, the admin's included. A session minted by a since-deleted
user or machine identity kept brokering its credentials and did so as an
admin, immune to grant removal, and showed Active until someone revoked
it. Reproduced live: deleting the identity that minted a never-expiring
session left the proxy attaching the real bearer token, with the
admin's own grant on the bundle removed too.

Resolve now answers 401 with its own reason before any lookup runs.
Give the menu item the danger variant and the same ban icon the revoked
status badge uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session row already carries the bundle id, so the badge can be the
navigation it looks like. OverflowBadgeList grows an optional getLinkProps
that renders the badge as a router link, and an icon that sits ahead of the
label. The icon has to go into the hidden measurement badges too, or the
overflow math sizes every badge as if it were icon-less and cuts the +N
rollup one badge too late.

Bundles that were deleted keep their name snapshot with no id, so they stay
inert badges rather than links to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fits

The default max-w-lg left ~464px inside the code block, short of the ~540px
the --session-token line needs, so the token was clipped behind a horizontal
scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A duration ms() cannot parse and one under the one minute floor both
reported "At least 1 minute.", which reads as nonsense for input like
"dsfsdfdsf". Split the two so an unparsable value says so instead.

Also space the custom input off the preset select by the 8px the field
already uses between its label and its control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ctive ones

Icons follow what the rest of the app already uses: pencil to edit, trash to
delete, ban to revoke, refresh to reissue. Revoking a connection, a proxy or a
member's access is destructive, so those items get the danger variant too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d-by badge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tables

Name and email cells truncate and carry the full value in a title, so one
long value no longer pushes the role and action columns out of view. The
pending invitation badge moves to the action column where it has room, a
member with no first or last name renders an empty marker rather than a
blank cell, and the role badge becomes one shared component that carries an
icon per role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list page's create button and row action menu were the last Agent Vault
controls without icons. Use the same ones the detail page already uses: plus to
create, users to manage access, pencil to edit, trash to delete. Deleting a
bundle is destructive, so that item gets the danger variant too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The truncatable name and email columns are capped at max-width 0, so the
trailing action column absorbed every pixel of slack and left several
hundred points of empty space between the role badge and the row actions.
Give the text columns a two-thirds width share so they take the slack
instead and the trailing columns sit close to the row actions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bundle description ran the full width of its cell, pushing the connections,
members, and created columns out of view. The name column now claims a fixed
share of the row with a floor for narrow viewports, and the name and
description each truncate with the full value in a title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ticket icon reads as a support ticket in a product sidebar. A session
is an issued, time-boxed credential bound to one actor, so an ID card
carries the meaning and holds up at 16px next to the other nav icons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The overlapping tiles outlined each logo with a border only a shade off the
tile and the row, so the stack blurred into one shape, and the OpenAI mark
is black on transparent, so it vanished into its tile entirely. Custom hosts
were tinted with a hashed hue that is not in the theme and read as people
avatars.

Each connection is now a shaded chip with a ring in the table color so the
overlap reads as layers, custom hosts show the same globe the template
picker uses, and the OpenAI template points at a white copy of its mark,
the way Datadog already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The ring that separates the overlapping chips is painted in the resting
table color, so once a row lit up on hover it turned into a dark outline
around every chip. It now follows the row to the hover color, on the same
75ms fade the row uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Creating a session is two fields, and the design system puts a form that
size in a centered dialog, keeping the sheet for large or multi-step forms.
Every other create form in the product is already a dialog, so this one was
a full-height panel holding a combobox and a select. The minted token also
lands in SessionCreatedDialog, so the flow crossed the screen from a
right-side panel to a centered modal halfway through.

The discard guard goes with it. Picking one item from a dropdown is thin
grounds for a confirm prompt, no other dialog here guards, and both fields
reset on open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Revoking a member from a bundle asked for the member's name to be typed
back, the same gate used for deleting a bundle. The grant can be given back
from the same sheet, so a typed confirmation was heavier than the action
warranted. A plain confirm dialog is enough.

The dialog stays open while the request runs, so a failure keeps the member
in view instead of dismissing the prompt and leaving only the error toast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Empty primitive grows to fill its flex parent, and the sheet body is a
flex column, so the no-members box stretched to the bottom of the sheet. Pin
it to its content height so it sits under the Grant Access button like the
other list empty states.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every session in a list often belongs to the same actor, so typing the
actor's name confirmed nothing about which session is being revoked. Typing
the word revoke keeps the deliberate pause without implying the name picks
out the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… pathname

d18fe11 moved every useProject gate for PAM and Agent Vault from the
pathname to the route matches, since the router changes location a frame
before it swaps matches and a pathname read mounts the project frame
early. The command menu arrived from main after that sweep with its own
PAM gate written the old way, so entering PAM from a page with no project
rendered CurrentProjectCommandMenu before the project existed and threw
Missing project id from the root boundary. A non-member saw that crash in
place of the access-denied page.

The gate reads useImplicitProduct like OrgSidebar and ProjectSelect do.
It also had no Agent Vault arm, so Agent Vault gets the project section
it was missing.
…LI one

The page named the Docker and systemd snippets and left the reader to
open the dialog for them, while the gateway deployment page shows its
deployment variants inline. All three are tabs now, with placeholders
where the dialog interpolates the token and the instance, plus the
volume's purpose and where the unit file goes.
The segment carried no meaning once the bundle grants took their own
agent-vault-access-bundle-member-* keys, and it only made these longer.
They are agent-vault-member-* now. PAM keeps pam-product-member-*: it
shipped in August, eventType is a plain string on audit_logs, and
renaming would orphan every stored row and any log-stream filter built
on it.
Every product that has roles worth explaining carries one: PAM, PKI and
secrets management each have an Access control concept page, while the
shared mechanics live under Identity and access. Agent Vault had
neither, so who is an admin, what a member reaches, and how a grant
differs from membership were compressed into one sentence on the access
bundles page. That sentence is now a link.

The page follows PAM's shape: the two levels, the role table, adding
members, bundle grants, and what a change does to a running agent.
…e screenshots

The pages shortened "access bundle" to "bundle" from the second mention
on, and used "mint" for what the dashboard calls Create Session, so two
names existed for each of the two things a reader meets first. Both now
match the product: access bundle throughout, and create rather than
mint, including the CLI reference and the Minting a session heading its
anchor was built on. Sessions are created with an access bundle rather
than over one.

Two older slips went with them: the overview called it an access
session, and the access control page repeated a sentence that sessions
already carries.

The screenshots land here too, fourteen of them across the quickstart
and the three product reference pages, with only the images the pages
reference.
Two files conflicted, and a third broke without conflicting.

docs.json: main restored the Agent Vault nav entry as a working external
link to docs.agent-vault.dev (#8077), repairing a group that expanded to
nothing. This branch deletes that link because Agent Vault now has its
own pages, so the removal wins and the stub page main added goes with it.

SignupCompleteStep: main replaced handleOpen and navigate() with a
getDestination() that feeds <Link> (#8007). That structure stands and the
Agent Vault arm is added beside the PAM one; the imperative version could
not survive anyway, since the auto-merged region had already dropped
useNavigate.

CertificateImportSheet came from main clean and did not compile. This
branch moved useWizardSteps into hooks/ so the connection sheet could
share it, and main's new file still imported it from the certificate
wizard. Repointed, the way the issuance and renewal modals already were.
The last-admin guard used to be a private copy that only needed an
advisory lock, so the mock handed it `{ raw }` and nothing else. Moving
the Agent Vault paths onto the shared assertWillRetainProjectAdmin
changed what the transaction has to be: it still locks through tx.raw,
but then counts live admins with a query built off tx() itself, which
an object cannot answer. Both guard tests died on "tx is not a function"
rather than on anything they were written to check.

The mock is now a callable chain whose count comes from the fixture's
adminMembershipIds, so each test's own setup still decides whether the
guard lets the write through: drop the other admin and both tests fail
on "must keep at least one admin", which is the guard doing its job.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-preview Sets up a live preview for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants