Your coding agent needs a GitHub token, a SaaS API key, or a per-tenant webhook secret to run a tool. The shortcut is stuffing GITHUB_TOKEN=ghp_... into the Lambda environment and giving the execution role kms:Decrypt on *. That shortcut is how one prompt-injected tool call exfiltrates every secret in the account. The unfair advantage is Secrets Manager + KMS grants with encryption context, short-lived grant tokens, and CloudTrail-auditable decrypts scoped to tenantId and toolName — not env plaintext.
⚡ TL;DR: Store tool secrets in Secrets Manager encrypted under CMKs. Issue KMS grants (or role policies) constrained by encryption context (
tenantId,toolName). Pass grant tokens into the agent runtime only for the active tool turn. Refusekms:Decrypton*. Pair with IAM Condition Keys for Agent Runtimes, Secure AI Sandboxes (ECS), and AI Coding Agent Tool Schemas.
Why env keys fail for agents
Lambda environment variables are visible to anyone with lambda:GetFunction / console access, often land in CI dumps, and are process-wide: every tool in the runtime can read every secret. Agents multiply blast radius — one compromised tool path sees all env keys.
// ❌ Plaintext in env — every tool can read it
const githubToken = process.env.GITHUB_TOKEN!;
await fetch("https://api.github.com/repos/acme/app/pulls", {
headers: { Authorization: `Bearer ${githubToken}` },
});
✅ Fetch from Secrets Manager at tool time, decrypt under a constrained grant, never cache across tenants in global memory without a keyed cache and TTL.
Secrets Manager + CMK with encryption context
Encrypt secrets with a customer managed key. Put encryption context on the secret’s KMS usage so decrypt requires matching context:
tenantId— which customer / workspacetoolName— which agent tool is allowed to see this secret- optionally
env=prod|staging
import {
SecretsManagerClient,
CreateSecretCommand,
} from "@aws-sdk/client-secrets-manager";
const sm = new SecretsManagerClient({});
export async function putToolSecret(input: {
tenantId: string;
toolName: string;
secretString: string;
kmsKeyId: string;
}) {
const name = `agent/${input.tenantId}/${input.toolName}`;
await sm.send(
new CreateSecretCommand({
Name: name,
SecretString: input.secretString,
KmsKeyId: input.kmsKeyId,
// Secrets Manager attaches context on encrypt; enforce on key policy / grants
Tags: [
{ Key: "tenantId", Value: input.tenantId },
{ Key: "toolName", Value: input.toolName },
{ Key: "agentSecret", Value: "true" },
],
})
);
return name;
}
On the CMK key policy, require encryption context for decrypt:
{
"Sid": "AllowDecryptOnlyWithAgentContext",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/AgentRuntime" },
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:EncryptionContext:toolName": "github_pr_read",
"kms:ViaService": "secretsmanager.ap-south-1.amazonaws.com"
},
"StringLike": {
"kms:EncryptionContext:tenantId": "*"
}
}
}
Tighten further: prefer StringEquals on tenantId via grants per tenant instead of StringLike on the role policy.
Grants and grant tokens with constraints
KMS grants let you mint a narrow decrypt capability without rewriting the key policy for every tenant. Create a grant that allows Decrypt only when encryption context matches, then pass the grant token into the hot path so eventual consistency on the grant does not race.
import {
KMSClient,
CreateGrantCommand,
RetireGrantCommand,
} from "@aws-sdk/client-kms";
import {
GetSecretValueCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
const kms = new KMSClient({});
const sm = new SecretsManagerClient({});
export async function withToolSecretGrant<T>(input: {
keyId: string;
granteePrincipal: string;
tenantId: string;
toolName: string;
secretId: string;
fn: (secret: string) => Promise<T>;
}): Promise<T> {
const grant = await kms.send(
new CreateGrantCommand({
KeyId: input.keyId,
GranteePrincipal: input.granteePrincipal,
Operations: ["Decrypt"],
Constraints: {
EncryptionContextSubset: {
tenantId: input.tenantId,
toolName: input.toolName,
},
},
// ✅ Name grants for audit; retire after the tool turn when possible
Name: `agent-${input.tenantId}-${input.toolName}`.slice(0, 256),
RetiringPrincipal: input.granteePrincipal,
})
);
try {
const secret = await sm.send(
new GetSecretValueCommand({
SecretId: input.secretId,
// Grant tokens help immediately after CreateGrant
})
);
// For direct KMS decrypt paths, pass GrantTokens: [grant.GrantToken!]
const value = secret.SecretString;
if (!value) throw new Error("empty_secret");
return await input.fn(value);
} finally {
if (grant.GrantId) {
await kms.send(
new RetireGrantCommand({
KeyId: input.keyId,
GrantId: grant.GrantId,
})
).catch(() => undefined);
}
}
}
For high-QPS tools, long-lived grants per tenantId+toolName with a rotator job may be better than create/retire every call — but still never put the plaintext in env.
Wire into the agent tool handler
export async function githubPrReadTool(ctx: {
tenantId: string;
runtimeRoleArn: string;
kmsKeyId: string;
}, args: { prNumber: number }) {
// ✅ Schema already validated prNumber; now least-privilege secret fetch
return withToolSecretGrant({
keyId: ctx.kmsKeyId,
granteePrincipal: ctx.runtimeRoleArn,
tenantId: ctx.tenantId,
toolName: "github_pr_read",
secretId: `agent/${ctx.tenantId}/github_pr_read`,
fn: async (token) => {
const res = await fetch(
`https://api.github.com/repos/acme/app/pulls/${args.prNumber}`,
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "cheatcoders-agent" } }
);
if (!res.ok) throw new Error(`github_${res.status}`);
return res.json();
},
});
}
Combine with tag-based IAM so the runtime role can only read secrets tagged agentSecret=true for its tenant — see IAM Condition Keys for Agent Runtimes.
❌ kms:Decrypt on * and other anti-patterns
| Anti-pattern | Risk | Fix |
|---|---|---|
kms:Decrypt Resource * |
Any CMK ciphertext in account | Resource = specific key ARNs |
| No encryption context | Any secret under the key | Require tenantId + toolName |
| Plaintext in env / SSM unencrypted | Universal tool access | Secrets Manager + CMK |
| Logging secret values | Prompt + log exfil | Redact; never EMF the value |
| Grant without retire / expiry process | Privilege pile-up | Retire or scheduled revoke |
| Shared “god secret” for all tools | One tool = all SaaS | One secret per toolName |
Audit with CloudTrail
Enable CloudTrail data events for KMS (and Secrets Manager). Alert on:
Decryptmissing expected encryption context keysCreateGrantfrom principals that are not your grant brokerGetSecretValuespikes for a tenant outside normal agent QPS
// Conceptual: metric filter on CloudTrail → alarm
// eventName=Decrypt && !.requestParameters.encryptionContext.toolName
Ship agent tool failures without echoing secret material. Meter decrypt counts per tenant with CloudWatch EMF — count decrypts, not secret bytes.
Checklist
- [ ] No tool API keys in Lambda environment variables
- [ ] Secrets Manager secrets under CMK; tagged with
tenantId,toolName - [ ] Key policy / grants require encryption context on decrypt
- [ ] No
kms:Decrypton*— explicit key ARNs only - [ ] Grant broker issues constrained grants; retire or rotate
- [ ] Tool handler fetches secret only inside the tool call scope
- [ ] CloudTrail alerts on decrypt without context / anomalous GetSecretValue
- [ ] Sandboxes get task-role credentials, not copied env blobs
Agents without least-privilege secrets are remote shells with your SaaS credentials attached. Grants + encryption context make decrypt an auditable, tenant-scoped event — not a global env read.
Most viewed
- Day 1: Tokens, Context Windows, and Why Models Forget Mid-Task
- Python Type Hints Complete Guide: Write Self-Documenting, Bug-Free Code
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- System Design Interview Cheat Sheet: The Framework That Gets You Hired at FAANG
- Day 9: Eval Harness Day One
Newly added
- API Gateway WebSockets for Multi-Turn Coding Agents: Connection State Without Sticky Myths
- CodeBuild Spec Sandboxes for AI Coding Agents: Ephemeral Builds Without ECS Sprawl
- KMS Decrypt Grants for Agent Tools: Least Privilege Without Env Keys
- EventBridge Scheduler: Overnight Agent Batches Without Cron Drift
- Lambda Recursive Loop Protection: Stop Agent Self-Invokes From Burning Quotas
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.