For the past few weeks I've been building Aegis-DevOps, an open-source check that sits in front of the shell commands an AI coding agent runs. Before Claude Code, Codex, Copilot, Cursor or Gemini CLI runs kubectl, terraform or aws, a hook hands the command to Aegis, and Aegis blocks it if your signed policy says no. It works. It also has a hole I knew about from the start, and version 0.3.0 is my first attempt at closing it.
Disclosure: I drafted this article with help from an AI assistant and reviewed it myself. The commands and output below are from running aegis-devops 0.3.0 against its example policy.
A pre-execution hook sees the command string the agent asks its shell tool to run. It doesn't see:
boto3 directly;
In all three cases the destructive call reaches the AWS API without ever looking like aws s3 rb. Blocking on the client is the right first layer, because it's fast and it can explain itself to the model. It isn't the last layer.
AWS already has a control that sits above everything an identity inside an account can do: a Service Control Policy. An SCP attached to an account in an AWS Organization caps what any role in that account may do, and an agent with IAM rights inside the account can't detach it.
So 0.3.0 adds a compiler. It takes the same policy the hook enforces and writes it out as SCPs:
$ aegis compile aws --account 111122223333 --out build/aegis-aws
aegis: wrote 1 SCP(s) for account 111122223333 to build/aegis-aws
(exact=2, not-applicable=27, partial=1)
aegis: agents.yaml is report-only: the SCPs are under report-only/ and are not for attaching;
review coverage.md, then set enforcement: enforce
The rule "never delete an S3 bucket or an RDS database" becomes an explicit Deny:
{
"Effect": "Deny",
"Action": ["rds:DeleteDBInstance", "rds:DeleteDBCluster", "rds:DeleteDBSnapshot",
"rds:DeleteDBClusterSnapshot", "s3:DeleteBucket", "s3:DeleteObject",
"s3:DeleteObjectVersion"],
"Resource": ["*"],
"Condition": {
"ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::111122223333:role/BreakGlass",
"arn:aws:iam::111122223333:role/Deploy"] },
"StringNotEqualsIfExists": { "aws:SourceIdentity": ["alice@example.com"] }
}
}
Now it doesn't matter whether the agent typed aws s3 rb, wrote a boto3 script, or found a key. If the call is made as an agent identity, AWS refuses it.
An API call carries an identity, not a hint about who was typing. So the compiler needs an identity model, in a signed agents.yaml. The default is deny-by-default: you list the identities you trust (people, your deploy role, CI bound to a specific workflow), plus at least one break-glass role, and everything else is treated as an agent.
I went with deny-by-default because the other way round, listing the agents, fails open. Forget one, or let an agent mint a new role, and the new one isn't restricted.
If the only thing between an agent and DeleteDBInstance is "is this call from the Deploy role?", the obvious move is to become the Deploy role. So every compile also adds statements that stop exactly that:
sts:AssumeRole into a trusted or break-glass role, and no iam:PassRole of one;organizations:LeaveOrganization, since an account that leaves the organization sheds its SCPs.
A guardrail that looks stronger than it is does more damage than no guardrail, because people stop checking. So every compile writes a coverage report, rule by rule:
### block-s3-bucket-delete: exact, mapping verified
- enforced: s3/bucket delete (s3:DeleteBucket, s3:DeleteObject, s3:DeleteObjectVersion)
- same effect, not covered: s3:PutLifecycleConfiguration: an expiration rule deletes every object
- same effect, not covered: s3:PutBucketPolicy: a deny-all bucket policy locks everyone out
### block-rds-delete: partial, mapping verified
- not enforced: 'rds/*' also matches resource types the action map does not know
- same effect, not covered: rds:ModifyDBInstance: BackupRetentionPeriod=0 deletes the automated backups
"Same effect, not covered" is the list of other ways to reach the same outcome that the rule, as written, doesn't block. You can widen the rule, or accept the gap knowingly.
A few more choices along the same lines:
report-only/ and tells you not to attach them. You switch agents.yaml to enforce (a signed change) once you've reviewed who would be restricted.
It's a preview. AWS is in the 0.3.0 release; a Kubernetes target (ValidatingAdmissionPolicies scoped to agent identities) is merged and waiting for the next release, and GCP and Azure are designed but not built. SCPs don't apply to an organization's management account, so agents shouldn't run there at all. Rules with a time window or a rate limit stay client-side, because an SCP can't express them. And it's a young project: I'd much rather hear "this mapping is wrong" now than after someone relies on it.
pip install aegis-devops
aegis init .aegis
aegis compile aws --account <your-account-id> --out build/aegis-aws
The client hook installs in one command for each agent (aegis install claude|codex|copilot|cursor|gemini|opencode), and there's a GitHub Action for Terraform and OpenTofu plans. Details on the compiler are in docs/server-side.md.
If you run agents with real AWS access, I'd like to know how you scope them today. Issues are open at github.com/moneytool/aegis-devops.