A coding agent that can npm test, edit files, and open PRs is useful. The same agent with long-lived credentials, outbound internet, and a shared EFS volume is a ransomware kit with a chatbot UI. On AWS, the unfair advantage is not “more tools” — it is ephemeral Lambda sandboxes that make every tool call a sealed, auditable transaction.
⚡ TL;DR: Run each agent tool (lint, test, patch apply, repo read) as a dedicated Lambda with a tiny IAM policy, no standing secrets in env, and network locked to VPC endpoints you choose. Pass a per-session workspace via S3 (or ephemeral
/tmp) — never a shared writable disk across tenants. Enforce wall-clock timeouts (e.g. 30–120s), memory caps, and concurrency limits. Return structured tool results (exit code, truncated stdout) to the model — not raw root shells. Illustrative: a 1024MB Node sandbox cold-starting in ~200–600ms warm path under ~50–150ms for simple file ops.
Why Lambda beats “always-on agent VMs” for tools
Long-running agent hosts feel convenient until one session leaves a malicious postinstall running. Lambda gives you:
- Process isolation per invocation — crash and malware die with the execution environment recycle.
- IAM as the real sandbox boundary — the model cannot escalate past the role.
- Cost that tracks tool use — idle agents cost $0; a misbehaving loop hits reserved concurrency and stops.
User → Orchestrator (Bedrock / custom) → Tool router
├─ ReadRepoLambda (S3 GetObject only)
├─ ApplyPatchLambda (S3 read/write session prefix only)
├─ RunTestsLambda (no AWS APIs; VPC deny egress)
└─ OpenPRLambda (GitHub App token from Secrets Manager, scoped)
❌ Don’t give one “AgentRuntime” role AdministratorAccess and hope prompt instructions hold.
✅ Split tools; each Lambda’s policy is the capability the model is allowed to have.
Session workspaces: S3 prefixes, not shared disks
Treat every agent session as s3://agent-workspaces/{tenantId}/{sessionId}/. The orchestrator copies the repo snapshot (or sparse checkout artifact) into that prefix; tools only see that prefix.
// apply-patch/handler.ts
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { createHash } from "crypto";
import { promises as fs } from "fs";
import path from "path";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const s3 = new S3Client({});
const BUCKET = process.env.WORKSPACE_BUCKET!;
const MAX_DIFF_BYTES = 200_000; // ✅ hard cap — illustrative
type PatchEvent = {
tenantId: string;
sessionId: string;
relativePath: string;
unifiedDiff: string;
};
export const handler = async (event: PatchEvent) => {
assertIds(event.tenantId, event.sessionId);
if (Buffer.byteLength(event.unifiedDiff, "utf8") > MAX_DIFF_BYTES) {
return { ok: false, error: "diff_too_large" };
}
if (event.relativePath.includes("..") || path.isAbsolute(event.relativePath)) {
return { ok: false, error: "invalid_path" }; // ❌ path traversal
}
const prefix = `tenants/${event.tenantId}/sessions/${event.sessionId}/`;
const key = `${prefix}${event.relativePath}`;
const local = `/tmp/work/${event.relativePath}`;
await fs.mkdir(path.dirname(local), { recursive: true });
const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
const body = await obj.Body!.transformToByteArray();
await fs.writeFile(local, body);
// Apply with `patch` binary — no shell metacharacters
const diffPath = "/tmp/change.diff";
await fs.writeFile(diffPath, event.unifiedDiff);
try {
// ✅ execFile argv array — never exec(`patch < ${user}`)
await execFileAsync("patch", ["-p0", "--batch", "-i", diffPath], {
cwd: "/tmp/work",
timeout: 10_000,
maxBuffer: 2_000_000,
});
} catch (e: any) {
return { ok: false, error: "patch_failed", detail: String(e?.stderr || e).slice(0, 2000) };
}
const patched = await fs.readFile(local);
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: patched,
Metadata: {
sha256: createHash("sha256").update(patched).digest("hex"),
session: event.sessionId,
},
})
);
return { ok: true, bytes: patched.length };
};
function assertIds(tenantId: string, sessionId: string) {
const re = /^[a-zA-Z0-9_-]{8,64}$/;
if (!re.test(tenantId) || !re.test(sessionId)) throw new Error("bad_ids");
}
IAM for ApplyPatch:
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::agent-workspaces/tenants/${aws:PrincipalTag/tenantId}/sessions/*"
}
Pass tenantId as a session tag on the assumed role so the condition key works. Illustrative pattern: orchestrator assumes AgentSessionRole with sts:TagSession.
Network deny-by-default for “run tests” sandboxes
RunTestsLambda should not call the public internet unless you explicitly allow a package mirror. Prefer:
- Bundle dependencies in the workspace artifact (CI already resolved lockfiles).
- Or allowlist an internal CodeArtifact / npm mirror via VPC endpoint / PrivateLink.
- Block SSM, STS, and cloud metadata misuse by not attaching broad AWS permissions at all.
resource "aws_security_group" "test_sandbox" {
name = "agent-test-sandbox"
vpc_id = var.vpc_id
# ❌ No egress 0.0.0.0/0
# ✅ Optional: allow HTTPS only to VPC endpoint SGs for CodeArtifact/S3
egress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [var.codeartifact_vpce_sg]
}
}
resource "aws_lambda_function" "run_tests" {
function_name = "agent-run-tests"
runtime = "nodejs20.x"
handler = "index.handler"
role = aws_iam_role.tests_no_aws_apis.arn
timeout = 120 # illustrative wall clock
memory_size = 2048 # tests often need CPU ~ proportional to memory
reserved_concurrent_executions = 20 # blast-radius cap
vpc_config {
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.test_sandbox.id]
}
environment {
variables = {
# ❌ No GITHUB_TOKEN here
MAX_STDOUT_BYTES = "100000"
}
}
}
// run-tests — return truncated structured output to the LLM
export const handler = async (evt: { sessionPath: string; cmd: "test" | "lint" }) => {
const allowed = {
test: ["npm", ["test", "--", "--runInBand"]],
lint: ["npm", ["run", "lint"]],
} as const;
const [bin, args] = allowed[evt.cmd];
try {
const { stdout, stderr } = await execFileAsync(bin, args, {
cwd: evt.sessionPath,
timeout: 90_000,
maxBuffer: 100_000,
env: { PATH: "/usr/local/bin:/usr/bin", HOME: "/tmp", CI: "1" }, // ✅ minimal env
});
return {
ok: true,
exitCode: 0,
stdout: stdout.slice(0, 100_000),
stderr: stderr.slice(0, 20_000),
};
} catch (e: any) {
return {
ok: false,
exitCode: e.code ?? 1,
stdout: String(e.stdout || "").slice(0, 100_000),
stderr: String(e.stderr || e.message).slice(0, 20_000),
};
}
};
Illustrative latency: warm lint tool ~0.5–2s; full unit suite 15–90s — set the agent’s UX to “job started” rather than blocking a chat bubble for 90s. Fan long tests out via Step Functions or SQS if the orchestrator needs to stay snappy.
Secrets: short-lived, task-scoped, never in prompts
Opening a PR needs a GitHub App installation token — mint it inside OpenPRLambda, use once, discard.
# open_pr/handler.py — illustrative
import os, time, jwt, urllib.request, json, boto3
sm = boto3.client("secretsmanager")
def handler(event, _ctx):
# event: {tenantId, sessionId, branch, title, body}
secret = json.loads(sm.get_secret_value(SecretId=os.environ["GH_APP_SECRET_ARN"])["SecretString"])
token = mint_installation_token(secret, event["installation_id"])
# ✅ token lives only in this invocation's memory
# ❌ never return token to the model / never log Authorization headers
pr = create_pull_request(token, event)
return {"ok": True, "prUrl": pr["html_url"], "number": pr["number"]}
def mint_installation_token(secret, installation_id: int) -> str:
now = int(time.time())
payload = {"iat": now - 60, "exp": now + 540, "iss": secret["app_id"]}
app_jwt = jwt.encode(payload, secret["private_key"], algorithm="RS256")
req = urllib.request.Request(
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
method="POST",
headers={
"Authorization": f"Bearer {app_jwt}",
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)["token"]
Pair with Secrets Manager rotation and CloudTrail data events on the secret ARN. If the model “needs” credentials, it needs a tool, not a string in the system prompt.
Orchestrator contract: structured tools only
Expose tools to the LLM as JSON schemas with enums — the model picks cmd: "lint" | "test", never a free-form shell string.
{
"name": "run_checks",
"description": "Run lint or unit tests in the session sandbox",
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["cmd"],
"properties": {
"cmd": { "type": "string", "enum": ["lint", "test"] }
}
}
}
✅ Allowlist binaries and argv.
❌ {"cmd": "bash", "args": "-c", "script": user_text} is how you fail a security review.
Closing checklist
✅ Dos
– ✅ One Lambda role per tool capability; session-tagged S3 prefixes
– ✅ execFile + allowlists; truncate stdout/stderr before returning to the model
– ✅ Deny-by-default egress; reserved concurrency as a kill switch
– ✅ Mint SCM tokens inside the PR tool; never persist them in DynamoDB chat logs
– ✅ Timeouts that match the tool (10s patch, 120s tests) — not 900s everywhere
❌ Don’ts
– ❌ Don’t run coding agents on a shared EC2 with Docker-in-Docker “for flexibility”
– ❌ Don’t put long-lived PATs in Lambda environment variables
– ❌ Don’t let the model supply shell strings or absolute paths
– ❌ Don’t share /tmp or EFS across tenants without ironclad isolation
– ❌ Don’t skip CloudWatch Logs redaction for Authorization and cookie headers
Related reading
- AWS Lambda Best Practices: Write Functions That Scale and Never Time Out
- Lambda in VPC Without the 10-Second Cold Start
- Hyper-Scale Serverless API Platform on AWS
- Terraform for Developers: Infrastructure as Code From Zero to Production AWS
- Amazon Bedrock Agents: Tool Use, Memory, and Production Guardrails (companion post)
- AI Code Review Bots: IAM, Secrets, and Least-Privilege Pipelines (companion post)
Last updated on September 10, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
