feat(s3): a guarded asset publisher, so an agent can put a file the public can download #90

Merged
coilysiren merged 1 commit from feat/serve-s3-publisher into main 2026-08-19 15:44:34 +00:00
Member

serve-s3, the first write-capable SDK mode in this runtime. Standing up an S3 MCP for the Sirens Dowel lane, so Dowel can publish an asset and hand back a link rather than only linking one Kai staged.

Why the guardfile carries more than IAM does

serve-ssm reads one parameter, so its whole boundary is one ARN and IAM states it. A publisher is different in kind. The caller supplies the bytes, the key, and the content type, and to IAM every PutObject inside the bucket looks alike. Several bounds therefore exist only here:

  • Content type is an exact allowlist, and the parser refuses text/html, image/svg+xml, application/xhtml+xml, and the other markup a browser executes in the origin's own security context. That origin carries other published work. SVG is the non-obvious one, an image everywhere else in this runtime and a scriptable document here. A guardfile listing one fails to start rather than starting and quietly never allowing it.
  • Keys take a narrow character set with no traversal and no empty segments. A key is a public URL path, so anything outside letters, digits, dash, underscore, dot, and slash is refused rather than escaped, because an escaped key round-trips differently through a CDN, a bucket listing, and a client that linkifies it.
  • Size is capped from the decoded bytes, never from a length the caller states.

What it deliberately cannot do

No delete tool, and the workload user's IAM policy withholds s3:DeleteObject rather than relying on that absence. put_object advertises readOnlyHint: false and idempotentHint: false, so a roster that trusts annotations does not read an upload as a safe call.

The bucket is private behind CloudFront, so base-url is the distribution's and the tools never hand back an S3 endpoint.

Notes for review

  • max-bytes reads the KDL argument by kind. Value.String() renders a debug form for every non-string kind and Value.Int() panics on one, so parsing the text rejected every integer written the obvious way. The test caught it, the parser now switches on Kind().
  • No examples/ entry. The S3 grammar is not lintable through lint, and TestLintAcceptsShippedExamples globs that directory, which is the same reason no SSM policy ships there. docs/s3.md carries the sample instead.
  • go.mod picks up service/s3 as a direct dependency, and go get upgraded several shared aws-sdk-go-v2 internals along with it.
  • gofmt, go vet, and the full go test ./... are green locally.

Merging this publishes the source-sha image the deploy side needs.

`serve-s3`, the first write-capable SDK mode in this runtime. Standing up an S3 MCP for the Sirens Dowel lane, so Dowel can publish an asset and hand back a link rather than only linking one Kai staged. ## Why the guardfile carries more than IAM does `serve-ssm` reads one parameter, so its whole boundary is one ARN and IAM states it. A publisher is different in kind. The caller supplies the bytes, the key, and the content type, and to IAM every `PutObject` inside the bucket looks alike. Several bounds therefore exist only here: * **Content type is an exact allowlist**, and the parser refuses `text/html`, `image/svg+xml`, `application/xhtml+xml`, and the other markup a browser executes in the origin's own security context. That origin carries other published work. SVG is the non-obvious one, an image everywhere else in this runtime and a scriptable document here. A guardfile listing one fails to start rather than starting and quietly never allowing it. * **Keys take a narrow character set** with no traversal and no empty segments. A key is a public URL path, so anything outside letters, digits, dash, underscore, dot, and slash is refused rather than escaped, because an escaped key round-trips differently through a CDN, a bucket listing, and a client that linkifies it. * **Size is capped from the decoded bytes**, never from a length the caller states. ## What it deliberately cannot do No delete tool, and the workload user's IAM policy withholds `s3:DeleteObject` rather than relying on that absence. `put_object` advertises `readOnlyHint: false` and `idempotentHint: false`, so a roster that trusts annotations does not read an upload as a safe call. The bucket is private behind CloudFront, so `base-url` is the distribution's and the tools never hand back an S3 endpoint. ## Notes for review * `max-bytes` reads the KDL argument **by kind**. `Value.String()` renders a debug form for every non-string kind and `Value.Int()` panics on one, so parsing the text rejected every integer written the obvious way. The test caught it, the parser now switches on `Kind()`. * **No `examples/` entry.** The S3 grammar is not lintable through `lint`, and `TestLintAcceptsShippedExamples` globs that directory, which is the same reason no SSM policy ships there. `docs/s3.md` carries the sample instead. * `go.mod` picks up `service/s3` as a direct dependency, and `go get` upgraded several shared aws-sdk-go-v2 internals along with it. * `gofmt`, `go vet`, and the full `go test ./...` are green locally. Merging this publishes the source-sha image the deploy side needs.
feat(s3): a guarded asset publisher, so an agent can put a file the public can download
All checks were successful
ci / gate (push) Successful in 36s
ci / publish (push) Has been skipped
ci / gate (pull_request) Successful in 36s
ci / publish (pull_request) Has been skipped
e8f8034e47
serve-s3 is the first write-capable SDK mode here. serve-ssm reads one
parameter, so its whole boundary is one ARN and IAM can state it. A publisher
is different in kind: the caller supplies the bytes, the key, and the content
type, and to IAM every PutObject inside the bucket looks alike.

So several bounds exist only in the guardfile. Content type is an exact
allowlist and the parser refuses text/html, image/svg+xml, and the rest of the
markup that executes in the origin's own security context, since the origin
carries other published work. Keys take a narrow character set with no
traversal, because a key is a public URL path and an escaped one round-trips
differently through a CDN, a bucket listing, and a client that linkifies it.
Size is capped from the decoded bytes rather than from the caller's claim.

No delete tool, and put_object advertises readOnlyHint false and
idempotentHint false so a roster that trusts annotations does not read an
upload as a safe call.

max-bytes reads the KDL argument by kind. Value.String() renders a debug form
for every non-string kind and Value.Int() panics on one, so parsing the text
would have rejected every integer written the obvious way.

No examples/ entry: the S3 grammar is not lintable through `lint`, which is
the same reason no SSM policy ships there either. docs/s3.md carries the
sample instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/mcp-beaver!90
No description provided.