Add phased-execution planning structure
Build and Push Container / build-and-push (push) Successful in 11s

Introduce .agents/ (PLAN.md with locked architectural anchors,
phase roadmap under phases/todo/) and AGENTS.md rules for
agents working in the repo. Queues the pending phases: fix
history XSS, simulate fetch commands, and nginx security
headers.
This commit is contained in:
2026-09-18 17:23:41 -04:00
parent 010758b3e9
commit a2f331e526
13 changed files with 963 additions and 0 deletions
@@ -0,0 +1,38 @@
# Phase 01 — Fix terminal history-recall XSS (audit M-1)
**Story:** n/a
**Context:** `.agents/PLAN.md` §4 (terminal modules), `.agents/remediation_plan.md` finding M-1
## Objective
Eliminate the DOM-XSS sink in the fake terminal's command-history recall:
ArrowUp/ArrowDown currently rebuild the prompt line with
`lastLine.innerHTML = '<span class="terminal-prompt">$</span> ' + cmdLine + ' '`
where `cmdLine` is raw user input from `commandHistory`. Replace it with
textContent-based construction so terminal input can never be injected as
HTML, and lock the fix with a Playwright regression test.
## Dependencies
— (none)
## Tasks
1. `01_fix_history_recall.md` — Rewrite the ArrowUp/ArrowDown recall to build the prompt line with `createElement`/`textContent` (mirror the existing `updateDisplay()` pattern).
2. `02_xss_regression_test.md` — Add a Playwright test proving a markup payload typed into the terminal is recalled as literal text, not DOM.
## Testing & Quality
- Unit/integration: the new regression test must cover (a) a payload that
would create a DOM node if injected (`<b id="xss-marker">x</b>`), (b) a
payload with an inline event handler (`<img src=x onerror=...`) asserting
no script execution, and (c) a benign command (`whoami`) still recalled
and executable.
- Coverage: **>90%** on new/modified code (the two recall branches in
`src/terminal.js`).
- E2E: full Playwright suite (`npm test`) green — it already exercises
history recall with benign commands and must not regress.
## Completion Criteria
- [ ] `grep -n "innerHTML" src/terminal.js` shows no occurrence combining
`innerHTML` with `cmdLine`/history data (static prompt literals and the
neofetch canned-output assignment are the only allowed `innerHTML` uses)
- [ ] new XSS regression test present in `tests/e2e/02-terminal.spec.js` and passing
- [ ] `./build.sh && npm test` fully green (44+ tests)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,49 @@
# Task 01 — Rewrite history recall with the safe display path
**Phase:** `01_fix_history_xss` · **Story:** n/a
## Objective
Remove the `innerHTML` sink from the terminal's ArrowUp/ArrowDown history
recall by routing recall through the existing `updateDisplay(text)` function,
which already builds the prompt line with `textContent`/`createElement`.
## Work
1. `src/terminal.js` — in the terminal `keydown` handler, replace the three
`lastLine.innerHTML = '<span class="terminal-prompt">$</span> ' + cmdLine + ' '`
constructions with `updateDisplay(...)` calls:
- **ArrowUp branch** (`if (historyIndex < commandHistory.length - 1)`):
delete the `innerHTML` line, the `newCursor` creation, and the
`appendChild` — keep `historyIndex++` and `const cmdLine = ...`, then:
```js
updateDisplay(cmdLine);
```
- **ArrowDown branch, first case** (`if (historyIndex > 0)`): same
replacement — `updateDisplay(cmdLine);`
- **ArrowDown branch, else case** (`historyIndex = -1`): replace the
`innerHTML`/cursor block with:
```js
updateDisplay('');
```
`updateDisplay` already: removes any stale `.terminal-cursor`, clears the
line via `textContent`, appends a `.terminal-prompt` span (respecting
`isRoot` → `#` vs `$`), the text, and a fresh cursor (red when root).
Note the two intentional, behavior-preserving improvements this brings:
(a) recalled prompts match the live typing state (cursor directly after
the text, no trailing space); (b) after `sudo su -`, recall shows `#`
instead of a stale `$`. Both are consistency fixes, not regressions.
## Testing & Quality
- Unit/integration: no new unit code — the existing E2E test
`02-terminal.spec.js › history: ArrowUp/ArrowDown recall previous commands`
exercises recall and execution of recalled commands and must stay green.
- Coverage: **>90%** on the modified branches (covered by that E2E test plus
the task-02 regression test).
## Completion Criteria
- [ ] `grep -n "innerHTML" src/terminal.js` — no remaining occurrence that
involves `cmdLine` or history data
- [ ] `./build.sh && npm test` fully green
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,65 @@
# Task 02 — Add the XSS regression test
**Phase:** `01_fix_history_xss` · **Story:** n/a
## Objective
Add a Playwright E2E test that proves a markup payload typed into the
terminal is recalled (ArrowUp) as **literal text**, never as DOM, and never
executes. This test fails on the pre-fix (innerHTML) code and passes on the
fixed code, so it guards against regressions/reverts.
## Work
1. `tests/e2e/02-terminal.spec.js` — add the following test inside the
`test.describe('Fake terminal — state and special commands', ...)` block
(it reuses `openTerminal`, `typeCommand`, `currentPromptLine` from
`./helpers`):
```js
test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => {
await openTerminal(page);
// 1) A payload that would create a DOM node if innerHTML-injected.
await typeCommand(page, '<b id="xss-marker">pwned</b>');
await page.keyboard.press('ArrowUp');
await expect(page.locator('#xss-marker')).toHaveCount(0);
await expect(currentPromptLine(page)).toContainText('<b id="xss-marker">pwned</b>');
// 2) An event-handler payload must not execute. `data:,` is an empty
// data URI so the <img> fails to decode deterministically (no
// network dependency) and onerror would fire if injected as HTML.
await page.evaluate(() => { window.__xssFired = false; });
await typeCommand(page, '<img src="data:," onerror="window.__xssFired=true">');
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(200); // allow onerror to fire if vulnerable
expect(await page.evaluate(() => window.__xssFired)).toBe(false);
await expect(page.locator('img[onerror]')).toHaveCount(0);
await expect(currentPromptLine(page)).toContainText('<img src="data:," onerror="window.__xssFired=true">');
// 3) Benign recall still works and is executable.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('optional');
});
```
Notes for the executor:
- Step 3 walks back down through history (`<img…>`, then `<b…>`, then the
prior real command) and executes it; adjust the final assertion to the
benign command that is actually on top of the history at that point if
the exact stack differs when you run it. The point is that recall +
execute of a benign command still works.
- Do **not** add an `onerror`-based assertion that depends on a real
network fetch; keep it self-contained with `data:,`.
## Testing & Quality
- Unit/integration: this test **is** the regression coverage for the M-1 fix.
It must: fail on innerHTML-based recall, pass on textContent-based recall.
- Coverage: **>90%** on the modified recall branches (both payload classes +
benign path covered).
## Completion Criteria
- [ ] new test present in `tests/e2e/02-terminal.spec.js`
- [ ] `./build.sh && npm test` fully green (45+ tests)
- [ ] (sanity) temporarily reverting task 01's fix makes this test fail —
confirms it is a genuine regression guard, then re-apply the fix
@@ -0,0 +1,44 @@
# Phase 02 — Remove unvalidated client fetch from curl/wget (audit M-2)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 (principle 5 — fun is safe) & §3 (terminal
modules), `.agents/remediation_plan.md` finding M-2
## Objective
The fake terminal's `curl`/`wget` commands run a real, unvalidated
`fetch(url)` from the visitor's browser — a client-side request-forgery
primitive (and cookie-attached once the site ever has auth) that is also
silently broken in production (CSP `connect-src` falls back to
`default-src 'self'`, blocking every external fetch). Replace the real
fetch with **simulated output** consistent with the rest of the fake
terminal: zero network I/O, deterministic, echoes the requested URL.
**Chosen approach** (audit recommendation, owner-approved 2026-09-18):
simulate. The rejected alternative (real fetch + https-only allowlist +
`connect-src` + `credentials: 'omit'`) is documented for the record but out
of scope.
## Dependencies
— (none)
## Tasks
1. `01_simulate_fetch_output.md` — Add `simulatedCurlOutput(url)` / `simulatedWgetOutput(url)` to `terminal-commands.js`; replace both `fetch()` calls in `terminal.js` with the simulated outputs.
2. `02_fetch_regression_test.md` — Add Playwright tests proving curl/wget render simulated output, make **zero** non-origin network requests, and still unlock the `web_navigator` achievement.
## Testing & Quality
- Unit/integration: the regression tests must cover (a) `curl <url>`
renders the simulated block and echoes the URL, (b) `wget <url>` same,
(c) no request to any non-origin host occurs (request listener),
(d) `web_navigator` achievement/toast still fires.
- Coverage: **>90%** on new/modified code (the two simulator functions +
both branches).
- E2E: full Playwright suite green.
## Completion Criteria
- [ ] `grep -n "fetch(" src/terminal.js` returns no matches (the only
`fetch(` in `src/` must be gone)
- [ ] `simulatedCurlOutput` / `simulatedWgetOutput` present in
`src/terminal-commands.js`
- [ ] new regression tests present in `tests/e2e/` and passing
- [ ] `./build.sh && npm test` fully green (46+ tests)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,81 @@
# Task 01 — Replace real fetch with simulated output
**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
## Objective
Remove the only real network I/O from the fake terminal: the `curl` and
`wget` commands stop calling `fetch(url)` and instead render a
deterministic, simulated response that echoes the requested URL. This kills
the client-side request-forgery primitive (audit M-2) and makes the feature
work identically in production (where CSP blocked the real fetch anyway).
## Work
1. `src/terminal-commands.js` — append two pure functions (no DOM, no
network). Keep them deterministic (the wget timestamp is the only
intentional non-determinism):
```js
// Simulated fetch output. The fake terminal performs NO real network
// I/O by design — these produce a deterministic, clearly-simulated
// response that echoes the requested URL.
function _simulatedBody(url) {
return 'simulated body for ' + url;
}
function simulatedCurlOutput(url) {
const body = _simulatedBody(url);
return [
' % Total % Received % Xferd Average Speed Time Time Time Current',
' Dload Upload Total Spent Left Speed',
'100 ' + body.length + ' 100 ' + body.length + ' 0 0 ' + (body.length * 40) + ' 0 --:--:-- --:--:-- --:--:--',
body,
].join('\n');
}
function simulatedWgetOutput(url) {
const body = _simulatedBody(url);
return [
'-- ' + new Date().toISOString().replace('T', ' ').slice(0, 19) + '-- ' + url,
'Resolving host... (simulated)',
'Connecting... connected (simulated).',
'HTTP request sent, awaiting response... 200 OK',
'Length: ' + body.length + ' [text/plain]',
"Saving to: 'index.html'",
'',
body,
'',
'Finished (simulated). No real network request was made.',
].join('\n');
}
```
2. `src/terminal.js` — in the `curl ` branch, delete the `fetch(url).then(...)
.catch(...)` chain and replace it with:
```js
// Simulated fetch — the fake terminal never performs real network I/O.
setTimeout(() => {
outLine.textContent = simulatedCurlOutput(url);
content.scrollTop = content.scrollHeight;
}, 400);
```
3. `src/terminal.js` — in the `wget ` branch, make the identical change but
call `simulatedWgetOutput(url)`.
Keep the existing `url` extraction regexes, the `Downloading...` line, and
the `whiteSpace`/`color` styling. The `web_navigator` achievement (keyed
on the `curl `/`wget ` prefix) is untouched and still fires.
## Testing & Quality
- Unit/integration: no unit test needed for the pure output functions beyond
the E2E assertions in task 02 (they are string builders exercised through
the terminal). If desired, a tiny `node -e` sanity check:
`node -e "eval(require('fs').readFileSync('src/terminal-commands.js','utf8')); console.log(simulatedCurlOutput('https://x'))"`.
- Coverage: **>90%** on the two new functions and both modified branches
(both covered by the task-02 E2E test).
## Completion Criteria
- [ ] `grep -n "fetch(" src/terminal.js` returns **no** matches
- [ ] `simulatedCurlOutput` and `simulatedWgetOutput` defined in
`src/terminal-commands.js`
- [ ] `./build.sh && npm test` fully green (existing 44 tests unaffected)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,59 @@
# Task 02 — Add the simulated-fetch regression test
**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
## Objective
Add a Playwright E2E test proving `curl`/`wget` render the simulated output,
make **zero** non-origin network requests, and still unlock the
`web_navigator` achievement.
## Work
1. `tests/e2e/02-terminal.spec.js` — add inside the
`test.describe('Fake terminal — state and special commands', ...)` block:
```js
test('curl/wget are simulated — no real network request leaves the origin', async ({ page }) => {
await openTerminal(page);
// Track every request that is NOT aimed at the test origin.
const external = [];
page.on('request', (r) => {
if (!r.url().startsWith('http://127.0.0.1:8123')) external.push(r.url());
});
await typeCommand(page, 'curl https://example.com/hello');
// achievement fires on the first curl/wget
await expect(page.locator('.toast').filter({ hasText: 'Web Navigator' })).toBeVisible();
const curlLine = page.locator('.terminal-content > div').nth(-2);
await expect(curlLine).toContainText('simulated body for https://example.com/hello');
await typeCommand(page, 'wget https://example.org/file.txt');
const wgetLine = page.locator('.terminal-content > div').nth(-2);
await expect(wgetLine).toContainText('No real network request was made');
await expect(wgetLine).toContainText('https://example.org/file.txt');
expect(external).toEqual([]);
});
```
Notes for the executor:
- The origin prefix `http://127.0.0.1:8123` must match `baseURL` in
`playwright.config.js`; keep them in sync if the port ever changes.
- The `web_navigator` toast is asserted right after the curl command
(toasts auto-remove after ~4.3 s).
- If `expect(external).toEqual([])` is flaky on your network (e.g. DNS
prefetch noise), tighten it to assert that no request URL contains
`example.com` or `example.org` instead — the point is that the
simulated URLs were never contacted.
## Testing & Quality
- Unit/integration: this test is the regression coverage for the M-2 fix —
it fails on the pre-fix code (a real `fetch` to example.com/example.org
would appear in `external`) and passes on the simulated code.
- Coverage: **>90%** on both modified branches (both exercised).
## Completion Criteria
- [ ] new test present in `tests/e2e/02-terminal.spec.js`
- [ ] `./build.sh && npm test` fully green (45+ tests)
- [ ] (sanity) temporarily reverting task 01's change makes this test fail —
confirms the request tracking works, then re-apply
@@ -0,0 +1,48 @@
# Phase 03 — Security headers on every location (audit M-4)
**Story:** n/a
**Context:** `.agents/PLAN.md` §3 (serving + edge), `.agents/remediation_plan.md`
findings M-4 (headers) and L-2 (`server_tokens`, included as a one-liner in
the same file)
## Objective
Fix the nginx `add_header` inheritance trap: the `location = /index.html`
and the hashed-asset location each define their own cache `add_header`
directives, so they **lose** the server-level security headers (CSP,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy). Extract the four
security headers into a snippet and `include` it in **every** location, so
every response class carries the full header set. Also set
`server_tokens off;` (L-2) in the same pass.
## Dependencies
— (none)
## Tasks
1. `01_extract_header_snippet.md` — Create `nginx-security-headers.inc`, wire it into the Dockerfile, and `include` it in the server block and both header-defining locations (plus `server_tokens off;`).
2. `02_add_header_verification.md` — Add `scripts/check-headers.sh` that builds and runs the real image and asserts all four headers on `/`, `/index.html`, and a hashed asset.
## Testing & Quality
- Unit/integration: the container-based header check (task 02) is the
integration test. It must assert all four headers on each of the three
response classes.
- Coverage: **>90%** on new/modified config (all three location paths
exercised by the check script).
- The Playwright E2E suite is unaffected (its webServer is plain python,
not nginx) and must stay green.
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists and holds the four `add_header …
always;` lines
- [ ] `nginx.conf` has **no** inline security `add_header` lines (only the
`include`), and both header-defining locations `include` the snippet
- [ ] `server_tokens off;` present in the `server` block
- [ ] `scripts/check-headers.sh` runs the real image and prints
`HEADER CHECK PASSED` (all four headers on `/`, `/index.html`, and a
hashed asset)
- [ ] `./build.sh && npm test` fully green
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
**Out of scope (separate, unphased):** L-1 HSTS — that is an **edge/Caddy**
responsibility (the container serves plain HTTP on 8080). Verify at the edge
manually: `curl -sI https://reeseapps.com/ | grep -i strict-transport`. Do
**not** add HSTS inside the 8080 container.
@@ -0,0 +1,96 @@
# Task 01 — Extract header snippet and include it in every location
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Move the four security headers into a shared snippet and `include` it in
**every** location, so no location can silently drop them by defining its own
`add_header` (the nginx inheritance trap, audit M-4). Add `server_tokens off;`
(audit L-2) in the same pass.
## Work
1. Create `nginx-security-headers.inc` (repo root, next to `nginx.conf`) with
exactly:
```
# Shared security headers — included by EVERY nginx location.
# A location that defines its own add_header inherits none from the
# server block, so each location includes this snippet directly.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
```
2. `Dockerfile` — create the snippets dir and copy the file in. Change the
existing `RUN rm … && apk add …` line to also `mkdir -p /etc/nginx/snippets`,
and add a COPY right before the `COPY nginx.conf …` line:
```dockerfile
RUN rm /etc/nginx/conf.d/default.conf && \
apk add --no-cache coreutils && \
mkdir -p /etc/nginx/snippets
COPY nginx-security-headers.inc /etc/nginx/snippets/security-headers.inc
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
3. `nginx.conf` — rewrite to the exact content below. Key changes: the four
inline `add_header` lines are gone from the `server` block; `server_tokens
off;` is added; each of the three `location` blocks `include`s the snippet
(so no location relies on header inheritance):
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hide the nginx version in the Server header (audit L-2)
server_tokens off;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 256;
gzip_vary on;
# Never cache index.html so the latest HTML is always served
location = /index.html {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Cache hashed assets forever (filename contains content hash)
location ~* \.(css|js|jpeg|jpg|png|gif|ico|svg|woff2?)$ {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
include /etc/nginx/snippets/security-headers.inc;
try_files $uri $uri/ /index.html;
}
# Custom error pages
error_page 404 /index.html;
}
```
## Testing & Quality
- Unit/integration: config correctness is proven by the container check in
task 02 (headers present on all three response classes) and by `nginx -t`
(valid syntax, run implicitly by the container starting).
- Coverage: **>90%** on the modified config (all three locations exercised).
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists with the four `add_header … always;`
lines
- [ ] `nginx.conf` contains **no** inline security `add_header` lines and has
`include /etc/nginx/snippets/security-headers.inc;` in all three
locations, plus `server_tokens off;`
- [ ] `Dockerfile` copies the snippet into the image
- [ ] `./build.sh && npm test` still fully green (Playwright is unaffected)
@@ -0,0 +1,87 @@
# Task 02 — Add container-level header verification
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Add a repeatable, container-based verification that the **real** image serves
all four security headers on every response class (`/`, `/index.html`, and a
hashed asset). This is the integration test for the M-4 fix and guards against
future nginx config regressions.
## Work
1. Create `scripts/check-headers.sh` (repo root; `mkdir -p scripts` first)
with exactly:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Verify every response class (/, /index.html, a hashed asset) carries the
# full security-header set, using the REAL nginx image on a scratch port.
# Requires a container runtime (podman or docker).
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if command -v podman >/dev/null 2>&1; then RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then RUNTIME=docker
else echo "ERROR: neither podman nor docker found" >&2; exit 2; fi
IMAGE="homepage-headercheck-$$"
PORT=18099
CID=""
trap 'if [ -n "$CID" ]; then $RUNTIME rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT
echo ">> building image ($RUNTIME)..."
$RUNTIME build -q -t "$IMAGE" .
echo ">> starting container on 127.0.0.1:$PORT..."
CID="$($RUNTIME run -d --rm -p 127.0.0.1:$PORT:8080 "$IMAGE")"
for i in $(seq 1 30); do
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
sleep 0.5
if [ "$i" -eq 30 ]; then echo "ERROR: container not ready" >&2; exit 1; fi
done
ASSET="$(curl -s "http://127.0.0.1:$PORT/" \
| grep -oE '(style|script|terminal)[a-z-]*\.[0-9a-f]{32}\.(css|js)' | head -n1 || true)"
if [ -z "$ASSET" ]; then echo "ERROR: no hashed asset found in index.html" >&2; exit 1; fi
fail=0
check_headers() {
local path="$1" hdrs h
hdrs="$(curl -sI "http://127.0.0.1:$PORT$path")"
for h in "X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy" "Content-Security-Policy"; do
if ! printf '%s' "$hdrs" | grep -qi "^${h}:"; then
echo "FAIL: ${h} missing on ${path}" >&2; fail=1
fi
done
[ "$fail" -eq 0 ] && echo "OK: all security headers present on ${path}"
}
echo ">> checking headers..."
check_headers "/"
check_headers "/index.html"
check_headers "/${ASSET}"
if [ "$fail" -ne 0 ]; then echo "HEADER CHECK FAILED" >&2; exit 1; fi
echo "HEADER CHECK PASSED"
```
Then `chmod +x scripts/check-headers.sh`.
2. Run it: `./scripts/check-headers.sh` — it must print
`HEADER CHECK PASSED` with an `OK:` line for each of the three paths.
## Testing & Quality
- Unit/integration: this script **is** the integration test — it builds the
real image, serves it, and asserts all four headers on each response class.
- Coverage: **>90%** on the modified nginx config (all three location paths
asserted).
## Completion Criteria
- [ ] `scripts/check-headers.sh` exists and is executable
- [ ] `./scripts/check-headers.sh` exits 0 and prints `HEADER CHECK PASSED`
(with `OK:` lines for `/`, `/index.html`, and the hashed asset)
- [ ] (negative check) temporarily removing one `include` from a location in
`nginx.conf` makes the script FAIL for that path — confirms the check
has teeth, then restore it
- [ ] `./build.sh && npm test` fully green