AWS S3 skill for Codex
Private buckets, direct uploads, bounded storage bills. Codex applies a fixed S3 baseline (Block Public Access, ACLs disabled, encryption, TLS-only policy, versioning, multipart cleanup) and audits existing buckets against it. It wires direct browser uploads with server-chosen keys and presigned POST size limits, picks storage classes by access pattern, and builds event consumers that tolerate duplicates and URL-encoded keys.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- aws-s3 Installs to ~/.agents/skills/aws-s3/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install aws-s3 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project aws-s3 Installs to .agents/skills/aws-s3/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/aws-s3.zipunzip -o aws-s3.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) aws-s3 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $aws-s3, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Configures and uses Amazon S3 buckets safely - Block Public Access, Object Ownership, encryption with SSE-S3 or SSE-KMS bucket keys, TLS-only bucket policies, versioning, lifecycle rules and storage classes, presigned GET/PUT/POST URLs with size limits, CORS for browser uploads, and event notifications to Lambda, SQS or EventBridge. Use when creating a bucket, letting users upload or download files directly, cutting S3 storage cost, reacting to new objects, or auditing a bucket that might be public.
Use it when
- Creating or hardening an S3 bucket
- Implementing direct uploads or downloads with presigned URLs
- Reducing S3 storage cost with lifecycle rules and storage classes
- Triggering processing when objects land in a bucket
Use something else when
- You are serving the bucket's content to the public through a CDN: use aws-cloudfront
- The storage is Cloudflare R2 rather than S3: use cloudflare-r2
How to check the result
Bucket inspection shows all Block Public Access flags on, default encryption and a TLS-deny policy; unsigned requests return 403 and oversized or expired presigned uploads are rejected.
The mistake it prevents
Letting the client choose the object key in a presigned upload, which lets one user overwrite another user's files.
Done well means: no bucket is public unless it serves public content through CloudFront, every object is encrypted and every request uses TLS, clients upload and download directly with short-lived presigned URLs, lifecycle rules keep storage cost bounded, and event consumers survive duplicates and out-of-order delivery.
Before changing anything
- Read
AGENTS.mdand find where buckets are defined (CDK, Terraform, SAM). Change IaC, not the console. - Inspect the current state (needs network and credentials):
B=acme-uploads-prod
aws s3api get-public-access-block --bucket $B
aws s3api get-bucket-ownership-controls --bucket $B
aws s3api get-bucket-encryption --bucket $B
aws s3api get-bucket-versioning --bucket $B
aws s3api get-bucket-policy --bucket $B --query Policy --output text | python3 -m json.tool
aws s3api get-bucket-lifecycle-configuration --bucket $B
aws s3api get-bucket-notification-configuration --bucket $B
NoSuchPublicAccessBlockConfiguration or ServerSideEncryptionConfigurationNotFoundError
are findings, not errors to ignore.
Baseline for every bucket
| Setting | Value | Why |
|---|---|---|
| Block Public Access | All four flags on, at bucket and account level | Default for new buckets; keep it |
| Object Ownership | BucketOwnerEnforced (ACLs disabled) | Access only through policies |
| Encryption | SSE-S3 default; SSE-KMS with BucketKeyEnabled: true when you need key-level access control or audit | Bucket keys cut KMS request cost sharply |
| TLS | Bucket policy denying aws:SecureTransport = false | Blocks plaintext requests |
| Versioning | On for anything you cannot regenerate | Recovery from overwrite and delete |
| Lifecycle | Abort incomplete multipart uploads after 7 days; expire noncurrent versions | Otherwise invisible storage grows forever |
| Access logging | Server access logs or CloudTrail data events for sensitive buckets | Forensics |
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::acme-uploads-prod", "arn:aws:s3:::acme-uploads-prod/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}]
}
Static websites: keep the bucket private and serve it through CloudFront with Origin
Access Control (see $aws-cloudfront). Do not enable S3 website hosting with a public
bucket policy unless the user explicitly wants that and accepts it.
Presigned URLs
Use presigned URLs so file bytes never pass through your API servers.
| Need | Use |
|---|---|
| Download a private object | Presigned GET, 5-15 min expiry |
| Browser upload, size limit required | Presigned POST with content-length-range |
| Browser or mobile upload, no size enforcement needed | Presigned PUT with fixed ContentType |
| Files over ~100 MB | Multipart upload with presigned part URLs |
import boto3
from botocore.config import Config
s3 = boto3.client("s3", region_name="eu-west-1",
config=Config(signature_version="s3v4"))
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "acme-uploads-prod", "Key": key,
"ResponseContentDisposition": 'attachment; filename="report.pdf"'},
ExpiresIn=900,
)
post = s3.generate_presigned_post(
Bucket="acme-uploads-prod",
Key=f"incoming/{user_id}/{uuid4()}.jpg",
Fields={"Content-Type": "image/jpeg"},
Conditions=[{"Content-Type": "image/jpeg"},
["content-length-range", 1, 10 * 1024 * 1024]],
ExpiresIn=300,
)
# client sends multipart/form-data to post["url"] with post["fields"] then the file
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'eu-west-1' });
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: 'acme-uploads-prod', Key: key, ContentType: 'image/png' }),
{ expiresIn: 300 },
);
// the client must send exactly this Content-Type header or the signature fails
Rules:
- Generate the key server-side. Never let the client choose the full key; it can overwrite other users’ objects.
- The URL carries the signer’s permissions. Sign with a role that can only touch the upload prefix.
- A URL signed with temporary credentials (Lambda, ECS task role) dies when those
credentials expire, even if
ExpiresInis longer. SigV4 maximum is 7 days. - Treat uploads as untrusted: validate type and size after upload (event-driven scan or check) before using the object.
- Use the regional endpoint for the bucket’s region; signing for the wrong region gives
SignatureDoesNotMatchor a redirect.
Browser uploads need CORS on the bucket:
[{
"AllowedOrigins": ["https://app.acme.com"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}]
Expose ETag for multipart uploads; the client needs each part’s ETag to complete.
Lifecycle and storage classes
| Access pattern | Class |
|---|---|
| Unknown or changing | S3 Intelligent-Tiering (no retrieval fees; small monitoring fee per object) |
| Read rarely, needed in milliseconds | Standard-IA after 30 days |
| Archives read a few times a year | Glacier Instant Retrieval |
| Compliance archives, hours to restore is fine | Glacier Flexible Retrieval or Deep Archive |
{
"Rules": [
{ "ID": "abort-mpu", "Status": "Enabled", "Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } },
{ "ID": "logs-tiering", "Status": "Enabled", "Filter": { "Prefix": "logs/" },
"Transitions": [{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 180, "StorageClass": "GLACIER_IR" }],
"Expiration": { "Days": 730 } },
{ "ID": "old-versions", "Status": "Enabled", "Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 } }
]
}
Gotchas: IA and Glacier classes have minimum storage durations and minimum billable object sizes, so tiering millions of tiny objects can cost more than it saves. By default lifecycle does not transition objects smaller than 128 KB. Expiration rules on a production bucket delete data: confirm the prefix and day count with the user first.
Event notifications
| Need | Use |
|---|---|
| One consumer, simple | S3 notification to SQS (then Lambda), not direct to Lambda, so you get buffering and a DLQ |
| Several consumers or content-based filtering | Enable EventBridge on the bucket and use rules |
| Fan-out to many queues | S3 to SNS to SQS, or EventBridge |
- Delivery is at-least-once and not ordered. Deduplicate on
bucket + key + versionIdor thesequencerfield. - Object keys in events are URL-encoded (spaces arrive as
+). Decode withurllib.parse.unquote_plusbefore callingGetObject. - A consumer that writes back into the same bucket and prefix triggers itself. Write output to a different prefix excluded by the filter, or a different bucket.
- Prefix/suffix filters on one bucket cannot overlap for the same event type.
Gotchas
- S3 is strongly consistent for reads after writes and for lists. Old advice about eventual consistency is obsolete.
- Prevent overwrites with conditional writes:
PutObjectwithIf-None-Match: *fails with 412 if the key exists. UseIf-Match: <etag>for optimistic updates. aws s3 sync --deleteandaws s3 rm --recursiveare destructive. Run with--dryrunfirst and get explicit confirmation before the real run. Deleting a bucket or disabling versioning on a production bucket also needs confirmation.- Request rates are per prefix (about 3,500 writes and 5,500 reads per second); spread hot keys across prefixes for very high throughput.
- Cross-region transfer and internet egress cost money; NAT gateway processing for S3 traffic is avoidable with a free S3 gateway VPC endpoint.
Verification
- Re-run the inspection commands; all four Block Public Access flags
true, encryption present, policy denies insecure transport. curl -I "https://acme-uploads-prod.s3.eu-west-1.amazonaws.com/some-key"without a signature returns 403.- Presigned flow: upload with the URL, check an oversized file is rejected, check the URL fails after expiry.
- IAM Access Analyzer for S3 lists no unintended external or public access.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "AWS S3"
short_description: "Private buckets, direct uploads, bounded storage bills"
default_prompt: "$aws-s3 Let users upload profile images straight to S3 from the browser with a 10 MB limit" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.