Skip to content

Commit 43de562

Browse files
hblankensqeeswy
authored andcommitted
sec(infra): non-root container + capability drop (ADR-0003)
Container ran as root until now; PR #2 acknowledged this in scripts/entrypoint.sh which still writes to /root/.claude/... The collab feature gives any participant a PTY inside the container, so "running as root" means a participant could cat /root/.local/share/opencode/*.sqlite and read every other user's GitHub OAuth token, or env-print SESSION_SECRET and the OAuth client secret. Add a non-root user (uid 10001) and hand the runtime tree over to it. Dockerfile + useradd opencode uid 10001 with /home/opencode as $HOME + chown -R 10001:10001 /app /home/opencode /var/opencode /usr/local/bin/opencode-entrypoint + USER opencode + ENV HOME=/home/opencode at the very end so build stages stay as root (faster, no permission gymnastics during build) + runtime dirs moved from /root/{.local,.config,.cache,.claude} to /home/opencode/{.local,.config,.cache,.claude} + plugin cache copied from /root path so the existing opencode-claude-auth pre-install is preserved across the move scripts/entrypoint.sh + $HOME_DIR derived from $HOME (set by the Dockerfile) — writes $CLAUDE_CREDENTIALS_JSON to $HOME_DIR/.claude/.credentials.json, not the hard-coded /root path docker-compose.yml + Volume mounts and bind-mount target moved to /home/opencode/... + Healthcheck switched to GET /healthz (ADR-0008) — finer-grained than GET / which only fails on a fully-dead Bun process DEPLOYMENT.md + ECS task definition gets "user": "10001:10001", "linuxParameters.capabilities.drop: [ALL]", healthcheck on /healthz + EFS volumes wired via Access Points (posixUser 10001/10001) with transitEncryption=ENABLED + One-time chown migration documented (aws ecs run-task with an entrypoint override) — operator step, not workflow plumbing, because it runs once per environment lifetime + EFS Access Point creation commands + the IAM perms the task role needs (ClientMount + ClientWrite on the AP ARNs) Local docker-compose continues to work: named volumes inherit their uid from the container that first writes to them, so a fresh "docker compose up -d --build" creates opencode-data as uid 10001 and the bind-mount of ~/.claude/.credentials.json is read-only.
1 parent 1d11ddf commit 43de562

4 files changed

Lines changed: 139 additions & 15 deletions

File tree

DEPLOYMENT.md

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,13 @@ Two file systems (or one with two access points) so the SQLite DB stays separate
139139
aws efs create-file-system --creation-token opencode-data --region $REGION
140140
aws efs create-file-system --creation-token collab-workspaces --region $REGION
141141
# Create mount targets in each subnet your ECS service runs in.
142-
# Create access points (uid/gid 0, root path "/data" each) so the task can mount
143-
# them at /root/.local/share/opencode and /var/opencode/workspaces respectively.
144142
```
145143

144+
Access points use **uid/gid 10001** (ADR-0003 — the `opencode` user).
145+
The exact `create-access-point` commands and the one-time chown of
146+
existing data live in "One-time EFS migration to uid 10001" inside
147+
Step 5 below.
148+
146149
Lower-spend alternative for early days: a single EC2 instance with EBS works fine — workspace dirs rarely exceed a few GB unless you clone monorepos.
147150

148151
### Step 4 — AWS Secrets Manager entries
@@ -191,11 +194,16 @@ Fargate, 2 vCPU / 4 GB. Replace `<…>` placeholders with the ARNs and IDs you
191194
{ "name": "SESSION_SECRET", "valueFrom": "<arn of opencode/session_secret>" }
192195
],
193196
"mountPoints": [
194-
{ "sourceVolume": "opencode-data", "containerPath": "/root/.local/share/opencode" },
197+
{ "sourceVolume": "opencode-data", "containerPath": "/home/opencode/.local/share/opencode" },
195198
{ "sourceVolume": "collab-workspaces", "containerPath": "/var/opencode/workspaces" }
196199
],
200+
"linuxParameters": {
201+
"capabilities": { "drop": ["ALL"] }
202+
},
203+
"readonlyRootFilesystem": false,
204+
"user": "10001:10001",
197205
"healthCheck": {
198-
"command": ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4096/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))\""],
206+
"command": ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4096/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""],
199207
"interval": 15, "timeout": 5, "retries": 5, "startPeriod": 30
200208
},
201209
"logConfiguration": {
@@ -208,12 +216,95 @@ Fargate, 2 vCPU / 4 GB. Replace `<…>` placeholders with the ARNs and IDs you
208216
}
209217
}],
210218
"volumes": [
211-
{ "name": "opencode-data", "efsVolumeConfiguration": { "fileSystemId": "<fs-id>", "rootDirectory": "/opencode-data" } },
212-
{ "name": "collab-workspaces", "efsVolumeConfiguration": { "fileSystemId": "<fs-id>", "rootDirectory": "/collab-workspaces" } }
219+
{
220+
"name": "opencode-data",
221+
"efsVolumeConfiguration": {
222+
"fileSystemId": "<fs-id>",
223+
"transitEncryption": "ENABLED",
224+
"authorizationConfig": { "accessPointId": "<fsap-id-data>", "iam": "ENABLED" }
225+
}
226+
},
227+
{
228+
"name": "collab-workspaces",
229+
"efsVolumeConfiguration": {
230+
"fileSystemId": "<fs-id>",
231+
"transitEncryption": "ENABLED",
232+
"authorizationConfig": { "accessPointId": "<fsap-id-workspaces>", "iam": "ENABLED" }
233+
}
234+
}
213235
]
214236
}
215237
```
216238

239+
#### One-time EFS migration to uid 10001 (ADR-0003)
240+
241+
Existing deployments wrote EFS files as uid 0. Before the new task
242+
definition above is deployed, the on-disk data has to be chowned —
243+
otherwise the new container (uid 10001) can't read its own SQLite file
244+
or write to workspace clones.
245+
246+
**Step 1 — chown the existing data** with the *current* image (still
247+
root), using an entrypoint override on a one-off ECS task:
248+
249+
```bash
250+
aws ecs run-task --cluster <cluster> \
251+
--task-definition <current-revision> \
252+
--launch-type FARGATE \
253+
--network-configuration 'awsvpcConfiguration={subnets=[...],securityGroups=[...]}' \
254+
--overrides '{
255+
"containerOverrides":[{
256+
"name":"opencode",
257+
"command":["chown","-R","10001:10001",
258+
"/var/opencode/workspaces",
259+
"/root/.local/share/opencode"]
260+
}]
261+
}'
262+
263+
# Wait for it to finish
264+
aws ecs wait tasks-stopped --cluster <cluster> --tasks <task-arn-from-above>
265+
```
266+
267+
This runs once, typically completes in under a minute.
268+
269+
**Step 2 — create EFS Access Points** so future writes are forced to
270+
uid 10001 regardless of in-container uid drift:
271+
272+
```bash
273+
aws efs create-access-point \
274+
--file-system-id <fs-id-data> \
275+
--posix-user 'Uid=10001,Gid=10001' \
276+
--root-directory 'Path=/,CreationInfo={OwnerUid=10001,OwnerGid=10001,Permissions=0755}' \
277+
--tags 'Key=Name,Value=opencode-data-ap'
278+
279+
aws efs create-access-point \
280+
--file-system-id <fs-id-workspaces> \
281+
--posix-user 'Uid=10001,Gid=10001' \
282+
--root-directory 'Path=/,CreationInfo={OwnerUid=10001,OwnerGid=10001,Permissions=0755}' \
283+
--tags 'Key=Name,Value=collab-workspaces-ap'
284+
```
285+
286+
**Step 3 — IAM** — the task role needs EFS client perms on the AP ARNs:
287+
288+
```jsonc
289+
{
290+
"Effect": "Allow",
291+
"Action": ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"],
292+
"Resource": ["<arn of fsap-data>", "<arn of fsap-workspaces>"]
293+
}
294+
```
295+
296+
`ClientRootAccess` is NOT needed after step 1; the chown is the only
297+
operation that requires it.
298+
299+
**Step 4 — deploy the new task definition** (the one with `"user": "10001:10001"`).
300+
ECS rolls it in. If the chown was successful, the container starts;
301+
if not, you'll see "Permission denied" errors on the SQLite open and
302+
the task crashes — re-run step 1.
303+
304+
> Local docker-compose is unaffected: named volumes inherit their uid
305+
> from the container that first writes to them; a fresh
306+
> `docker compose up -d --build` creates `opencode-data` as uid 10001.
307+
217308
Key points vs. the local `docker-compose.yml`:
218309

219310
- `ANTHROPIC_API_KEY` is now a real API key (was `dummy` locally). This bypasses the `opencode-claude-auth` plugin entirely — opencode sees a non-empty key and uses it directly.

Dockerfile

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,16 @@ RUN --mount=type=cache,target=/root/.npm \
7272
echo "WARNING: opencode-claude-auth pre-install failed; will install lazily at runtime"
7373

7474
# Pre-create directories that opencode and the collab workspace need at runtime.
75-
RUN mkdir -p /var/opencode/workspaces /root/.local/share/opencode /root/.config/opencode && \
76-
printf '{"plugin":["opencode-claude-auth@latest"]}\n' > /root/.config/opencode/opencode.json
75+
# Paths live under /home/opencode (ADR-0003) — the opencode user owns them and
76+
# they're created here so the final-stage chown is one shallow walk.
77+
RUN mkdir -p /var/opencode/workspaces \
78+
/home/opencode/.local/share/opencode \
79+
/home/opencode/.config/opencode \
80+
/home/opencode/.cache/opencode/packages \
81+
/home/opencode/.claude && \
82+
printf '{"plugin":["opencode-claude-auth@latest"]}\n' > /home/opencode/.config/opencode/opencode.json && \
83+
# Carry the pre-installed plugin tree across from /root.
84+
cp -r /root/.cache/opencode/packages/. /home/opencode/.cache/opencode/packages/ 2>/dev/null || true
7785

7886
# Bring in ONLY manifests, then install workspace deps.
7987
# Cache mount on /root/.bun/install/cache keeps the bun package store between builds.
@@ -104,7 +112,23 @@ RUN --mount=type=cache,target=/app/packages/app/node_modules/.vite \
104112
COPY scripts/entrypoint.sh /usr/local/bin/opencode-entrypoint
105113
RUN chmod +x /usr/local/bin/opencode-entrypoint
106114

115+
# ─────────────────────────────────────────────────────────────────────────────
116+
# Non-root user (ADR-0003).
117+
#
118+
# Until this stage everything ran as root for build speed. Now we create the
119+
# `opencode` user (uid 10001) and hand the runtime tree over to it. The
120+
# container's working set after this point — /app, /home/opencode, and the
121+
# data mount at /var/opencode — is owned by uid 10001. Drops the
122+
# blast-radius of any future RCE / PTY abuse from "read every secret" to
123+
# "stuff the unprivileged user can see".
124+
# ─────────────────────────────────────────────────────────────────────────────
125+
RUN useradd --uid 10001 --create-home --shell /bin/bash --home-dir /home/opencode opencode 2>/dev/null || true && \
126+
chown -R 10001:10001 /app /home/opencode /var/opencode /usr/local/bin/opencode-entrypoint
127+
107128
ENV NODE_ENV=production
129+
ENV HOME=/home/opencode
108130
EXPOSE 4096
109131

132+
USER opencode
133+
110134
ENTRYPOINT ["/usr/local/bin/opencode-entrypoint"]

docker-compose.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,18 @@ services:
4343
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
4444

4545
volumes:
46-
# Persistent SQLite database
47-
- opencode-data:/root/.local/share/opencode
46+
# Persistent SQLite database — owned by uid 10001 (ADR-0003).
47+
- opencode-data:/home/opencode/.local/share/opencode
4848
# Persistent server-side repo workspaces
4949
- collab-workspaces:/var/opencode/workspaces
5050
# Claude Code credentials for opencode-claude-auth plugin (bind-mount from host)
5151
# To populate: security find-generic-password -s "Claude Code-credentials" -w > ~/.claude/.credentials.json
52-
- ${HOME}/.claude/.credentials.json:/root/.claude/.credentials.json:ro
52+
- ${HOME}/.claude/.credentials.json:/home/opencode/.claude/.credentials.json:ro
5353
restart: unless-stopped
5454
healthcheck:
55-
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4096/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))\""]
55+
# /healthz returns 503 when SQLite is unreachable — finer-grained than
56+
# GET / (which only fails on a fully-dead Bun process). See ADR-0008.
57+
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:4096/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
5658
interval: 15s
5759
timeout: 5s
5860
retries: 5

scripts/entrypoint.sh

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@
1717

1818
set -eu
1919

20+
# Resolve the home dir from $HOME (set by the Dockerfile to /home/opencode
21+
# under ADR-0003). Falls back to /home/opencode if unset, which is the
22+
# only correct path post-ADR. /root/... would mean we're still root —
23+
# entrypoint logs would show a chown error and the credential write would
24+
# 500 the auth plugin until ECS replaces the task.
25+
HOME_DIR="${HOME:-/home/opencode}"
26+
2027
if [ -n "${CLAUDE_CREDENTIALS_JSON:-}" ]; then
21-
mkdir -p /root/.claude
22-
printf '%s' "$CLAUDE_CREDENTIALS_JSON" > /root/.claude/.credentials.json
23-
chmod 0600 /root/.claude/.credentials.json
28+
mkdir -p "$HOME_DIR/.claude"
29+
printf '%s' "$CLAUDE_CREDENTIALS_JSON" > "$HOME_DIR/.claude/.credentials.json"
30+
chmod 0600 "$HOME_DIR/.claude/.credentials.json"
2431
fi
2532

2633
# Hand off to the real server. $@ propagates whatever args ECS / CMD passed.

0 commit comments

Comments
 (0)