README.md
README.mdBrowse 4 files
18,306 bytes
Token encoding: o200k_base
Snapshot 5b913e7
Paperclip Page Skill
paperclip-page publishes static page directories to a Paperclip-controlled S3
bucket served through CloudFront. It is the durable Paperclip-owned replacement
for quick here.now-style page sharing.
The v1 security posture is:
- CloudFront + ACM + Origin Access Control in front of a private S3 REST origin.
- Public content only.
- Dedicated uploader IAM identity, separate from Paperclip attachment storage.
- No
s3:DeleteObject, noaws s3 sync --delete, and no bucket/IAM/DNS changes from the publish helper. - Symlinks, hidden files, unsafe slugs, and accidental overwrites are rejected.
Agent Quick Start
Build or prepare a static directory with index.html at its root:
site/
index.html
assets/app.css
assets/app.js
Validate without AWS writes:
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --dry-run
Publish:
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo
Update an existing page from the same source directory:
.agents/skills/paperclip-page/scripts/publish.sh ./site --slug demo --update
The helper prints:
- public URL
- S3 key prefix
- local ownership state path
Source Directory Rules
index.htmlmust exist at the directory root.- Source directory itself must not be a symlink.
- No symlinks anywhere in the tree.
- No hidden files or dot paths in published content.
.paperclip-page/state.jsonis allowed and excluded from uploads.- Do not publish secrets, credentials, internal logs, private company material, customer data, or regulated data.
Add this to the publishing repo or generated site .gitignore when the source
directory lives in a git checkout:
.paperclip-page/
Environment Variables
Required for live publishes:
export AWS_REGION=us-east-1
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=...
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=...
Optional:
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
export PAPERCLIP_PAGE_AWS_SESSION_TOKEN=... # only with the namespaced key pair
Credential resolution order inside publish.sh:
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID+PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY(scoped to the helper'sawscalls; the surrounding process identity is untouched)PAPERCLIP_PAGE_AWS_PROFILE, passed toawsas--profile(ambientAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN/AWS_PROFILEare stripped from the helper'sawscalls so the named profile always wins)- The ambient AWS credential chain
Setting both the namespaced key pair and PAPERCLIP_PAGE_AWS_PROFILE is an
error.
Recommended Paperclip secret names:
paperclip-page-aws-access-key-idpaperclip-page-aws-secret-access-key
Bind those secrets into publisher agents as PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY. Never bind them as the global
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY names: static env keys override
AWS_PROFILE in the AWS CLI and every SDK, so global names silently switch the
whole agent run — and every subprocess — to the page-uploader identity and
break access to anything the uploader cannot reach (Secrets Manager, STS role
use, other buckets). Do not reuse Paperclip's internal S3 attachment/object
storage credentials.
AWS Setup
Run setup with an operator/admin AWS profile. Agents using this skill should not create buckets, mutate IAM, change DNS, or manage CloudFront.
export AWS_PROFILE=paperclip-admin
export AWS_REGION=us-east-1
export BUCKET=paperclip-pages-prod
export DOMAIN=pages.paperclip.ing
export UPLOADER_USER=paperclip-page-uploader
export CLOUDFRONT_COMMENT="Paperclip pages"
aws sts get-caller-identity --profile "$AWS_PROFILE"
Create the bucket:
aws s3api create-bucket \
--profile "$AWS_PROFILE" \
--region "$AWS_REGION" \
--bucket "$BUCKET"
For regions other than us-east-1, add:
--create-bucket-configuration LocationConstraint="$AWS_REGION"
Disable ACLs and keep ownership bucket-enforced:
aws s3api put-bucket-ownership-controls \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--ownership-controls '{"Rules":[{"ObjectOwnership":"BucketOwnerEnforced"}]}'
Block public access. CloudFront reads through OAC, so the bucket does not need a public website policy:
aws s3api put-public-access-block \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
Enable versioning:
aws s3api put-bucket-versioning \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--versioning-configuration Status=Enabled
Enable default encryption:
aws s3api put-bucket-encryption \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
Upload an operator-managed root 404.html before creating the distribution:
mkdir -p /tmp/paperclip-pages-bootstrap
printf '<!doctype html><title>Not found</title><h1>Not found</h1>\n' \
> /tmp/paperclip-pages-bootstrap/404.html
aws s3 cp /tmp/paperclip-pages-bootstrap/404.html "s3://$BUCKET/404.html" \
--profile "$AWS_PROFILE" \
--content-type text/html \
--cache-control 'public,max-age=60'
Create an ACM certificate in us-east-1 for CloudFront:
export ACM_REGION=us-east-1
aws acm request-certificate \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--domain-name "$DOMAIN" \
--validation-method DNS \
--idempotency-token paperclippages \
> /tmp/paperclip-pages-acm.json
export CERT_ARN="$(jq -r '.CertificateArn' /tmp/paperclip-pages-acm.json)"
aws acm describe-certificate \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--certificate-arn "$CERT_ARN" \
--query 'Certificate.DomainValidationOptions[].ResourceRecord'
Add the returned DNS validation record in Cloudflare, then wait:
aws acm wait certificate-validated \
--profile "$AWS_PROFILE" \
--region "$ACM_REGION" \
--certificate-arn "$CERT_ARN"
Create a CloudFront Origin Access Control:
aws cloudfront create-origin-access-control \
--profile "$AWS_PROFILE" \
--origin-access-control-config "{
\"Name\":\"paperclip-pages-oac\",
\"Description\":\"OAC for $BUCKET\",
\"SigningProtocol\":\"sigv4\",
\"SigningBehavior\":\"always\",
\"OriginAccessControlOriginType\":\"s3\"
}" \
> /tmp/paperclip-pages-oac.json
export OAC_ID="$(jq -r '.OriginAccessControl.Id' /tmp/paperclip-pages-oac.json)"
Create and publish a CloudFront Function so clean page URLs such as /demo/
load /demo/index.html from the S3 REST origin:
cat > paperclip-pages-index-router.js <<'EOF'
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
return request;
}
var lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
if (lastSegment.indexOf('.') === -1) {
request.uri = uri + '/index.html';
}
return request;
}
EOF
aws cloudfront create-function \
--profile "$AWS_PROFILE" \
--name paperclip-pages-index-router \
--function-config 'Comment=Rewrite clean page URLs to index.html,Runtime=cloudfront-js-2.0' \
--function-code fileb://paperclip-pages-index-router.js \
> /tmp/paperclip-pages-function.json
export FUNCTION_ETAG="$(jq -r '.ETag' /tmp/paperclip-pages-function.json)"
aws cloudfront publish-function \
--profile "$AWS_PROFILE" \
--name paperclip-pages-index-router \
--if-match "$FUNCTION_ETAG" \
> /tmp/paperclip-pages-function-live.json
export FUNCTION_ARN="$(jq -r '.FunctionSummary.FunctionMetadata.FunctionARN' /tmp/paperclip-pages-function-live.json)"
Create cloudfront-config.json:
export CALLER_REFERENCE="paperclip-pages-$(date +%s)"
jq -n \
--arg caller "$CALLER_REFERENCE" \
--arg comment "$CLOUDFRONT_COMMENT" \
--arg domain "$DOMAIN" \
--arg bucket "$BUCKET" \
--arg oac "$OAC_ID" \
--arg functionArn "$FUNCTION_ARN" \
--arg cert "$CERT_ARN" \
'{
CallerReference: $caller,
Comment: $comment,
Enabled: true,
IsIPV6Enabled: true,
Aliases: {Quantity: 1, Items: [$domain]},
Origins: {
Quantity: 1,
Items: [{
Id: "s3-origin",
DomainName: ($bucket + ".s3.amazonaws.com"),
OriginAccessControlId: $oac,
S3OriginConfig: {OriginAccessIdentity: ""}
}]
},
DefaultRootObject: "index.html",
DefaultCacheBehavior: {
TargetOriginId: "s3-origin",
ViewerProtocolPolicy: "redirect-to-https",
AllowedMethods: {Quantity: 2, Items: ["GET", "HEAD"], CachedMethods: {Quantity: 2, Items: ["GET", "HEAD"]}},
Compress: true,
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf",
FunctionAssociations: {
Quantity: 1,
Items: [{EventType: "viewer-request", FunctionARN: $functionArn}]
}
},
CustomErrorResponses: {
Quantity: 1,
Items: [{ErrorCode: 403, ResponsePagePath: "/404.html", ResponseCode: "404", ErrorCachingMinTTL: 60}]
},
ViewerCertificate: {
ACMCertificateArn: $cert,
SSLSupportMethod: "sni-only",
MinimumProtocolVersion: "TLSv1.2_2021"
},
Restrictions: {GeoRestriction: {RestrictionType: "none", Quantity: 0}}
}' > cloudfront-config.json
Create the distribution:
aws cloudfront create-distribution \
--profile "$AWS_PROFILE" \
--distribution-config file://cloudfront-config.json \
> /tmp/paperclip-pages-cloudfront.json
export DISTRIBUTION_ID="$(jq -r '.Distribution.Id' /tmp/paperclip-pages-cloudfront.json)"
export DISTRIBUTION_DOMAIN="$(jq -r '.Distribution.DomainName' /tmp/paperclip-pages-cloudfront.json)"
Grant CloudFront read access to the private bucket:
export ACCOUNT_ID="$(aws sts get-caller-identity --profile "$AWS_PROFILE" --query Account --output text)"
jq -n \
--arg bucket "$BUCKET" \
--arg account "$ACCOUNT_ID" \
--arg distribution "$DISTRIBUTION_ID" \
'{
Version: "2012-10-17",
Statement: [{
Sid: "AllowCloudFrontServicePrincipalReadOnly",
Effect: "Allow",
Principal: {Service: "cloudfront.amazonaws.com"},
Action: "s3:GetObject",
Resource: ("arn:aws:s3:::" + $bucket + "/*"),
Condition: {
StringEquals: {
"AWS:SourceArn": ("arn:aws:cloudfront::" + $account + ":distribution/" + $distribution)
}
}
}]
}' > bucket-policy.json
aws s3api put-bucket-policy \
--profile "$AWS_PROFILE" \
--bucket "$BUCKET" \
--policy file://bucket-policy.json
Create the uploader IAM user:
aws iam create-user \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER"
Create paperclip-page-uploader-policy.json. This policy supports collision
checks and additive uploads under slug prefixes while protecting root bootstrap
objects such as 404.html.
jq -n \
--arg bucket "$BUCKET" \
'{
Version: "2012-10-17",
Statement: [
{
Sid: "ListPublishedPagePrefixes",
Effect: "Allow",
Action: ["s3:ListBucket"],
Resource: ("arn:aws:s3:::" + $bucket)
},
{
Sid: "ReadPublishedPages",
Effect: "Allow",
Action: ["s3:GetObject"],
Resource: ("arn:aws:s3:::" + $bucket + "/*")
},
{
Sid: "WritePublishedPageObjects",
Effect: "Allow",
Action: ["s3:PutObject"],
Resource: ("arn:aws:s3:::" + $bucket + "/*/*")
},
{
Sid: "DenyReservedRootWrites",
Effect: "Deny",
Action: ["s3:PutObject", "s3:DeleteObject", "s3:PutObjectTagging"],
Resource: [
("arn:aws:s3:::" + $bucket + "/404.html"),
("arn:aws:s3:::" + $bucket + "/index.html")
]
}
]
}' > paperclip-page-uploader-policy.json
Attach it:
aws iam put-user-policy \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--policy-name PaperclipPagePublisher \
--policy-document file://paperclip-page-uploader-policy.json
Create access keys and treat the output as secret material:
aws iam create-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
> /tmp/paperclip-page-uploader-key.json
chmod 600 /tmp/paperclip-page-uploader-key.json
Cloudflare DNS
Use DNS-only or proxied CNAME to CloudFront. Do not point v1 at the S3 website endpoint.
Cloudflare UI:
- Open the
paperclip.ingzone. - Add
CNAME:- Name:
pages - Target: the CloudFront domain, for example
d111111abcdef8.cloudfront.net - Proxy status: DNS only or Proxied
- TTL: Auto
- Name:
API equivalent:
export CF_ZONE_ID=<paperclip.ing-zone-id>
export CF_API_TOKEN=<token-with-zone-dns-edit>
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg name pages \
--arg content "$DISTRIBUTION_DOMAIN" \
'{type:"CNAME", name:$name, content:$content, ttl:1, proxied:false}')"
Smoke check:
curl -I "https://$DOMAIN/404.html"
Paperclip Secrets
Create secrets from environment variables so values do not land in shell history:
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-access-key-id \
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-secret-access-key \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
Bind runtime env to publishing agents. Use the namespaced names — never the
global AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, which would shadow the
host AWS_PROFILE identity for the entire agent run:
{
"PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID": {
"type": "secret_ref",
"secretId": "<access-key-secret-id>",
"version": "latest"
},
"PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY": {
"type": "secret_ref",
"secretId": "<secret-key-secret-id>",
"version": "latest"
},
"AWS_REGION": { "type": "plain", "value": "us-east-1" },
"PAPERCLIP_PAGE_BUCKET": { "type": "plain", "value": "paperclip-pages-prod" },
"PAPERCLIP_PAGE_BASE_URL": { "type": "plain", "value": "https://pages.paperclip.ing" },
"PAPERCLIP_PAGE_DEFAULT_PREFIX": { "type": "plain", "value": "" }
}
Install And Attach
Create or update the company skill from this package:
npx paperclipai skills create \
--company-id <company-id> \
--name "Paperclip Page" \
--slug paperclip-page \
--description "Publish static pages to the Paperclip pages host" \
--body-file .agents/skills/paperclip-page/SKILL.md
Attach it to an agent:
npx paperclipai skills agent sync <agent-id-or-shortname> \
--company-id <company-id> \
--skill paperclip-page
Ensure the agent can read this directory or copy the package into the installed
company skill location with scripts/publish.sh preserved as executable.
Credential Rotation
- Create a second access key:
aws iam create-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
> /tmp/paperclip-page-uploader-key-rotation.json
chmod 600 /tmp/paperclip-page-uploader-key-rotation.json
- Store new secret versions in Paperclip Secrets.
- Update agent env bindings to the new versions or
latest. - Run a dry-run and a small publish smoke.
- Disable the old key:
aws iam update-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--access-key-id <old-access-key-id> \
--status Inactive
- Delete the old key after the next successful publish:
aws iam delete-access-key \
--profile "$AWS_PROFILE" \
--user-name "$UPLOADER_USER" \
--access-key-id <old-access-key-id>
Troubleshooting
AccessDenied on upload:
- Confirm agent env contains the uploader key, not the admin key.
- Confirm the uploader policy allows
s3:ListBucket,s3:GetObject, ands3:PutObject. - Confirm uploads target
<slug>/...so thearn:aws:s3:::<bucket>/*/*object ARN matches.
Slug already exists:
- Use a different slug.
- Or run
--updatefrom the original source directory that has.paperclip-page/state.json.
Generated slug collides:
- The helper appends a short suffix for generated slugs when AWS reports a collision. Explicit slugs fail instead of silently changing the URL.
URL 404s after upload:
- Check
curl -I https://<domain>/<slug>/. - Check CloudFront distribution deployment status.
- Check DNS CNAME target.
- Check the object exists at
s3://<bucket>/<slug>/index.html.
Stale browser cache:
- The helper uses
Cache-Control: public,max-age=60. - Wait a minute or issue a CloudFront invalidation if the operator wants an immediate refresh.
CloudFront returns 403:
- Confirm the bucket policy references the correct distribution ARN.
- Confirm OAC is attached to the S3 origin.
- Confirm the bucket is private and public access block is enabled.
Public Content Security Notes
Anything published with this skill is public. The tool cannot reliably classify generated files, so the publishing agent must inspect content before uploading.
Do not publish:
- API keys, OAuth tokens, cookies, or
.envfiles - internal customer data
- private company docs
- unpublished security reports
- raw transcripts that may contain secrets
Recovery after accidental overwrite uses S3 versioning. Because v1 uploader credentials cannot delete objects, rollback should be performed by an operator with admin credentials.