the short answer
Create a scoped IAM Identity Center permission set for agent work, give it a named profile, and enforce it at the agent’s tool boundary: deny AWS, Terraform and IaC calls that name an admin profile or no profile, and tell the agent which profile to use. Enforce it on every call; session-start events are observational on every supported harness, so they cannot gate a session.
- Credential layer
- An IAM Identity Center permission set and named profile for agents
- Policy layer
- Deny calls that use an unapproved profile or none
- Where to gate
PreToolUseon every call;UserPromptSubmitwhere it blocks- Session start
- Observational on every supported harness
- Login
aws sso login --profile agent-dev
Why Profile Enforcement Is the Highest-Leverage AWS Control
A developer’s shell often has several AWS profiles configured: a sandbox, a dev role, and for admins an AdministratorAccess or on-call permission set. Agents use whichever one the environment points at, usually whatever AWS_PROFILE was last exported. Requiring a scoped profile for agent work turns every other AWS policy from “the only thing in the way” into a second layer.
Step 1: Create the Agent Profile
- In IAM Identity Center, create a permission set for agent work, for example read-only plus the specific write actions your developers need in dev accounts.
- Set a short session duration. Permission sets allow 1 to 12 hours; agent work rarely needs the maximum.
- Add a named profile to developers’
~/.aws/config, for exampleagent-dev, via your onboarding script or MDM. - Developers sign in with
aws sso login --profile agent-devbefore starting agent sessions.
Step 2: Enforce the Profile on Every Call
Requiring an explicit profile is stricter than inspecting the environment, and that is the point: policies receive the tool call, not the shell’s exported variables, so “whatever was exported last” in an earlier session cannot silently become an admin role. Each call is checked on its own, including calls behind sudo, env, bash -c or inside $( ), so one profiled call cannot cover an unprofiled one; an export AWS_PROFILE=… earlier in the same command counts. Calls that need no credentials, such as aws --version or terraform fmt, are let through.
import { customPolicies, allow, deny } from "failproofai";
// An AWS-credentialed tool in command position: start of a segment, inside
// $( ) or backticks, after sudo/env/time/xargs or bash -c, optionally after
// VAR=value assignments.
const AWS_CALL = /(?:^|[(`]|\s-c\s+["']?|\b(?:sudo|env|time|exec|nohup|xargs)\s+(?:-\S+\s+)*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*["']?(aws|terraform|tofu|cdk|pulumi|sam|eksctl)["']?\s+([\w-]+)/g;
const NO_CREDENTIALS = /^(--version|version|fmt|validate|help|--help)$/;
const APPROVED = new Set(["agent-dev", "agent-readonly"]);
const NAMED = /(?:--profile[=\s]+|AWS_PROFILE=)([\w.-]+)/g;
const EXPORTED = /^export\s+AWS_PROFILE=([\w.-]+)/;
customPolicies.add({
name: "agents-use-approved-aws-profile",
description: "Every AWS, Terraform and IaC call from an agent must name an approved profile",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (ctx.toolName !== "Bash") return allow();
const cmd = String(ctx.toolInput?.command ?? "");
let exported = null;
// Check each segment on its own, so one profiled call cannot cover another.
for (const segment of cmd.split(/\n|;|&&|\|\||\|/).map((s) => s.trim())) {
const set = segment.match(EXPORTED);
if (set) exported = set[1];
const calls = [...segment.matchAll(AWS_CALL)].filter((m) => !NO_CREDENTIALS.test(m[2]));
if (calls.length === 0) continue;
const profiles = [...segment.matchAll(NAMED)].map((m) => m[1]);
const bad = [...profiles, exported].find((p) => p && !APPROVED.has(p));
if (bad) {
return deny(`Profile "${bad}" is not approved for agent sessions. Use agent-dev, or ask a human to run this.`);
}
if (!exported && profiles.length < calls.length) {
return deny("Name the AWS profile on every call: prefix it with AWS_PROFILE=agent-dev or pass --profile agent-dev.");
}
}
return allow();
},
});Test Cases
| Command | Expected |
|---|---|
aws s3 ls | deny |
aws s3 ls --profile agent-dev | allow |
AWS_PROFILE=agent-dev terraform plan | allow |
aws s3 ls --profile admin | deny |
aws --version | allow |
terraform fmt | allow |
git commit -m 'update aws docs' | allow |
cd infra && AWS_PROFILE=oncall-admin terraform apply | deny |
sudo aws s3 ls | deny |
env aws s3 ls | deny |
bash -c "aws s3 ls" | deny |
AWS_PROFILE=agent-dev aws s3 ls; aws s3 ls s3://prod-bucket | deny |
aws ec2 terminate-instances --instance-ids $(aws ec2 describe-instances --query x --output text) --profile agent-dev | deny |
export AWS_PROFILE=agent-dev && aws s3 ls && terraform plan | allow |
Step 3: Why per Call and Not at Session Start
It is tempting to check once at session start and let the session run. Failproof’s harness capability matrix lists session lifecycle events as observational on every supported harness, so a session-start check cannot stop anything. The profile can also change mid-session when the agent exports a new one.
Use UserPromptSubmit, which blocks on Claude Code, Codex, Copilot CLI, Cursor, Pi, OpenClaw, Factory Droid and Devin CLI, if you want to refuse a task outright, and the per-call policy above for everything else.
| Gate | Blocks on | Use it for |
|---|---|---|
SessionStart | No supported harness | Recording session-level state |
UserPromptSubmit | Claude Code, Codex, Copilot CLI, Cursor, Pi, OpenClaw, Factory Droid, Devin CLI | Refusing a task before it starts |
PreToolUse | All twelve supported harnesses | Enforcing the approved profile on every call |
SDK Scripts and Terraform Providers
- A Python script that builds a
boto3.Session(profile_name="admin")names the profile in the file. Extend the policy toWriteandEditcontent, as in the bypass guide. - Terraform providers can hardcode
profile = "admin"inprovider "aws"blocks. Grep your repos; prefer the environment so the policy is the source of truth. - An SDK that reads the default credential chain with no profile named falls back to the environment. The IAM permission set is the only control there.
FAQ
Can the policy read `AWS_PROFILE` from the agent’s environment?
No. Policies receive the normalized tool call, session metadata (such as the working directory) and the source harness, not the agent process environment. That is why the pattern requires the profile to be named in the command.
Will developers hate typing the profile?
The agent types it, not the developer. A clear deny reason teaches the agent the convention after one refusal.
Does this replace least privilege?
No. It routes agent work to the least-privileged profile you created. Without that profile there is nothing safe to route to.
Sources
Checked against the sources below on October 21, 2026. Harness settings, hook capabilities and cloud features change between versions.
- AWS docs: Set session duration for AWS accounts (IAM Identity Center)
- AWS docs: Configuring IAM Identity Center authentication with the AWS CLI
- Failproof AI docs: Custom policies (policy SDK)
- Failproof AI docs: Agent harnesses and enforcement capability
- Failproof AI docs: Test a policy
- Claude Code docs: Hooks reference