Skip to content

Commit c37ca88

Browse files
broomvaclaude
andcommitted
fix(security): two blockers found by adversarial review, not by the suite (BRO-2227)
Both were guards that existed, read correctly, and did not hold. 1. AN EMPTY READING OF THE CONTEXT WALKED EVERY ACCEPT GATE. `proposeFromDirectory` skips dot entries, but the SOURCE_EMPTY check runs BEFORE that filter. A directory holding only dot entries therefore produced a proposal with no state, no actions and -- decisively -- no blocking questions. Every downstream gate is keyed on there being something to ask about, so BLOCKING_QUESTIONS_OPEN could not fire and neither could the reconciliation check. Result: an ontology minted with ZERO human input, recording a named person as having accepted a model of their own context. This is not a contrived input. A freshly provisioned tenant workspace contains only `.claude`, so the empty reading IS the first-run path. Reproduced end to end before fixing: propose -> 0 fields, 0 actions, 0 questions; accept with no answers -> ACCEPTED, acceptedBy "anyone". Fixed at two layers: propose now fails DEGENERATE_CONTEXT when nothing readable was found, and activate refuses NO_ACTIONS for an empty action space however the proposal arrived -- a domain with no actions cannot have anything happen in it. 2. A SYMLINK DEFEATED THE ONLY PATH GUARD ON THE AGENT-FACING SURFACE. confine() was purely lexical: resolve() then startsWith(). path.resolve never follows symlinks, so `ln -s /somewhere ws/data` produced a target that starts with the base and points anywhere. Reproduced: an outside directory's listing -- `customer_pii/`, `private.key` -- was read into a proposal, rendered into the message sent to the human, and persisted in the pending record. This one matters more than it would elsewhere: the surrounding tenant sandbox confines writes but NOT reads, so a lexical-only guard was the difference between describing the tenant's own workspace and describing whatever they pointed a link at. Now checked twice -- lexically, then physically via realpathSync. The base is realpath'd too, because /tmp is itself a symlink on macOS and comparing a resolved target against an unresolved base fails on ordinary paths, which would push someone to loosen the check for the wrong reason. Adds PATH_NOT_FOUND and WORKSPACE_UNREADABLE so a missing path and an unreadable workspace are not reported as escapes. test/regression.test.ts pins both. Every refusal is PAIRED WITH A CONTROL that must pass -- a real subdirectory is still allowed, a workspace with visible entries still proposes, a proposal with actions still activates -- because a guard that refuses everything is indistinguishable from a working one on the refusal case alone, which is exactly how both of these survived the tests written beside them. Mutation-proven, anchors confirmed unique before each mutation: disabling DEGENERATE_CONTEXT kills 1, disabling NO_ACTIONS kills 1, reverting confine() to lexical-only kills 3. Restored 188 pass, 0 fail. Also: adding codes in src/core broke src/tools/errors.ts at the type level, which the 175-test suite did not catch because `bun test` does not typecheck. The union is hand-mirrored; deriving it was attempted and does not typecheck cleanly against the re-export shape, so the drift hazard is documented in place rather than left implicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6e6b6ee commit c37ca88

21 files changed

Lines changed: 7492 additions & 318 deletions

landing/index.html

Lines changed: 659 additions & 316 deletions
Large diffs are not rendered by default.

render.yaml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Parallax hub - Render Blueprint
2+
#
3+
# One web service that serves BOTH the landing page (GET /) and the hub API
4+
# (POST /api/*, GET /r/:id). The frozen hub contract binds Bun.serve to
5+
# process.env.PORT on host 0.0.0.0, which is exactly what Render requires.
6+
#
7+
# WHY runtime: node AND NOT runtime: bun
8+
# There is no `bun` runtime on Render. `render blueprints validate` rejects it
9+
# outright: {"error":"invalid runtime bun","path":"services[0].runtime"}.
10+
# Bun ships as a preloaded tool INSIDE the JS/TS native runtime (`node`), and
11+
# is only present in the build+runtime image if the service root carries a
12+
# bun.lock / bun.lockb, a .bun-version file, or a BUN_VERSION env var.
13+
# This repo's root IS the service root and bun.lock is tracked there, so the
14+
# gate is satisfied. BUN_VERSION below is a second, explicit belt: if anyone
15+
# ever gitignores bun.lock, the env var alone keeps Bun in the image.
16+
# A Dockerfile is therefore NOT needed and deliberately not present.
17+
#
18+
# WHY plan: free IS WRITTEN OUT
19+
# Omitting `plan` silently defaults to `starter`, and this workspace has no
20+
# payment method on file, so the Blueprint then fails validation with
21+
# need_payment_info. `free` must be explicit. Consequences of free, which are
22+
# real and are not worked around here:
23+
# - No persistent disk. The filesystem is ephemeral, so anything the hub
24+
# writes (a bun:sqlite EventLog, a rendered receipt) is lost on every
25+
# redeploy, restart and idle spin-down. GET /r/:id must therefore render
26+
# from the runId+seed deterministically, or be documented as in-memory.
27+
# - 15 minutes without inbound traffic spins the service down; the next
28+
# request takes about a minute to answer. Warm it before a live demo.
29+
# - 750 free instance-hours per workspace per calendar month.
30+
# To move to a paid instance later: add a card, then change `plan` to
31+
# `starter` here. Region cannot be changed after creation; plan can.
32+
#
33+
# WHY name: parallax-hub
34+
# The hostname is https://<name-slugified>.onrender.com. `parallax.onrender.com`
35+
# and `simulacro.onrender.com` are both already taken by unrelated apps.
36+
# `parallax-hub.onrender.com` was verified unclaimed (HTTP 404 with
37+
# `x-render-routing: no-server`, which is Render's tell for a free hostname).
38+
#
39+
# Validate any edit to this file BEFORE committing it:
40+
# render blueprints validate ./render.yaml
41+
42+
services:
43+
- type: web
44+
name: parallax-hub
45+
runtime: node
46+
repo: https://github.com/broomva/parallax
47+
branch: main
48+
49+
# Explicit. Omitting this means `starter`, which this workspace cannot bill.
50+
plan: free
51+
52+
# Closest Render region to the team and to LatAm judges. Immutable after
53+
# the service is created - changing it later requires a new service.
54+
region: virginia
55+
56+
buildCommand: bun install --frozen-lockfile
57+
startCommand: bun run src/hub/serve.ts
58+
59+
# GET /health returns 200 {ok:true, version, uptimeSeconds}. Render requires
60+
# a 2xx/3xx within five seconds. With this set, a deploy only goes live once
61+
# the new instance answers, so a broken build fails visibly instead of
62+
# replacing a working service with a dead one.
63+
healthCheckPath: /health
64+
65+
# Deploy on every push to main. `autoDeployTrigger` replaces the deprecated
66+
# `autoDeploy` key; setting both is a validation error. Switch the value to
67+
# `checksPass` to gate deploys on the ci.yml workflow going green, or `off`
68+
# to make every deploy manual.
69+
autoDeployTrigger: commit
70+
71+
# Pull-request preview environments are off: each one is a second instance
72+
# burning from the same 750 free instance-hours per month.
73+
previews:
74+
generation: "off"
75+
76+
envVars:
77+
# Pins the toolchain to the version this repo is developed and CI-tested
78+
# against (.github/workflows/ci.yml uses oven-sh/setup-bun @ 1.3.14), so
79+
# the deployed image cannot drift with Render's rolling default.
80+
- key: BUN_VERSION
81+
value: "1.3.14"
82+
- key: NODE_ENV
83+
value: production
84+
# PORT is intentionally NOT set here. Render injects it (10000 in
85+
# production) and the hub reads process.env.PORT with a local default of
86+
# 3000. Setting PORT in this file would override Render's own value.

0 commit comments

Comments
 (0)