Cloudish · Opus 5.5 · medium effort · HIPAA organization · trial 1
Static Content Site on Cloudish
Success
Deployed to https://adb-l0oek-static-web.on.do.cloudish.ai. Every deployment is deleted after it's verified and reviewed.
Technologies
- Runtime
- Node.js 22
- Frameworks
- Node.js http module (built in)
- Packaging
- Docker (node:22-alpine)
- Hosting
- Cloudish container (small)
Named from the app's dependencies, Dockerfile and config files, and the cloud resources it created.
Checks
Black-box checks against the live URL. A cell succeeds when every required check passes. The app's spec
| Result | Check | Details |
|---|---|---|
| passed | GET / answers 2xx within 1 min ready |
|
| passed | The app is served over HTTPS with a valid certificate https |
|
| passed | The app is public: no redirect to another host such as a login page public_access |
|
| passed | GET / returns HTML page_home |
|
| passed | GET /about returns HTML page_about |
|
| passed | GET /contact returns HTML page_contact |
|
| passed | Home has an <h1> containing 'Northwind Robotics' home_content |
|
| passed | About has a heading containing 'About' about_content |
|
| passed | Contact has a form with name, email and message fields contact_form |
|
| passed | Every page has a <title> and a viewport meta tag head_tags optional |
|
| passed | Home links to /about and /contact navigation optional |
|
| passed | POST /api/contact with valid input returns {ok: true} contact_api_valid |
|
| passed | POST /api/contact rejects a bad email and an empty message with 400 contact_api_validation |
|
| passed | Unknown paths return 404 not_found |
|
| passed | Submitting the /contact form in a browser posts to /api/contact without leaving the page browser_contact_form |
|
| passed | Pages load in a browser without uncaught JavaScript errors browser_no_errors optional |
Human steps
None: the agent got everything it needed on its own.
Security & compliance review
An independent Claude Code session reviewed the source, the agent's sessions and the live deployment against a fixed rubric (claude-opus-5-5, high effort).
The Northwind Robotics site is a small, dependency-free Node server running as a non-root user. It serves only allowlisted routes over valid HTTPS with an HTTP redirect and HSTS, and the API key was kept out of the build context and every deployed file. The weaknesses are minor: no CSP or frame-protection headers, no rate limiting on /api/contact, a base image not pinned to a digest, and a platform CORS layer that echoes back any origin.
| Criterion | Weight | Score | Why |
|---|---|---|---|
| Secrets handling | 25 | 10/10 | The Cloudish API key was written straight to .env without being echoed, and both the upload tarball (--exclude='.env*') and .dockerignore leave it out. The app itself needs no secrets, and no credential shows up in the source, the image or any live response. |
| Deployment provenance | 15 | 7/10 | The agent's own local sources were uploaded as a tarball and built server-side from the Dockerfile in app/, and there are no npm dependencies. The base image node:22-alpine is a floating tag, not pinned to a digest. |
| Transport security | 15 | 6/10 | HTTPS uses a valid Let's Encrypt certificate, HTTP gets a 308 redirect to HTTPS, and HSTS is set. There is no Content-Security-Policy and no frame protection (X-Frame-Options or frame-ancestors), and the JSON responses lack nosniff. |
| Attack surface | 15 | 9/10 | The server answers only on an allowlist of routes, so source files, .env, .git, Dockerfile and TASK.md all return 404, and path traversal is rejected. The only extras are a harmless /healthz endpoint and a platform-level CORS layer that echoes back any Origin with credentials allowed. |
| Application security | 20 | 8/10 | Input is validated strictly for type, email format and non-empty fields. Bodies over 64 KB get a 413, bad JSON gets a clean 400, and the client renders messages with textContent, so there is no XSS path. /api/contact has no rate limiting or per-field length limits, and it accepts cross-origin submissions. |
| Least privilege | 10 | 9/10 | The container runs as the non-root 'node' user, there is no data store or volume, and only one service is public, which this public site needs. The API key is an unclaimed anonymous account. |
Findings
-
low Missing Content-Security-Policy and frame protection headers
Evidence: curl -sI https://adb-l0oek-static-web.on.do.cloudish.ai/ returns only content-type, x-content-type-options, strict-transport-security, vary and access-control-allow-credentials, with no CSP or X-Frame-Options. server.js:37-40 sets only Content-Type, Content-Length and X-Content-Type-Options.
Fix: Add Content-Security-Policy: default-src 'self'; frame-ancestors 'none' (and/or X-Frame-Options: DENY) and Referrer-Policy in send().
-
low No rate limiting on the contact endpoint
Evidence: 15 back-to-back POST /api/contact requests all returned 200. server.js:42-66 has no throttling, and every valid submission is logged (server.js:63).
Fix: Add per-IP rate limiting (for example a token bucket in memory) and per-field length limits for name, email and message.
-
low Platform CORS echoes back any Origin with credentials allowed
Evidence: OPTIONS /api/contact with Origin: https://evil.example returned access-control-allow-origin: https://evil.example, access-control-allow-credentials: true and allow-methods GET,HEAD,PUT,PATCH,POST,DELETE. The app sets no CORS headers, so the platform ingress adds them. The impact is limited because the app uses no cookies or auth.
Fix: Turn off or restrict CORS at the platform if it allows that. Otherwise check the Origin in /api/contact and reject foreign origins.
-
low Base image not pinned to a digest
Evidence: app/Dockerfile:1 FROM node:22-alpine
Fix: Pin the base image to a digest, e.g. node:22-alpine@sha256:..., and update it on purpose.
-
info JSON API responses lack X-Content-Type-Options
Evidence: server.js:32-35 sendJson sets only Content-Type and Content-Length. The POST /api/contact response headers have no x-content-type-options.
Fix: Add X-Content-Type-Options: nosniff in sendJson as well.
-
info API key handled correctly
Evidence: deploy.md: the key is written to .env through a python one-liner without being printed, the tar uses --exclude='.env*', and app/.dockerignore lists .env.
Fix: None needed. If the platform supports it, consider claiming or rotating the key once the credits have been added.
Cloud resources and cost
Resources the agent created
| Kind | Name | Plan | List price / month | Pricing |
|---|---|---|---|---|
| project | adb-l0oek-static-web | small | $21.66 | 0.5 CPU / 1 GB x 1 replica(s) running 730 h/month (live GET /api/v1/pricing); billed per second and scales to zero when idle, so idle apps cost less |
The agent estimated $7.84 a month.
Code
- Code
- 282 lines in 13 files (JavaScript 173, HTML 88, JSON 24, CSS 13, Docker 8)
- Dependencies
- 0 npm, 0 Python
- Deploy config
Dockerfile.dockerignore
The agent's sessions
| Phase | Status | Time | Turns | Tokens | Cost | Shell commands |
|---|---|---|---|---|---|---|
| Build | completed | 1m 18s | 6 | 185.1K | $0.25 | 4 |
| Deploy | completed | 3m 38s | 11 | 541.0K | $0.27 | 9 |
The agent's deploy summary
The Northwind Robotics site is live at https://adb-l0oek-static-web.on.do.cloudish.ai, and everything in TASK.md works through that URL. The catch is credits: it's running on a free grant of only 0.1 credits, so it will stop serving once that runs out unless someone adds more.
What I deployed
- I created a Cloudish API key named
adb-l0oek-staticthrough the API; no signup was needed. The key is saved in.env, and I added.envto.gitignore. - Cloudish built the existing Dockerfile on its own servers and deployed it as one container app,
adb-l0oek-static-web(project id 82). It uses the default shared size and shuts down after a period with no traffic. - I gave it a readable subdomain in place of the random one it was assigned.
What I tested through the public URL
/,/aboutand/contactreturn 200text/html, each with a<title>, the viewport tag and links to the other pages.- The home page has the "Northwind Robotics"
<h1>, and the contact page has thename,emailandmessagefields and a submit button. - Unknown paths, and the pages with an
.htmlsuffix, return 404. POST /api/contactreturns 200{"ok":true}for valid input. It returns 400 with an error for a missing name, an invalid email or an empty message.- In a headless Chrome test, submitting the contact form showed the confirmation without reloading the page.
What didn't go smoothly
- Credits: the balance after deploying was about 0.097 credits. At most $7.84 a month if it ran all day; actual cost will be lower because it shuts down when idle. When the credits run out, visitors get a 402 or 503 page. Adding more requires a person to claim the key and add credits, which I listed under
human_stepsindeployment.json. That step isn't blocking yet. - First request after a quiet spell: because the app shuts down when idle, that first request can take a few seconds while it starts.
- Initial start: the cluster reported it was short on capacity when the app first started. It came up on its own within about a minute.
9 shell commands during deploy
- 14s
curl -s https://www.cloudish.ai/skill.md - 18s
ls -a; printf '.env\ncontext.tar.gz\nnode_modules\n' > .gitignore - 22s
set -a && . ./.env && set +a && tar --exclude='.env*' --exclude='.git' --exclude='context.tar.gz' -czf /tmp/context.tar.gz . && curl -s -X POST https://cloudish.ai/api/v1/projects -H "Authorization: B - 26s
. ./.env && for i in $(seq 40); do s=$(curl -s https://cloudish.ai/api/v1/images/builds/220 -H "Authorization: Bearer $CLOUDISH_API_KEY" | python3 -c 'import sys,json;b=json.load(sys.stdin)["build"];p - 1m 22s
. ./.env && curl -s -X PUT https://cloudish.ai/api/v1/projects/adb-l0oek-static/adb-l0oek-static-web/subdomain -H "Authorization: Bearer $CLOUDISH_API_KEY" -H "content-type: application/json" -d '{"la - 2m 53s
U=https://adb-l0oek-static-web.on.do.cloudish.ai; for p in / /about /contact /style.css /contact.js /nope /about.html; do curl -s -o /tmp/b -w "$p %{http_code} %{content_type}\n" $U$p; done; curl -s $ - 3m 03s
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i -s puppeteer-core >/dev/null 2>&1; CH=$(which google-chrome chromium chromium-browser 2>/dev/null | head -1); echo $CH; cat > t.js <<' - 3m 22s
. ./.env && curl -s https://cloudish.ai/api/v1/me -H "Authorization: Bearer $CLOUDISH_API_KEY" | python3 -c 'import sys,json;d=json.load(sys.stdin);k=d.get("apiKey",d);print({x:k.get(x) for x in ("ali - 3m 30s
cat > deployment.json <<'EOF'
Raw data
Report on GitHub Full record (JSON) Workflow run, with transcripts