KMS Decrypt Grants for Agent Tools: Least Privilege Without Env Keys

0 views

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. Refuse kms:Decrypt on *. 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 / workspace
  • toolName — 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:

  • Decrypt missing expected encryption context keys
  • CreateGrant from principals that are not your grant broker
  • GetSecretValue spikes 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:Decrypt on * — 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.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.