Skip to content

visual-artifacts v1: PR 1 static renderer and trust contract#217

Merged
garagon merged 10 commits into
mainfrom
visual-artifacts-v1-pr1-static-renderer
May 11, 2026
Merged

visual-artifacts v1: PR 1 static renderer and trust contract#217
garagon merged 10 commits into
mainfrom
visual-artifacts-v1-pr1-static-renderer

Conversation

@garagon
Copy link
Copy Markdown
Owner

@garagon garagon commented May 11, 2026

Summary

PR 1 of the Visual Artifacts v1 round (architect spec, 2026-05-11). Introduces a local, static HTML renderer for Nanostack JSON artifacts. JSON remains canonical; the renderer is strictly downstream and writes only under $NANOSTACK_STORE/visual/. PR 1 wires the /plan renderer end to end; the other core phases are reserved for PR 2.

What changed

  • reference/visual-artifact-contract.md — normative contract: CLI shape, exit codes, store layout, manifest schema (schema_version: "1"), HTML safety rules, locked trust badge wording (verified / unverified / tampered).
  • bin/lib/html-escape.shnano_html_escape, nano_attr_escape, nano_json_string. Every JSON-derived scalar passes through one of these before reaching HTML.
  • bin/lib/visual-render.sh — shared page shell, CSP, output path safety, lexical path normalization, symlinked-descend rejection.
  • bin/render-artifact.sh — argument parser, source resolution via bin/find-artifact.sh --no-session-sync, trust verification via bin/lib/artifact-trust.sh, schema validation via bin/lib/artifact-schemas.sh, manifest writer, /plan body renderer. Atomic write via mktemp + rename.
  • bin/find-artifact.sh — adds --no-session-sync flag so a strictly read-only consumer can resolve --latest without mutating session.json.
  • ci/e2e-visual-artifacts.sh — 86 checks across 21 cells.
  • ci/check-visual-artifact-templates.sh — 20 static checks (forbidden patterns, required markers).
  • .github/workflows/lint.yml — new visual-artifact-contract job.

Contract invariants

  1. JSON is canonical. No skill reads HTML as source evidence.
  2. Output paths live under $NANOSTACK_STORE/visual/. Outside paths exit 4.
  3. integrity_mismatch always fails (exit 3). integrity_missing fails under --strict; without --strict it renders with the unverified badge.
  4. Every JSON-derived string passes through nano_html_escape or nano_attr_escape.
  5. CSP default-src 'none'. No external URLs, no fetch, no localStorage, no eval.
  6. --interactive reserved for PR 4 (exit 2). journal / stack reserved for PR 3 (exit 2). think/review/security/qa/ship reserved for PR 2 (exit 1).

Path-safety hardening (codex passes 2 – 8)

Pass Finding Fix
2 .. after a missing segment escaped visual/ Lexical normalization replaces realpath walk-up
3 Relative NANOSTACK_STORE produced relative manifest paths nano_resolve_abs canonicalizes before manifest write
4 Symlinked subdirectory under visual/ accepted nano_visual_assert_safe_descend rejects symlinked components
5 Symlinked or directory leaf at --out accepted Leaf check at end of descend
6 Symlink + .. collapsed lexically but kernel resolved at write Reassign HTML_PATH / MANIFEST_PATH to normalized form
6 Same-second renders shared a manifest stem PID suffix in timestamp
6 Non-object JSON ([], string, number) crashed with jq exit 5 .phase? suppresses path error so input-error branch returns exit 1
7 --latest mutated session.json via find-artifact.sh --no-session-sync flag on find-artifact.sh
8 Predictable temp filename ($path.tmp.$$) mktemp $path.tmp.XXXXXX with O_EXCL
8 Glob expansion in path normalization set -f around set -- $raw split
9 --manifest-only left HTML temp behind Explicit rm -f $TMP_HTML before trap reset

Final codex pass returned clean.

Test counts

  • 86 checks in ci/e2e-visual-artifacts.sh across 21 cells.
  • 20 checks in ci/check-visual-artifact-templates.sh.
  • Both wired into a new visual-artifact-contract job in lint.yml.

Test plan

  • ci/e2e-visual-artifacts.sh 86/86
  • ci/check-visual-artifact-templates.sh 20/20
  • ci/e2e-artifact-trust.sh 29/29 (no regression on bin/find-artifact.sh)
  • python3 -c "import yaml; yaml.safe_load(open('.github/workflows/lint.yml'))" passes
  • Codex review pass 10 returned clean

garagon added 10 commits May 11, 2026 14:59
Introduce bin/render-artifact.sh and the supporting libraries that
turn a Nanostack JSON artifact into a local, static HTML view under
$NANOSTACK_STORE/visual/. JSON remains canonical; the renderer is
strictly downstream and writes only to the visual root.

PR 1 wires the /plan renderer end to end. think, review, security,
qa, ship are reserved for PR 2 and exit 1 with a clear message;
journal and stack are reserved for PR 3 and exit 2; --interactive is
reserved for PR 4 and exits 2.

Key files:
- reference/visual-artifact-contract.md: normative contract.
- bin/lib/html-escape.sh: nano_html_escape, nano_attr_escape,
  nano_json_string. Every JSON-derived scalar passes through one of
  these before reaching HTML.
- bin/lib/visual-render.sh: shared page shell, CSP, locked trust
  badge wording (verified / unverified / tampered), output path
  safety, symlinked visual root rejection.
- bin/render-artifact.sh: argument parsing, source resolution via
  bin/find-artifact.sh, trust verification via
  bin/lib/artifact-trust.sh, schema validation via
  bin/lib/artifact-schemas.sh, manifest writer, /plan body renderer.
  Atomic write with $path.tmp.$$ rename.
- ci/e2e-visual-artifacts.sh: 49 checks across 9 cells (happy path,
  XSS, --strict integrity_missing, integrity_mismatch always fails,
  --out path safety, reserved features, --manifest-only, phase
  mismatch, symlinked visual root).
- ci/check-visual-artifact-templates.sh: 20 static checks for
  forbidden patterns (http://, https://, fetch, XMLHttpRequest,
  localStorage, document.cookie, eval) plus required markers (CSP,
  data-nanostack-visual, locked badge wording).
- .github/workflows/lint.yml: new visual-artifact-contract job that
  runs both checks on every push.

Trust contract:
- integrity_mismatch always fails with exit 3.
- integrity_missing fails under --strict (exit 3); without --strict
  the render proceeds and the badge shows 'unverified'.
- --out outside the visual root fails with exit 4.
- A symlinked visual root fails with exit 4.

Total: 69 contract checks locked in CI.
Two PR 1 pass 1 codex findings.

1. Legacy --from-session plan artifacts store .summary as a string,
   so jq -r '.summary.goal' aborted the body renderer under set -e.
   The schema warning was already emitted in the page head, but the
   body never reached the user. Normalize .summary and
   .context_checkpoint into objects at the top of render_plan_body
   (defaulting missing arrays to empty arrays), then read every
   field from the normalized JSON. Adds two regression cells:
   --from-session legacy plan + an artifact with both summary and
   context_checkpoint as strings.

2. --out under $NANOSTACK_STORE/visual was rejected on fresh stores
   because the visual root did not yet exist; the canonical
   walk-up landed on $NANOSTACK_STORE, which is outside the visual
   root. Pre-create the visual root before the safety check so
   realpath has a stable target. Adds a regression cell that
   confirms --out works when visual/ does not pre-exist.

Test count: 49 -> 59 (cells 9a, 9b cover the regressions).
Codex PR 1 pass 2 caught a path-safety gap. A path like
$NANOSTACK_STORE/visual/new/../../outside.html passed the previous
"walk up to nearest existing ancestor" check because the 'new'
segment was missing on disk; the walk landed on visual/, the prefix
matched, and the final mv wrote to $NANOSTACK_STORE/outside.html
outside the visual root.

Replace the realpath walk-up with lexical normalization. The new
nano_visual_normalize_path resolves "." and ".." string-wise so
non-existent intermediate segments do not anchor the comparison.
Both the candidate path and the visual root are normalized
lexically; symlink protection on the visual root itself is still
provided by nano_visual_assert_safe_root.

Adds a regression cell that exercises the documented escape and a
check that no file is left behind outside visual/ after the
rejection.

Test count: 59 -> 61.
Codex PR 1 pass 3 finding. A relative NANOSTACK_STORE override
(e.g. NANOSTACK_STORE=.nano-rel) propagated into the renderer's
output_path, source path, and stdout. The visual artifact contract
requires output_path to be absolute, so the manifest emitted under
a relative store violated the contract.

After mkdir -p of the parent directories (so $(cd .. && pwd) has a
real target), resolve HTML_PATH, MANIFEST_PATH, and ART_PATH to
absolute via a small nano_resolve_abs helper. The fix runs late
enough that the path-safety check still operates on the caller's
literal --out string, and the manifest writer sees only absolutes.

Adds cell 9d: chdir to a fresh project, set NANOSTACK_STORE to a
relative ".nano-rel", render, and assert the stdout, manifest
output_path, and manifest source path are all absolute.

Test count: 61 -> 64.
Codex PR 1 pass 4 finding. A pre-existing symlink under visual/
(for example visual/plan -> /tmp/outside) was accepted by mkdir -p
and the renderer's atomic mv then wrote the HTML or manifest into
the symlink target. nano_visual_assert_safe_root only guarded the
root itself, so the new path-safety contract was incomplete.

Add nano_visual_assert_safe_descend: walk from the visual root down
to (but not including) the leaf file, asserting -L is false at every
intermediate. render-artifact.sh calls it for both the HTML path and
the manifest path before mkdir -p runs.

Adds two regression cells: a symlinked visual/plan and a symlinked
visual/manifests, each confirming exit 4 and no file written
through the symlink target.

Test count: 64 -> 68.
Codex PR 1 pass 5 finding. The descend check walked every
directory component but stopped before the leaf, so an --out whose
final segment was a pre-existing symlink to a directory escaped
the path-safety contract: the atomic mv moved the temp file INTO
the symlink target instead of overwriting the link.

Extend nano_visual_assert_safe_descend to refuse leaves that are
symlinks (return 4) or directories (return 4). The renderer writes
a regular file at that path; symlinks and directories at the leaf
were never part of the contract.

Adds cell 9g: a symlinked leaf at --out and a directory at the
--out path. Both must exit 4 and leave no file in the symlink
target.

Test count: 68 -> 71.
Three findings from codex PR 1 pass 6.

P1 — write to the normalized path
A path like --out $NANOSTACK_STORE/visual/link/../evil.html with
link a symlink to /tmp/outside collapsed lexically to visual/
evil.html and passed the safety check. The kernel still resolved
the original path at write time: it followed link to /tmp/outside,
took .. back to /tmp/, and wrote evil.html outside the visual root.
Reassign HTML_PATH and MANIFEST_PATH to their normalized form
before mkdir -p / mv so the kernel never traverses a `..` after a
symlinked component.

P2 — unique manifest stem per render
Two same-second renders shared a manifest stem, so the second
render overwrote the first manifest while the first HTML kept
pointing at the now-stale path. Append the renderer's PID to the
timestamp stem; each render-artifact.sh invocation is its own
process so the stem is unique across same-second invocations.

P3 — non-object JSON exits 1
A top-level array, string, or number artifact crashed jq -r
'.phase // ""' with exit 5 under set -e, violating the CLI
contract (exit 1 for input errors). Switch to `.phase?` so the
path error is suppressed and the existing phase-mismatch branch
returns exit 1 cleanly.

Adds three regression cells covering the symlink+.. bypass, the
same-second manifest uniqueness, and the three non-object JSON
shapes (array, string, number).

Test count: 71 -> 79.
Codex PR 1 pass 7 finding. find-artifact.sh registers the producing
phase via session.sh phase-start as a convenience for downstream
skills; render-artifact.sh hitting that code path through --latest
made a strictly-downstream viewer mutate session.json. A user who
opened the latest plan as HTML would silently start a plan phase.

Add --no-session-sync to find-artifact.sh. The flag bypasses the
phase-start side effect while preserving every other behavior
(integrity checks, max-age filter, project matching).
render-artifact.sh now uses --no-session-sync on its --latest
lookup, restoring the read-only contract documented in
reference/visual-artifact-contract.md.

Adds cell 9l: start a session, snapshot phase_log, run a render,
assert phase_log is unchanged and plan is not flagged in_progress.

Test count: 79 -> 81.
Codex PR 1 pass 8. Two findings.

P2 — predictable temp file
Temp filenames followed a guessable pattern ($HTML_PATH.tmp.<pid>).
An attacker with write access to a parent directory could
pre-create a symlink at that path, and bash's > redirect would
follow the symlink and write outside visual/.

Replace the manual temp naming with mktemp("$path.tmp.XXXXXX").
mktemp opens with O_EXCL so a pre-existing symlink races into a
clear failure (exit 4) instead of silent follow. The cleanup trap
keeps unlinking on early exit. Cell 9n verifies no .tmp.* leftover
after a successful render.

P3 — glob expansion during normalization
The IFS split inside nano_visual_normalize_path and
nano_visual_assert_safe_descend used an unquoted `set -- $raw`,
which performs pathname expansion against the current working
directory. An --out like "star*.html" could be silently rewritten
to a matching real filename, so the renderer wrote to a different
path than the caller asked for and the manifest recorded the wrong
output_path.

Save the current `set -f` state, disable globbing for the split,
and restore the previous state when the helpers return. Cell 9m
locks the contract: a literal glob path stays literal.

Test count: 81 -> 85.
Codex PR 1 pass 9. After the PR 1 pass 8 switch to mktemp for the
temp files, the --manifest-only branch moved the manifest into
place and disabled the cleanup trap, leaving the HTML temp file
behind. The intent of --manifest-only is "write no HTML artifacts",
so the leftover violated the contract and would accumulate stale
*.html.tmp.* files under visual/plan/ on every CI trust-check run.

Remove TMP_HTML explicitly in the --manifest-only branch before
disabling the trap. Cell 7 now also asserts zero *.tmp.* files
remain after a --manifest-only render.

Test count: 85 -> 86.
@garagon garagon merged commit 621fdbe into main May 11, 2026
62 checks passed
@garagon garagon deleted the visual-artifacts-v1-pr1-static-renderer branch May 11, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant