Blog

Your Agent Should Never Hold a Credential

Illustration of a smiling AI agent figure surrounded by labels: credentials not secured in agent, transient data access, no local data held.

In part one we described how the Composer writes every command in a compiled workflow agent's Motion, at design time, under the author's identity, in a loop with the Prompt One Service CLI. The result is a Motion whose data access is a set of commands chosen at design time. In this post we follow one of those commands into production.

Our example is the renewal motion from part one. Every morning its agent finds the opportunities closing in the next ninety days, checks the service desk for open priority-one incidents on those accounts, and flags the renewals at risk. The Motion holds two commands, one per system. Here is what happens to the first.

The request the agent sends

A deployed agent runs in its own container with no route to Salesforce, ServiceNow, or any other connected system. Its generated code executes in a restricted sandbox with no network access except to the gateway.1 When the morning run reaches the first command, the agent's gateway client sends one HTTP request: the command string the Composer wrote and validated, its flags, and the alias of the tenant instance it targets.

{
  "command": "salesforce data query",
  "flags": {"query": "SELECT Id, Name, CloseDate FROM Opportunity WHERE CloseDate = NEXT_N_DAYS:90 AND IsClosed = false"},
  "instance": "primary"
}

Three headers go with it: the agent's registration key, issued by the gateway when the agent was registered, the platform SSO id of the acting user, and the deployed agent's own id in the platform. The agent's container sets the acting user's identity before the agent's code runs, and that code cannot change it.

The instance alias matters because a provider, the Service CLI's integration with one platform such as Salesforce or ServiceNow, can have several tenants, and each tenant is an instance. A company that operates several Salesforce orgs has several instances, and every agent's command names which one it uses. The gateway holds separate credentials, connections, and custom-object catalogs for each.

Authentication and identity

The gateway first authenticates the calling agent from its registration key. It then resolves the forwarded SSO id to a platform user and attaches that user, with the claims from their token, to the invocation, where every later check, the credential lookup, and the audit record can read it. From this point the request is attributed to a person even when the target system will only ever see a service account. An agent in individual credential mode, described below, is refused outright if no user identity can be resolved.

What the command may do, checked before its code loads

Before any provider code loads, the gateway assembles the full command, including its flags and execution metadata, and performs the following checks:

  1. Rate limit. Each registered agent has a request budget per window. A call over budget returns a retry-after interval.
  2. Scope. The command's provider and operation form a scope such as salesforce:read. The agent's registration must grant that scope, the provider's whole scope, or the operation across every provider.
  3. Provider capability. The gateway learned at introspection time what the org's service account may do on each object. If the account itself cannot perform the operation, the command is refused before it is tried.
  4. Blast radius. The command's reach is compared with the agent's ceiling. An agent limited to single records cannot run a command that touches a filtered set, whatever its scope says.
  5. Denied commands. An administrator can name commands an agent may never run, regardless of scope.
  6. Write constraints. A create or update whose values fall outside the agent's declared constraints, for example a record tagged to the wrong region, is refused.
  7. Query constraints. A read is rewritten before it runs. The gateway wraps the query's existing filter and appends the agent's constraint, so WHERE CloseDate = NEXT_N_DAYS:90 runs as WHERE (CloseDate = NEXT_N_DAYS:90) AND (OwnerId IN ...). The Composer wrote the query once. The gateway narrows it on every run.

Only after all checks pass does the command run. Failures return an error that includes the check that failed and what was required. If step seven rewrote the command, the rewritten form is what executes and what the audit record shows.

Permission sets: the builder's narrowing

The checks above come from the agent's registration with the gateway. The agent builder adds a second layer when defining the agent, by attaching permission sets. A permission set names the scopes the agent may use, the entities it may access, the fields it may read or write within each entity, a blast radius ceiling, and query constraints of its own. Permission sets can only restrict what the registration allows. How the gateway applies them depends on the agent's credential mode.

An agent in individual mode acts in the target system as the acting user, with that user's own credential. With no permission set attached, the agent inherits exactly the permissions the user holds there, and the target system's own access control decides what the call may see or change. We do not build a second permission model that could drift from the first.

Attaching a permission set switches enforcement on, because a user rarely wants an agent to hold every permission the user has. For example, Alice has read and write permission on every Salesforce object, but she wants her agent to update Contacts and nothing else. She attaches a permission set, through the built-in AI permissions assistant, that grants the salesforce:read and salesforce:update scopes and lists one entity, Contact, with its read and update flags set. Within Contact she allows writes to the phone number field only. From then on the gateway refuses a create or delete on any object, because neither scope is granted, refuses any operation on an object other than Contact, because an entity the set does not list is denied, and refuses an update that sets any Contact field other than the phone number, because the set forbids writing it. All three refusals happen during validation, before a credential is resolved. The command that passes still runs under Alice's credential, so Salesforce applies her sharing rules on top. Her own access is untouched.

An agent in shared mode acts through the org's service account. A service account is typically shared by a group of users and is granted the union of what all of them need, while a shared agent typically performs one task and needs only a small part of that. The default is therefore reversed: a shared agent is denied all SaaS access until at least one permission set is attached.

The renewal motion runs as a shared agent. Every user who opens it acts through the same Salesforce and ServiceNow service accounts, and those accounts can read and write far more than the motion needs. The builder attaches a permission set for each system that allows read access to only the objects the motion uses, Opportunity and Account in Salesforce and Incident in ServiceNow. With those sets attached, the agent can run the two commands the Composer wrote. Once the agent is deployed, those two commands are all it has, and no run can add a Salesforce update or a read of Case. The permission sets would hold even if it could. They are enforced by the gateway, on the gateway's side of the connection, and the agent has no way around them. An agent that chose its commands on the fly would be refused the same update and the same read, whatever the service account could do.

Canonical names

A command written with --canonical names entities and fields in one vocabulary, and the gateway resolves each name to the native object and field of the provider and instance the request targets, on every call. That is how the renewal motion's two commands return records under the same canonical name for the account, and why the join the Composer wrote works without any per-run reasoning about which field means what.

At design time the canonical vocabulary is what the Composer writes against. It composes the Salesforce command and the ServiceNow command with the same name for the account and the same name for the customer on an incident, so the join, the scoring step, and the fields shown in the live view are all generated once, in canonical terms, without the Composer learning either org's native object names or a customer's custom fields. How the mappings are built, reviewed, and resolved is the subject of a forthcoming post.

Execution, and the envelope that comes back

Just before the command executes, the gateway fetches its credential from the enterprise's vault: the org's service account for a shared agent, or the acting user's own connection for an individual one, always for the exact provider and instance the request names. The secret goes to the provider client and never reaches the agent, the response, or a log. The command then runs inside the gateway process through the Service CLI's embedded runtime. The runtime keeps a connection pool, so the login made on the first call to an instance is reused by every later call, and two instances never share a connection.

The response is one JSON envelope for every command on every provider, with a success flag, the result, and an exit code the agent's code branches on. A failure returns the structured error the Composer saw at design time.

Every run also produces an audit event naming the command as it executed, the acting user, the calling agent, and the exit code. An operator can read who ran what, on which instance, with which result, even when the target system saw only the service account.

Security by construction

Each property below follows from how an agent is built. None of them is a setting an operator could turn off, and none depends on a model behaving well: an inference failure or a prompt injection can produce a wrong or corrupted response, and every one of these properties still holds.

  • The agent holds no credential. Credentials are resolved inside the gateway per call and handed to the provider client only, so there is no secret in the agent to leak.
  • The agent has one route to a provider. Its sandbox has no network except the gateway, so every call goes through the checks above.
  • The agent cannot widen its scope. Scope is decided from the metadata the command declares and the registration the gateway holds, and permission sets only narrow it.
  • The agent cannot act as someone else. Its container sets the acting identity before the agent's code runs, and the gateway resolves that identity to a platform user on every call.
  • The agent cannot be steered. Its control flow is compiled code. A prompt injection in an email, a ticket comment, or a record field can at worst corrupt the output of one bounded model call, and a parameter that call produces still passes every check above. Which command runs next was decided at design time.1

We call this security by construction. The guarantees come from the arrangement of the parts, so a security review of the arrangement covers every agent built on it, and a new Motion adds no new review.

Where the two halves meet

In part one we argued that a workflow agent should never choose its own tools, because every command chosen at design time can be validated for scope and authorization before it exists. This post has shown the other half. The gateway supplies at run time what the command cannot hold: the credential, the acting identity, the policy, and the audit record. The command is chosen at design time and the authority is injected at run time, and neither the Composer nor the agent ever has both.

The whitepaper Inside a Compiled Workflow Agent covers the full lifecycle, and if your team has a motion it wants compiled, start a free trial and watch a command cross the gateway on your own systems.


1. The sandbox limits and the bounded-injection property are described in our whitepaper Inside a Compiled Workflow Agent, in the section on compiled security. The resume-screening reference agent that demonstrates the injection bound is public at github.com/promptone/cost-example.

← All posts