Skip to content

Commit 621fdbe

Browse files
authored
visual-artifacts v1: PR 1 static renderer and trust contract (#217)
* visual-artifacts: PR 1 static renderer and trust contract 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. * visual-artifacts: fix legacy plan render and fresh-store --out 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). * visual-artifacts: reject --out paths that escape visual/ via ".." 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. * visual-artifacts: canonicalize manifest paths under relative store 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. * visual-artifacts: reject symlinked subdirectories under visual/ 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. * visual-artifacts: reject symlinked or directory leaves at output 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. * visual-artifacts: write to normalized path and lock other contracts 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. * visual-artifacts: render does not mutate sprint session state 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. * visual-artifacts: secure temp files and glob-safe path normalization 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. * visual-artifacts: clean HTML temp under --manifest-only 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.
1 parent 90ca3d3 commit 621fdbe

8 files changed

Lines changed: 1931 additions & 2 deletions

File tree

.github/workflows/lint.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3766,3 +3766,19 @@ jobs:
37663766
exit 1
37673767
fi
37683768
echo "OK: save-artifact.sh wires the per-phase validator."
3769+
3770+
visual-artifact-contract:
3771+
# Locks the Visual Artifacts v1 PR 1 contract: bin/render-artifact.sh
3772+
# writes static HTML under $NANOSTACK_STORE/visual/, escapes every
3773+
# JSON-derived string, ships a CSP, refuses unsafe output paths, and
3774+
# records source trust in a companion manifest. See
3775+
# reference/visual-artifact-contract.md.
3776+
runs-on: ubuntu-latest
3777+
permissions:
3778+
contents: read
3779+
steps:
3780+
- uses: actions/checkout@v4
3781+
- name: Static template safety lint
3782+
run: ci/check-visual-artifact-templates.sh
3783+
- name: End-to-end render contract
3784+
run: ci/e2e-visual-artifacts.sh

bin/find-artifact.sh

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bash
22
# find-artifact.sh — Find the most recent artifact for a phase and project
3-
# Usage: find-artifact.sh <phase> [max-age-days] [--verify] [--require-integrity]
3+
# Usage: find-artifact.sh <phase> [max-age-days] [--verify] [--require-integrity] [--no-session-sync]
44
# Example: find-artifact.sh plan 2 --require-integrity
55
# Returns: path to most recent artifact, or empty + exit 1 if none found
66
#
@@ -15,6 +15,15 @@
1515
# evidence. Added in the 2026-05-10 architecture audit
1616
# PR 2 so callers stop reimplementing the check.
1717
#
18+
# Read-only flag:
19+
# --no-session-sync skip the phase-start session registration that
20+
# find-artifact.sh otherwise performs as a
21+
# convenience for downstream skills. Used by the
22+
# visual renderer (render-artifact.sh), which is a
23+
# strictly downstream consumer and must not mutate
24+
# sprint state. Added in the Visual Artifacts v1
25+
# PR 1 round (codex pass 7).
26+
#
1827
# On failure, the reason goes to stderr in a stable format so callers can
1928
# categorize: "INTEGRITY FAILED: <path>" (mismatch) or
2029
# "INTEGRITY MISSING: <path>" (no .integrity field).
@@ -32,6 +41,7 @@ shift
3241
MAX_AGE=30
3342
VERIFY=false
3443
REQUIRE_INTEGRITY=false
44+
NO_SESSION_SYNC=false
3545
# The max-age argument is optional; detect it by shape so callers can
3646
# skip it and pass a flag in $2 (e.g. find-artifact.sh plan
3747
# --require-integrity). A leading dash means flag, not age. Codex
@@ -46,6 +56,7 @@ for arg in "$@"; do
4656
case "$arg" in
4757
--verify) VERIFY=true ;;
4858
--require-integrity) REQUIRE_INTEGRITY=true; VERIFY=true ;;
59+
--no-session-sync) NO_SESSION_SYNC=true ;;
4960
esac
5061
done
5162

@@ -73,7 +84,7 @@ done | sort -r | head -1)
7384
# recurse back to find-artifact.sh and hang). The phase stays
7485
# "in_progress" until save-artifact.sh completes it.
7586
SESSION_FILE="$NANOSTACK_STORE/session.json"
76-
if [ -f "$SESSION_FILE" ]; then
87+
if [ "$NO_SESSION_SYNC" = false ] && [ -f "$SESSION_FILE" ]; then
7788
# Cache the phase list extracted from session.json. Multiple find-artifact.sh
7889
# calls in one resolve.sh run reuse the same list; session.sh writes bump
7990
# session.json's mtime which invalidates the cache automatically.

bin/lib/html-escape.sh

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
# html-escape.sh — Shared HTML escape primitives for the visual artifact
3+
# layer. Used by bin/render-artifact.sh. Centralizing the escape rules
4+
# here makes the security contract testable: ci/check-visual-artifact-
5+
# templates.sh greps for direct printf of JSON values and fails if the
6+
# escape helpers are bypassed.
7+
#
8+
# Public functions (stdin -> stdout):
9+
# nano_html_escape text content. & < > " ' -> entities. Preserves newlines.
10+
# nano_attr_escape attribute content. Same set; stricter quoting.
11+
# nano_json_string string -> JSON-encoded literal (without surrounding quotes).
12+
# Used by the manifest writer when piping shell
13+
# strings into JSON without a jq round-trip.
14+
15+
if [ "${_NANO_HTML_ESCAPE_LOADED:-0}" = "1" ]; then
16+
return 0 2>/dev/null || true
17+
fi
18+
_NANO_HTML_ESCAPE_LOADED=1
19+
20+
# Escape & < > " ' via awk. Replaces ampersand FIRST so later
21+
# replacements do not double-encode the entity prefix. Reads stdin and
22+
# writes to stdout. Newlines pass through untouched.
23+
nano_html_escape() {
24+
awk '
25+
BEGIN { OFS = "" }
26+
{
27+
gsub(/&/, "\\&amp;")
28+
gsub(/</, "\\&lt;")
29+
gsub(/>/, "\\&gt;")
30+
gsub(/"/, "\\&quot;")
31+
gsub(/\047/, "\\&#39;")
32+
print
33+
}
34+
'
35+
}
36+
37+
# Attribute content uses the same character set. Kept as a separate
38+
# function so future hardening (for example, encoding the equals sign
39+
# or backtick inside attribute context) lands in one place. Reads
40+
# stdin and writes to stdout.
41+
nano_attr_escape() {
42+
awk '
43+
BEGIN { OFS = "" }
44+
{
45+
gsub(/&/, "\\&amp;")
46+
gsub(/</, "\\&lt;")
47+
gsub(/>/, "\\&gt;")
48+
gsub(/"/, "\\&quot;")
49+
gsub(/\047/, "\\&#39;")
50+
print
51+
}
52+
'
53+
}
54+
55+
# JSON-string escape. We delegate to jq when available because jq
56+
# already implements the full RFC 8259 escape set (control characters,
57+
# \uXXXX for non-ASCII). The output includes surrounding double
58+
# quotes; callers strip them with `${var:1:-1}` when embedding inside
59+
# a larger jq filter, or use the quoted form for raw concatenation.
60+
nano_json_string() {
61+
if command -v jq >/dev/null 2>&1; then
62+
jq -Rs '.'
63+
else
64+
# Minimal fallback. Escapes the characters that break JSON strings.
65+
# Loses control-character handling beyond newline; that is
66+
# acceptable because the visual layer pipes everything through jq
67+
# when jq is on PATH, which is a Nanostack requirement enforced
68+
# elsewhere.
69+
awk '
70+
BEGIN { ORS = ""; printf "\"" }
71+
{
72+
s = $0
73+
gsub(/\\/, "\\\\", s)
74+
gsub(/"/, "\\\"", s)
75+
gsub(/\t/, "\\t", s)
76+
printf "%s", s
77+
printf "\\n"
78+
}
79+
END { printf "\"\n" }
80+
'
81+
fi
82+
}

0 commit comments

Comments
 (0)