How we keep AI agent actions under server side control in rails
Here is an endpoint that looks fine in a first agent prototype. def create tool = params[:tool].constantize result = tool.call(**params[:arguments]) render json: result end The model returns a tool name a
Here is an endpoint that looks fine in a first agent prototype.
def create
tool = params[:tool].constantize
result = tool.call(**params[:arguments])
render json: result
end
The model returns a tool name and some arguments. Rails finds the class and runs it. It's short, and it works in a demo.
It also gives the model the final say. If the agent reads a poisoned document, picks the wrong target or carries context over from another tenant, this endpoint runs the bad decision as it is.
In a security product the side effects are big. An agent can end a session, disable an account, isolate a machine, rotate a credential or change a cloud policy. I don't want the model to authorize any of that.
So in our product the agent only proposes. It sends a typed action request. Rails takes the tenant and the user from the authenticated session, looks the action up in its own list, validates the arguments, applies policy and saves the request. If a person has to approve it, the approval is tied to the payload they reviewed. Right before execution, Rails checks everything again.
I'm Oleksandr Sainchuk, a Lead Full Stack Developer at UITOP. For three years we've worked with a client on an enterprise cybersecurity platform. It uses role-scoped AI agents for investigation, sensitive-data protection, remediation and audit across AWS and Azure. We design the product, build the interface, write the Rails backend and the agent logic, and maintain it.
I covered the life of one account-disable request in an earlier article. This one is about the Rails layer underneath it.
The case the whole design is built around
An agent investigating a suspicious login finds a leaked session and proposes disabling the identity for 60 minutes. An analyst opens the request, reads the evidence and approves it.
A minute later someone edits the request. The duration becomes 240 minutes, or the target becomes another account. The first approval must not cover the edited version.
Most of the code below exists for that moment.
Start with a registry the server owns
The model picks from the tool descriptions you give it. That list helps generation. It doesn't decide what can run, so Rails needs its own allowlist.
module AgentActions
class Registry
ACTIONS = {
"collect_identity_evidence" => CollectIdentityEvidence,
"revoke_session" => RevokeSession,
"disable_identity" => DisableIdentity,
"isolate_endpoint" => IsolateEndpoint
}.freeze
def self.fetch!(name)
ACTIONS.fetch(name)
rescue KeyError
raise UnknownAction, name
end
end
end
I never call constantize, public_send, a shell command or a URL that the model supplied. The registry maps a string from the model to one command class that we wrote. An unknown name raises and nothing runs.
It also gives reviewers a single file that lists every action the agent can request. Nobody has to dig through prompts or provider settings to find out.
Give each action an input contract
Strong Parameters filter which attributes reach mass assignment. The Rails guide describes them as an explicit allowlist. They don't know whether an identity belongs to the current tenant or whether a duration is safe. Agent actions need a contract that checks meaning.
module AgentActions
class DisableIdentity
include ActiveModel::Model
include ActiveModel::Attributes
attribute :identity_id, :string
attribute :reason, :string
attribute :duration_minutes, :integer
validates :identity_id, :reason, presence: true
validates :duration_minutes,
inclusion: { in: [15, 30, 60, 120, 240] }
def resolve_target!(tenant:)
tenant.identities.find(identity_id)
end
def risk_level
:high
end
end
end
The agent can send identity_id, reason and a duration from a fixed list. If it sends tenant_id or approved_by, ActiveModel::UnknownAttributeError is raised, because the contract has no such attribute. I treat that as an invalid proposal. Risk level, policy version and status come from server code.
The target lookup is scoped to the tenant. Identity.find(identity_id) would accept a valid ID from any tenant. tenant.identities.find(identity_id) keeps the scope inside the query.
Save the proposal before doing any work
An approval flow has to survive a page refresh, a deploy and a background job that starts an hour later. So I save the proposal as application data before any connector runs.
class ProposeAgentAction
def self.call(agent:, raw_call:)
actor = Current.user
tenant = Current.tenant
command_class = AgentActions::Registry.fetch!(raw_call.fetch("name"))
command = command_class.new(raw_call.fetch("arguments"))
raise InvalidArguments, command.errors.full_messages unless command.valid?
target = command.resolve_target!(tenant: tenant)
decision = AgentActionPolicy.call(
tenant: tenant,
actor: actor,
agent: agent,
command: command,
target: target
)
AgentAction.create!(
tenant: tenant,
requested_by: actor,
proposed_by_agent_id: agent.id,
action_type: raw_call.fetch("name"),
target: target,
arguments: command.attributes,
risk_level: decision.risk_level,
policy_version: decision.policy_version,
status: decision.status,
expires_at: decision.expires_at
)
end
end
The policy returns one of three statuses: denied, awaiting_approval or approved_for_execution. A read-only evidence lookup can run on its own. Disabling a production identity needs a security administrator. When policy context is missing, the answer is a denial or an approval request, never a guess in favor of running.
The prompt can tell the model about these rules, and Rails is what applies them.
Tie the approval to what the person reviewed
An Approve button means little on its own. Say an administrator reviews this request:
{
"action": "disable_identity",
"target_id": "idn_1842",
"duration_minutes": 60
}
If the target changes to idn_9011 after the review, the old approval must not cover it. The same goes for a changed action type, duration, tenant, policy version or any other argument that matters for security.
We build an approval payload from normalized server data and run it through a versioned canonical serializer.
def approval_payload
{
schema_version: 1,
tenant_id: tenant_id,
action_type: action_type,
target_type: target_type,
target_id: target_id,
arguments: arguments,
policy_version: policy_version,
expires_at: expires_at&.iso8601
}
end
def current_digest
AgentActions::Digest.call(approval_payload)
end
The approval record stores the digest, the approver, the decision time and the expiry. At execution time Rails recomputes the digest and compares it with the approved one. An edit changes the digest, so the old approval stops matching.
OWASP's AI Agent Security Cheat Sheet recommends the same for high-impact actions. It says to bind the approval to the actor, tool, target resource and normalized parameters, and to record a timestamp and expiry. It also asks for replay protection and idempotent actions where possible.
Frameworks can pause a run and resume it after a decision. In RubyLLM 2.0, a tool marked requires_approval stays pending, and the pending call and the decision live in your database. Your application still decides who may approve, what they approved and whether that approval is valid now.
Check authorization again at execution time
Policy can change while an action waits. The target can move to another state. The user who started the request can lose access. The approval can expire. So the job loads current records and evaluates current policy.
class ExecuteAgentActionJob < ApplicationJob
self.enqueue_after_transaction_commit = :always
def perform(action_id)
action = AgentAction.find(action_id)
idempotency_key = action.with_lock do
next unless action.approved?
next if action.expires_at&.past?
next unless action.approved_digest == action.current_digest
decision = AgentActionPolicy.call_for_execution(action)
next unless decision.allowed?
action.update!(
status: "executing",
execution_key: action.execution_key || SecureRandom.uuid
)
action.execution_key
end
return unless idempotency_key
result = AgentActions::Executor.call(
action: action.reload,
idempotency_key: idempotency_key
)
action.update!(
status: "verifying",
external_reference: result.reference
)
rescue Net::ReadTimeout
# Use whichever timeout error your connector raises.
action&.update!(status: "execution_unknown")
ReconcileAgentActionJob.perform_later(action.id) if action
end
end
The row lock stops two workers from claiming the same approved action. Rails supports both optimistic and pessimistic locking, and the Active Record guide covers them.
The connector call runs after the short lock is released. A database transaction can't make a cloud API call atomic with a local update, and Rails' transaction callbacks exist for work that happens outside the database. So the connector receives a stable idempotency key.
enqueue_after_transaction_commit = :always was added in Rails 7.2. It holds the job back until the surrounding transaction commits, so a worker never looks for a record that isn't saved yet. The Active Job guide explains the setting.
If the connector times out, I don't mark the action as failed and send a new request. The remote system may have finished the work. execution_unknown is a real state in our model. A reconciliation job asks the remote system about the operation reference, or inspects the target, before anyone tries again.
Pass record IDs to jobs
Jobs get the action ID and nothing else.
ExecuteAgentActionJob.perform_later(action.id)
The worker reloads the proposal, target, approval and policy context from our database. It never receives the model's original arguments as trusted input.
This matters when a request waits hours for a decision. The chat transcript explains why the agent proposed an action. The database record decides whether the action runs.
Keep audit events apart from the chat log
A chat log gives context, but it's a poor security record. For each action I store structured events:
who and which agent proposed it, and which tenant, incident, target and evidence were involved
which action contract and policy version were evaluated, and which arguments Rails accepted
who approved, rejected or edited it, which digest they approved and when the approval expired
which worker claimed it, with the idempotency key and the external reference
whether verification confirmed the result, and whether a rollback followed
Events hold evidence references and short summaries. They never hold credentials, full sensitive files or hidden model reasoning.
OWASP's Agent Control Standard, now part of the OWASP GenAI Security Project, says agents must be inspectable, traceable and instrumentable. A durable event history gives an application a concrete way to meet that.
Prompt injection is an authorization problem too
Injected text can still convince a model to request disable_identity. That request then meets the same registry, contract, tenant scope, policy, approval, expiry, lock and audit checks as any other proposal. It can't skip a step because the model was persuaded.
Filtering untrusted content and isolating tools still matter. The Rails boundary limits what a successful injection can do. OWASP's prompt injection guidance recommends least privilege and human approval for high-risk actions for that reason.
What I check before an agent changes customer state
- Every action the agent can run sits in a server-owned registry with its own input contract.
- Tenant, user, roles and policy version come from server state, and targets are found through tenant-scoped queries.
- Policy runs when the proposal is saved and again before execution.
- An approval covers one normalized action, expires, and stops matching when someone edits the action.
- Workers get record IDs, take a lock and reload current state.
- External calls carry an idempotency key, and a timeout leads to a reconciliation step.
- Audit events record the decision and the outcome and keep secrets out.
More questions:
How do I stop an AI agent from running actions nobody approved in a Rails app?
Keep a server-owned registry of allowed actions and give each one an input contract. Let Rails take the tenant and user from the session, apply policy, and store any approval against a digest of the normalized action. Check policy and the digest again in the job that executes it.
Is Strong Parameters enough to validate AI tool call arguments?
No. Strong Parameters filter which keys reach mass assignment. They don't check that a record belongs to the current tenant or that a value is safe. Use an ActiveModel contract per action and a tenant-scoped lookup for the target.
What should a Rails app do when an agent action times out?
Mark the action execution_unknown and don't send a new request yet. The remote system may have finished the work. Pass a stable idempotency key to the connector, and run a reconciliation job that queries the operation reference or the target before any retry.
Should I keep agent approvals in the chat transcript?
No. Store the approval in its own table with the approver, the decision time, the expiry and the digest of the action they reviewed. The transcript explains why the agent proposed something. The approval record decides whether it can run.
Should agent authorization live in the AI framework or the Rails app?
Keep the final authorization decision in Rails. The AI framework can pause a tool call and resume the run after approval. Rails checks the actor, tenant, target, current policy and approval before the executor reaches a real service.
How do I stop the same approved action from running twice?
Claim the action under a row lock and give the external request a stable idempotency key. If the result is uncertain, move the action to execution_unknown and reconcile the remote state before another attempt.
If your team builds an agent that can touch customer accounts, cloud resources or sensitive data, I'd like to hear where you put the execution boundary and how you handle an unknown result after a retry 🤝
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
