diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e75a085..a355eb2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3766,3 +3766,19 @@ jobs: exit 1 fi echo "OK: save-artifact.sh wires the per-phase validator." + + visual-artifact-contract: + # Locks the Visual Artifacts v1 PR 1 contract: bin/render-artifact.sh + # writes static HTML under $NANOSTACK_STORE/visual/, escapes every + # JSON-derived string, ships a CSP, refuses unsafe output paths, and + # records source trust in a companion manifest. See + # reference/visual-artifact-contract.md. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Static template safety lint + run: ci/check-visual-artifact-templates.sh + - name: End-to-end render contract + run: ci/e2e-visual-artifacts.sh diff --git a/bin/find-artifact.sh b/bin/find-artifact.sh index c64ccc0..7f6a8c1 100755 --- a/bin/find-artifact.sh +++ b/bin/find-artifact.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # find-artifact.sh — Find the most recent artifact for a phase and project -# Usage: find-artifact.sh [max-age-days] [--verify] [--require-integrity] +# Usage: find-artifact.sh [max-age-days] [--verify] [--require-integrity] [--no-session-sync] # Example: find-artifact.sh plan 2 --require-integrity # Returns: path to most recent artifact, or empty + exit 1 if none found # @@ -15,6 +15,15 @@ # evidence. Added in the 2026-05-10 architecture audit # PR 2 so callers stop reimplementing the check. # +# Read-only flag: +# --no-session-sync skip the phase-start session registration that +# find-artifact.sh otherwise performs as a +# convenience for downstream skills. Used by the +# visual renderer (render-artifact.sh), which is a +# strictly downstream consumer and must not mutate +# sprint state. Added in the Visual Artifacts v1 +# PR 1 round (codex pass 7). +# # On failure, the reason goes to stderr in a stable format so callers can # categorize: "INTEGRITY FAILED: " (mismatch) or # "INTEGRITY MISSING: " (no .integrity field). @@ -32,6 +41,7 @@ shift MAX_AGE=30 VERIFY=false REQUIRE_INTEGRITY=false +NO_SESSION_SYNC=false # The max-age argument is optional; detect it by shape so callers can # skip it and pass a flag in $2 (e.g. find-artifact.sh plan # --require-integrity). A leading dash means flag, not age. Codex @@ -46,6 +56,7 @@ for arg in "$@"; do case "$arg" in --verify) VERIFY=true ;; --require-integrity) REQUIRE_INTEGRITY=true; VERIFY=true ;; + --no-session-sync) NO_SESSION_SYNC=true ;; esac done @@ -73,7 +84,7 @@ done | sort -r | head -1) # recurse back to find-artifact.sh and hang). The phase stays # "in_progress" until save-artifact.sh completes it. SESSION_FILE="$NANOSTACK_STORE/session.json" -if [ -f "$SESSION_FILE" ]; then +if [ "$NO_SESSION_SYNC" = false ] && [ -f "$SESSION_FILE" ]; then # Cache the phase list extracted from session.json. Multiple find-artifact.sh # calls in one resolve.sh run reuse the same list; session.sh writes bump # session.json's mtime which invalidates the cache automatically. diff --git a/bin/lib/html-escape.sh b/bin/lib/html-escape.sh new file mode 100755 index 0000000..697ccf7 --- /dev/null +++ b/bin/lib/html-escape.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# html-escape.sh — Shared HTML escape primitives for the visual artifact +# layer. Used by bin/render-artifact.sh. Centralizing the escape rules +# here makes the security contract testable: ci/check-visual-artifact- +# templates.sh greps for direct printf of JSON values and fails if the +# escape helpers are bypassed. +# +# Public functions (stdin -> stdout): +# nano_html_escape text content. & < > " ' -> entities. Preserves newlines. +# nano_attr_escape attribute content. Same set; stricter quoting. +# nano_json_string string -> JSON-encoded literal (without surrounding quotes). +# Used by the manifest writer when piping shell +# strings into JSON without a jq round-trip. + +if [ "${_NANO_HTML_ESCAPE_LOADED:-0}" = "1" ]; then + return 0 2>/dev/null || true +fi +_NANO_HTML_ESCAPE_LOADED=1 + +# Escape & < > " ' via awk. Replaces ampersand FIRST so later +# replacements do not double-encode the entity prefix. Reads stdin and +# writes to stdout. Newlines pass through untouched. +nano_html_escape() { + awk ' + BEGIN { OFS = "" } + { + gsub(/&/, "\\&") + gsub(//, "\\>") + gsub(/"/, "\\"") + gsub(/\047/, "\\'") + print + } + ' +} + +# Attribute content uses the same character set. Kept as a separate +# function so future hardening (for example, encoding the equals sign +# or backtick inside attribute context) lands in one place. Reads +# stdin and writes to stdout. +nano_attr_escape() { + awk ' + BEGIN { OFS = "" } + { + gsub(/&/, "\\&") + gsub(//, "\\>") + gsub(/"/, "\\"") + gsub(/\047/, "\\'") + print + } + ' +} + +# JSON-string escape. We delegate to jq when available because jq +# already implements the full RFC 8259 escape set (control characters, +# \uXXXX for non-ASCII). The output includes surrounding double +# quotes; callers strip them with `${var:1:-1}` when embedding inside +# a larger jq filter, or use the quoted form for raw concatenation. +nano_json_string() { + if command -v jq >/dev/null 2>&1; then + jq -Rs '.' + else + # Minimal fallback. Escapes the characters that break JSON strings. + # Loses control-character handling beyond newline; that is + # acceptable because the visual layer pipes everything through jq + # when jq is on PATH, which is a Nanostack requirement enforced + # elsewhere. + awk ' + BEGIN { ORS = ""; printf "\"" } + { + s = $0 + gsub(/\\/, "\\\\", s) + gsub(/"/, "\\\"", s) + gsub(/\t/, "\\t", s) + printf "%s", s + printf "\\n" + } + END { printf "\"\n" } + ' + fi +} diff --git a/bin/lib/visual-render.sh b/bin/lib/visual-render.sh new file mode 100644 index 0000000..3cdbbf3 --- /dev/null +++ b/bin/lib/visual-render.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +# visual-render.sh — Shared page shell, CSP, trust badges, and output +# path safety for the visual artifact layer. Centralizing this here +# means every phase renderer in bin/render-artifact.sh gets the same +# CSS, the same security headers, and the same locked trust wording. +# Without a shared shell each phase would drift, and the +# ci/check-visual-artifact-templates.sh forbidden-pattern sweep would +# have to grep across many files. +# +# Public functions: +# nano_visual_root echo $NANOSTACK_STORE/visual +# nano_visual_output_dir echo phase output directory +# nano_visual_manifest_path +# echo manifest path for a render +# nano_visual_html_path +# echo HTML path for a phase render +# nano_visual_timestamp echo a deterministic YYYYMMDD-HHMMSS +# nano_visual_assert_safe_output exit 4 if path escapes the visual root +# nano_visual_assert_safe_root exit 4 if visual/ is a symlink +# nano_visual_csp echo the CSP value +# nano_visual_trust_badge_text echo the locked badge wording +# nano_visual_page_start emit the page shell start +# nano_visual_page_end +# emit the provenance footer + close +# +# Every renderer writes through these helpers; phase-specific bodies +# render between page_start and page_end. + +if [ "${_NANO_VISUAL_RENDER_LOADED:-0}" = "1" ]; then + return 0 2>/dev/null || true +fi +_NANO_VISUAL_RENDER_LOADED=1 + +if [ -z "${NANOSTACK_STORE:-}" ]; then + echo "visual-render.sh: NANOSTACK_STORE not set; source bin/lib/store-path.sh first" >&2 + return 1 2>/dev/null || exit 1 +fi + +source "$(dirname "${BASH_SOURCE[0]}")/html-escape.sh" + +NANO_VISUAL_RENDERER_NAME="nanostack-html-renderer" +NANO_VISUAL_RENDERER_VERSION="1" + +nano_visual_root() { + printf '%s\n' "$NANOSTACK_STORE/visual" +} + +nano_visual_output_dir() { + local phase="$1" + local custom="${2:-false}" + if [ "$custom" = "true" ]; then + printf '%s\n' "$NANOSTACK_STORE/visual/custom/$phase" + else + printf '%s\n' "$NANOSTACK_STORE/visual/$phase" + fi +} + +nano_visual_timestamp() { + # PID suffix prevents two same-second renders from colliding on the + # manifest stem. Codex PR 1 pass 6 caught the contract violation: + # the second render overwrote the first manifest while the first + # HTML kept pointing at the now-stale path. + printf '%s-%s\n' "$(date -u +%Y%m%d-%H%M%S)" "$$" +} + +nano_visual_html_path() { + local phase="$1" + local timestamp="$2" + local custom="${3:-false}" + printf '%s/%s-%s.html\n' \ + "$(nano_visual_output_dir "$phase" "$custom")" \ + "$timestamp" \ + "$phase" +} + +nano_visual_manifest_path() { + local kind="$1" # phase | journal | stack + local phase="$2" + local timestamp="$3" + local custom="${4:-false}" + local stem + if [ "$custom" = "true" ]; then + stem="$timestamp-custom-$phase" + else + stem="$timestamp-$phase" + fi + printf '%s/manifests/%s.manifest.json\n' \ + "$NANOSTACK_STORE/visual" \ + "$stem" +} + +# Refuse to follow a symlinked visual root. The renderer otherwise +# would write to whatever the symlink resolves to, which can escape +# the store. The contract requires the visual root be a plain +# directory (or absent, in which case we mkdir it ourselves). +nano_visual_assert_safe_root() { + local root + root="$(nano_visual_root)" + if [ -L "$root" ]; then + echo "render-artifact: refusing to write under symlinked visual root: $root" >&2 + return 4 + fi + return 0 +} + +# Refuse to write into any subdirectory under visual/ that is a +# symlink. Codex PR 1 pass 4 caught this: if visual/plan was already +# a symlink to /tmp/outside, mkdir -p accepted it and the later +# atomic mv wrote into the target, escaping the visual root despite +# the lexical path-safety check on --out. Walk from the visual root +# down to (but not including) the leaf file, asserting -L is false at +# every existing intermediate. +# +# Called after nano_visual_normalize_path has rewritten the candidate +# path, so the input is already absolute and ".." is resolved. +nano_visual_assert_safe_descend() { + local path="$1" # absolute, normalized + local root + root="$(nano_visual_normalize_path "$(nano_visual_root)")" + # Require the path to live under the canonical root. The caller + # already verified this via nano_visual_assert_safe_output for --out; + # we re-check for safety in case this helper is reused later. + case "$path" in + "$root"|"$root"/*) ;; + *) + echo "render-artifact: refusing to write outside $root: $path" >&2 + return 4 + ;; + esac + local rel="${path#"$root"}" + rel="${rel#/}" + local current="$root" + if [ -L "$current" ]; then + echo "render-artifact: visual root is a symlink: $current" >&2 + return 4 + fi + # Disable globbing for the split (codex PR 1 pass 8). Same risk + # as nano_visual_normalize_path: an unquoted set -- expands * and + # ? against the cwd, which could mask symlink components from the + # check. + local restore_glob=0 + case $- in *f*) ;; *) restore_glob=1; set -f ;; esac + local IFS=/ + # shellcheck disable=SC2086 + set -- $rel + local part + # The last component is the leaf file. Drop it so we only check + # directory components (intermediate dirs can be symlinks; the + # file itself is created by the renderer's atomic move). + local count=$# + local i=0 + for part in "$@"; do + i=$((i+1)) + [ "$i" = "$count" ] && break + current="$current/$part" + if [ -L "$current" ]; then + echo "render-artifact: refusing to descend into symlinked subdirectory: $current" >&2 + [ "$restore_glob" -eq 1 ] && set +f + return 4 + fi + done + [ "$restore_glob" -eq 1 ] && set +f + # Leaf check. Codex PR 1 pass 5 caught the gap: if the leaf itself + # is a symlink to a directory, the later atomic mv moves the temp + # file INTO the link target instead of overwriting the link, so + # the render escapes the visual root despite every directory + # component being safe. Refuse symlinks and directories at the + # leaf; the contract is to write a regular file at that path. + if [ -L "$path" ]; then + echo "render-artifact: refusing to overwrite symlinked output leaf: $path" >&2 + return 4 + fi + if [ -d "$path" ]; then + echo "render-artifact: refusing to overwrite directory at output leaf: $path" >&2 + return 4 + fi + return 0 +} + +# Lexically normalize an absolute path: resolve "." and ".." components +# without touching the filesystem. This defeats --out escapes through +# missing directory segments followed by ".." (codex caught the gap +# on PR 1 pass 2: a path like .../visual/new/../../outside.html passed +# the previous "walk up to nearest existing ancestor" check because +# the missing 'new' segment never appeared on disk for realpath to +# resolve, leaving the comparison anchored at visual/). +# +# This implementation is pure shell so it runs on the same Bash 3.2 +# that ships with macOS without depending on `realpath -m`. +nano_visual_normalize_path() { + local raw="$1" + case "$raw" in + /*) ;; + *) raw="$PWD/$raw" ;; + esac + # Disable globbing for the split. Codex PR 1 pass 8 caught that an + # unquoted `set -- $raw` performs pathname expansion against the + # current working directory; an --out containing `*` or `?` could + # be silently rewritten to a matching real filename, producing a + # manifest output_path that disagrees with the caller's request. + local restore_glob=0 + case $- in *f*) ;; *) restore_glob=1; set -f ;; esac + local out="" + local IFS=/ + # shellcheck disable=SC2086 + set -- $raw + local part + for part in "$@"; do + case "$part" in + ""|.) ;; + ..) + # Pop the last segment from $out if any. + if [ -n "$out" ]; then + out="${out%/*}" + fi + ;; + *) + out="$out/$part" + ;; + esac + done + [ "$restore_glob" -eq 1 ] && set +f + [ -z "$out" ] && out="/" + printf '%s\n' "$out" +} + +# Refuse output paths that escape the visual root after lexical +# normalization. The visual root is canonicalized through realpath +# when present (so a real-but-non-symlinked root resolves cleanly); +# the caller path is normalized lexically because it may include +# directories that do not exist yet. +nano_visual_assert_safe_output() { + local path="$1" + local root + root="$(nano_visual_root)" + case "$path" in + /*) ;; + *) + echo "render-artifact: --out must be an absolute path: $path" >&2 + return 4 + ;; + esac + + # Both root and path are normalized lexically so a symlinked + # filesystem path (for example /tmp -> /private/tmp on macOS) does + # not produce a false mismatch: realpath would expand the root and + # not the not-yet-existing path, and the prefix check would fail. + # The renderer's threat model treats the visual root's symlink + # status as the one filesystem property worth checking + # (nano_visual_assert_safe_root); deeper symlink chains are out of + # scope for the path-safety check, which is purely about defeating + # ".." escape and absolute-path misrouting in --out. + local path_canon root_canon + path_canon="$(nano_visual_normalize_path "$path")" + root_canon="$(nano_visual_normalize_path "$root")" + + case "$path_canon" in + "$root_canon"/*) return 0 ;; + *) + echo "render-artifact: refusing to write outside $root_canon: $path (normalized: $path_canon)" >&2 + return 4 + ;; + esac +} + +nano_visual_csp() { + local mode="${1:-static}" + case "$mode" in + interactive) + # Reserved for PR 4. Inline script will be enabled here only when + # the copy-only contract is verified. Until then, callers in + # PR 1 must request "static". + printf "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\n" + ;; + *) + printf "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\n" + ;; + esac +} + +# Locked trust badge wording. CI greps for these exact strings. +nano_visual_trust_badge_text() { + local status="${1:-not_found}" + case "$status" in + verified) printf 'verified\n' ;; + integrity_missing) printf 'unverified\n' ;; + integrity_mismatch) printf 'tampered\n' ;; + *) printf 'unknown\n' ;; + esac +} + +# Page shell: doctype, head with CSP and CSS, hero header with trust +# badge. The phase body follows this and nano_visual_page_end closes. +nano_visual_page_start() { + local phase="$1" + local trust="$2" + local csp_mode="${3:-static}" + local custom="${4:-false}" + + local phase_esc trust_esc badge_text title_phase + phase_esc="$(printf '%s' "$phase" | nano_attr_escape)" + trust_esc="$(printf '%s' "$trust" | nano_attr_escape)" + badge_text="$(nano_visual_trust_badge_text "$trust")" + if [ "$custom" = "true" ]; then + title_phase="$phase (custom)" + else + title_phase="/$phase" + fi + local title_esc + title_esc="$(printf '%s' "$title_phase" | nano_html_escape)" + + cat < + + + + + + Nanostack $title_esc visual artifact + + + +
+
+

Nanostack visual artifact

+

$title_esc

+

$(printf '%s' "$badge_text" | nano_html_escape)

+
+HTML +} + +# Closes the page with provenance pointing back to the source artifact +# and the companion manifest. Every render must call this so the +# audit trail is locked in HTML. +nano_visual_page_end() { + local source_path="$1" + local manifest_path="$2" + local integrity="${3:-}" + + local src_esc mfst_esc integ_esc + src_esc="$(printf '%s' "$source_path" | nano_html_escape)" + mfst_esc="$(printf '%s' "$manifest_path" | nano_html_escape)" + if [ -n "$integrity" ]; then + integ_esc="$(printf '%s' "$integrity" | nano_html_escape)" + else + integ_esc="not recorded" + fi + + cat < +

Source artifact: $src_esc

+

Manifest: $mfst_esc

+

Source integrity (SHA-256): $integ_esc

+

Renderer: $NANO_VISUAL_RENDERER_NAME v$NANO_VISUAL_RENDERER_VERSION

+ +
+ + +HTML +} diff --git a/bin/render-artifact.sh b/bin/render-artifact.sh new file mode 100755 index 0000000..1e01cee --- /dev/null +++ b/bin/render-artifact.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash +# render-artifact.sh — Render a Nanostack JSON artifact as a static +# HTML view under $NANOSTACK_STORE/visual/. JSON remains canonical; +# this script is strictly downstream and writes only to the visual +# root. See reference/visual-artifact-contract.md for the full +# contract. +# +# Usage: +# render-artifact.sh [artifact-path|--latest] [--strict] +# [--interactive] [--out ] +# [--manifest-only] +# +# PR 1 scope: /plan renderer + manifest + safety locks. Other phases +# exit 1 with a clear "phase not yet supported" message; PR 2 wires +# think/review/security/qa/ship. journal/stack are reserved for PR 3 +# (exit 2). --interactive is reserved for PR 4 (exit 2). + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib/store-path.sh" +source "$SCRIPT_DIR/lib/html-escape.sh" +source "$SCRIPT_DIR/lib/visual-render.sh" +source "$SCRIPT_DIR/lib/artifact-trust.sh" +[ -f "$SCRIPT_DIR/lib/artifact-schemas.sh" ] && source "$SCRIPT_DIR/lib/artifact-schemas.sh" + +usage() { + cat < [artifact-path|--latest] [--strict] + [--interactive] [--out ] + [--manifest-only] + +PR 1 scope: + phase = plan render the latest or explicit /plan artifact + phase = think|review|security|qa|ship + reserved for PR 2 (exit 1) + phase = journal reserved for PR 3 (exit 2) + phase = stack reserved for PR 3 (exit 2) + +Flags: + --strict require nano_artifact_trust == verified + --interactive reserved for PR 4 (exit 2) + --out write HTML to explicit path under \$NANOSTACK_STORE/visual + --manifest-only write manifest only, skip HTML + --latest resolve via find-artifact.sh (default if no path) + +Exit codes: + 0 success + 1 input error + 2 feature reserved for a later PR + 3 trust failure + 4 unsafe output path +USAGE +} + +if [ $# -eq 0 ]; then + usage >&2 + exit 1 +fi + +PHASE="$1" +shift + +ART_PATH="" +USE_LATEST=false +STRICT=false +INTERACTIVE=false +MANIFEST_ONLY=false +OUT_PATH="" + +# Reserved phase kinds. Surface a clear message and use the contract +# exit code so CI can categorize. +case "$PHASE" in + journal) + echo "render-artifact: 'journal' is reserved for PR 3 (sprint journal renderer)" >&2 + exit 2 + ;; + stack) + echo "render-artifact: 'stack' is reserved for PR 3 (custom stack graph renderer)" >&2 + exit 2 + ;; +esac + +# Argument parsing. The first non-flag argument after is the +# explicit artifact path. Flags can appear before or after. +while [ $# -gt 0 ]; do + case "$1" in + --latest) USE_LATEST=true ;; + --strict) STRICT=true ;; + --interactive) + echo "render-artifact: --interactive is reserved for PR 4 (copy-only interactive mode)" >&2 + exit 2 + ;; + --manifest-only) MANIFEST_ONLY=true ;; + --out) + shift + [ -z "${1:-}" ] && { echo "render-artifact: --out requires a path" >&2; exit 1; } + OUT_PATH="$1" + ;; + --help|-h) usage; exit 0 ;; + -*) + echo "render-artifact: unknown flag: $1" >&2 + exit 1 + ;; + *) + if [ -n "$ART_PATH" ]; then + echo "render-artifact: extra positional argument: $1" >&2 + exit 1 + fi + ART_PATH="$1" + ;; + esac + shift +done + +# Validate phase. Core phases supported by PR 1: plan. The rest of the +# core phases will be wired in PR 2; report a clear message. +case "$PHASE" in + plan) ;; + think|review|security|qa|ship) + echo "render-artifact: phase '$PHASE' is reserved for PR 2 (core phase renderers)" >&2 + exit 1 + ;; + *) + echo "render-artifact: unsupported phase: $PHASE" >&2 + exit 1 + ;; +esac + +# Resolve the source artifact. --no-session-sync keeps the renderer +# strictly downstream: find-artifact.sh otherwise calls +# `session.sh phase-start` as a convenience for skills, which would +# mutate session.json just because a user viewed an artifact. Codex +# PR 1 pass 7 caught the boundary violation. +if [ -z "$ART_PATH" ] || [ "$USE_LATEST" = true ]; then + ART_PATH=$("$SCRIPT_DIR/find-artifact.sh" "$PHASE" 30 --no-session-sync 2>/dev/null || true) + if [ -z "$ART_PATH" ]; then + echo "render-artifact: no $PHASE artifact found in the last 30 days" >&2 + exit 1 + fi +fi + +if [ ! -f "$ART_PATH" ]; then + echo "render-artifact: artifact not found: $ART_PATH" >&2 + exit 1 +fi + +# JSON must parse. +if ! jq -e '.' "$ART_PATH" >/dev/null 2>&1; then + echo "render-artifact: artifact is not valid JSON: $ART_PATH" >&2 + exit 1 +fi + +# .phase field must match the requested phase. Codex PR 1 pass 6 +# caught: a top-level JSON array or string crashes `jq -r '.phase // +# ""'` with exit 5 under set -e; the documented input-error exit is +# 1. The `?` operator suppresses the path error so non-object JSON +# falls through cleanly and the phase mismatch branch handles it. +ART_PHASE=$(jq -r '.phase? // ""' "$ART_PATH") +if [ "$ART_PHASE" != "$PHASE" ]; then + echo "render-artifact: artifact phase '$ART_PHASE' does not match requested phase '$PHASE': $ART_PATH" >&2 + exit 1 +fi + +# Trust check. integrity_mismatch always fails (exit 3). Under +# --strict, integrity_missing also fails. integrity_missing without +# strict renders with an "unverified" badge. +TRUST=$(nano_artifact_trust "$ART_PATH" 2>/dev/null || echo "not_found") +case "$TRUST" in + verified) ;; + integrity_missing) + if [ "$STRICT" = true ]; then + echo "render-artifact: --strict requires verified trust; source is integrity_missing: $ART_PATH" >&2 + exit 3 + fi + ;; + integrity_mismatch) + echo "render-artifact: source artifact integrity check failed: $ART_PATH" >&2 + exit 3 + ;; + *) + echo "render-artifact: artifact unreadable: $ART_PATH" >&2 + exit 1 + ;; +esac + +# Determine output paths. +TS=$(nano_visual_timestamp) +nano_visual_assert_safe_root + +# Materialize the visual root before any path-safety check. Codex +# caught (PR 1 pass 1) that on a fresh store the visual root does not +# yet exist, so the canonical walk-up in nano_visual_assert_safe_output +# stops at $NANOSTACK_STORE, which is outside the canonical visual +# root path. Pre-creating visual/ keeps the realpath comparison stable. +mkdir -p "$(nano_visual_root)" + +if [ -n "$OUT_PATH" ]; then + nano_visual_assert_safe_output "$OUT_PATH" + HTML_PATH="$OUT_PATH" +else + HTML_PATH=$(nano_visual_html_path "$PHASE" "$TS") +fi +MANIFEST_PATH=$(nano_visual_manifest_path "phase" "$PHASE" "$TS") + +# Codex PR 1 pass 6 caught: even after lexical normalization passes +# the safety check, mkdir -p and mv use the ORIGINAL path, and the +# kernel resolves symlink components literally. A path like +# `visual/link/../evil.html` with `link` pointing outside collapses +# to `visual/evil.html` for the check but resolves to +# `outside/../evil.html` -> `outside/.../evil.html` at write time. +# Reassign HTML_PATH and MANIFEST_PATH to their normalized form so +# the kernel never traverses a `..` after a symlinked component. +HTML_PATH="$(nano_visual_normalize_path "$HTML_PATH")" +MANIFEST_PATH="$(nano_visual_normalize_path "$MANIFEST_PATH")" + +# Refuse any symlink under visual/ on the path to HTML or manifest. +# Codex PR 1 pass 4 caught that mkdir -p / mv would happily write +# through a pre-existing visual/plan symlink to an outside target; +# nano_visual_assert_safe_root only guards the root itself. +nano_visual_assert_safe_descend "$HTML_PATH" +nano_visual_assert_safe_descend "$MANIFEST_PATH" + +mkdir -p "$(dirname "$HTML_PATH")" +mkdir -p "$(dirname "$MANIFEST_PATH")" + +# Manifest contract requires output_path to be absolute. If +# NANOSTACK_STORE was set to a relative path the derived HTML and +# manifest paths inherit the relativity, so canonicalize both before +# they reach the manifest body or the stdout that the caller sees. +# Codex PR 1 pass 3 caught the contract violation in the relative- +# store case. +nano_resolve_abs() { + local p="$1" + case "$p" in + /*) printf '%s\n' "$p" ;; + *) + local dir base + dir="$(cd "$(dirname "$p")" 2>/dev/null && pwd)" + base="$(basename "$p")" + if [ -n "$dir" ]; then + printf '%s/%s\n' "$dir" "$base" + else + printf '%s\n' "$p" + fi + ;; + esac +} +HTML_PATH="$(nano_resolve_abs "$HTML_PATH")" +MANIFEST_PATH="$(nano_resolve_abs "$MANIFEST_PATH")" +ART_PATH="$(nano_resolve_abs "$ART_PATH")" + +# Pull the stored integrity hash. It is recorded in the manifest so a +# later check can decide whether the rendered view's source still +# matches what was on disk at render time. +SRC_INTEGRITY=$(jq -r '.integrity // ""' "$ART_PATH" 2>/dev/null || echo "") + +# Optional schema validation. A failing schema does not block the +# render; it adds a visible warning to the HTML and is recorded in the +# manifest. This is intentional: a malformed artifact still benefits +# from being inspectable. +SCHEMA_OK=true +SCHEMA_ERR="" +if declare -F nano_validate_artifact >/dev/null 2>&1; then + if ! SCHEMA_ERR=$(nano_validate_artifact "$PHASE" "$(cat "$ART_PATH")" 2>&1); then + SCHEMA_OK=false + fi +fi + +# ─── Phase renderers ──────────────────────────────────────── +# +# Each function emits the body between page_start and page_end. They +# read named fields with jq, escape every scalar before printing, and +# render "Not recorded" / "None recorded" for absent values. + +render_plan_body() { + local artifact="$1" + # Normalize: legacy --from-session plan artifacts store .summary as + # a string and may omit .context_checkpoint entirely. Schema + # validation already surfaces a warning above; here we coerce the + # shape so the body still renders ("Not recorded" / "None recorded") + # instead of jq crashing under set -e. Codex caught this on PR 1 + # pass 1: without the coercion, `render-artifact.sh plan --latest` + # aborted on legacy artifacts the renderer was meant to inspect. + local norm + norm=$(jq -c ' + .summary = (if (.summary | type) == "object" then .summary else {} end) + | .context_checkpoint = (if (.context_checkpoint | type) == "object" then .context_checkpoint else {} end) + | .summary.planned_files = (if (.summary.planned_files | type) == "array" then .summary.planned_files else [] end) + | .summary.risks = (if (.summary.risks | type) == "array" then .summary.risks else [] end) + | .summary.out_of_scope = (if (.summary.out_of_scope | type) == "array" then .summary.out_of_scope else [] end) + | .context_checkpoint.key_files = (if (.context_checkpoint.key_files | type) == "array" then .context_checkpoint.key_files else [] end) + | .context_checkpoint.decisions_made= (if (.context_checkpoint.decisions_made| type) == "array" then .context_checkpoint.decisions_made else [] end) + | .context_checkpoint.open_questions= (if (.context_checkpoint.open_questions| type) == "array" then .context_checkpoint.open_questions else [] end) + ' "$artifact") + + local goal scope approval + goal=$(printf '%s' "$norm" | jq -r '.summary.goal // "Not recorded"' | nano_html_escape) + scope=$(printf '%s' "$norm" | jq -r '.summary.scope // "Not recorded"' | nano_html_escape) + approval=$(printf '%s' "$norm" | jq -r '.summary.plan_approval // "Not recorded"' | nano_html_escape) + + cat < +

Summary

+
+
Goal
$goal
+
Scope
$scope
+
Approval
$approval
+
+ +HTML + + # Planned files. Reads from the normalized JSON so a legacy + # artifact with .summary as a string still renders an empty list + # instead of crashing. + local files_count + files_count=$(printf '%s' "$norm" | jq -r '.summary.planned_files | length') + printf '
\n

Planned files (%s)

\n' \ + "$(printf '%s' "$files_count" | nano_html_escape)" + if [ "$files_count" = "0" ]; then + printf '

None recorded

\n' + else + printf '
    \n' + printf '%s' "$norm" | jq -r '.summary.planned_files | map(tostring) | sort | .[]' | while IFS= read -r f; do + printf '
  • %s
  • \n' "$(printf '%s' "$f" | nano_html_escape)" + done + printf '
\n' + fi + printf '
\n' + + # Risks + local risks_count + risks_count=$(printf '%s' "$norm" | jq -r '.summary.risks | length') + printf '
\n

Risks (%s)

\n' \ + "$(printf '%s' "$risks_count" | nano_html_escape)" + if [ "$risks_count" = "0" ]; then + printf '

None recorded

\n' + else + printf '
    \n' + printf '%s' "$norm" | jq -r '.summary.risks | .[] | tostring' | while IFS= read -r r; do + printf '
  • %s
  • \n' "$(printf '%s' "$r" | nano_html_escape)" + done + printf '
\n' + fi + printf '
\n' + + # Out-of-scope + local oos_count + oos_count=$(printf '%s' "$norm" | jq -r '.summary.out_of_scope | length') + printf '
\n

Out of scope (%s)

\n' \ + "$(printf '%s' "$oos_count" | nano_html_escape)" + if [ "$oos_count" = "0" ]; then + printf '

None recorded

\n' + else + printf '
    \n' + printf '%s' "$norm" | jq -r '.summary.out_of_scope | .[] | tostring' | while IFS= read -r item; do + printf '
  • %s
  • \n' "$(printf '%s' "$item" | nano_html_escape)" + done + printf '
\n' + fi + printf '
\n' + + # Context checkpoint + local ck_summary + ck_summary=$(printf '%s' "$norm" | jq -r '.context_checkpoint.summary // "Not recorded"' | nano_html_escape) + printf '
\n

Context checkpoint

\n' + printf '

%s

\n' "$ck_summary" + local kf_count + kf_count=$(printf '%s' "$norm" | jq -r '.context_checkpoint.key_files | length') + if [ "$kf_count" != "0" ]; then + printf '

Key files

\n
    \n' + printf '%s' "$norm" | jq -r '.context_checkpoint.key_files | .[] | tostring' | while IFS= read -r kf; do + printf '
  • %s
  • \n' "$(printf '%s' "$kf" | nano_html_escape)" + done + printf '
\n' + fi + local dec_count + dec_count=$(printf '%s' "$norm" | jq -r '.context_checkpoint.decisions_made | length') + if [ "$dec_count" != "0" ]; then + printf '

Decisions made

\n
    \n' + printf '%s' "$norm" | jq -r '.context_checkpoint.decisions_made | .[] | tostring' | while IFS= read -r d; do + printf '
  • %s
  • \n' "$(printf '%s' "$d" | nano_html_escape)" + done + printf '
\n' + fi + local oq_count + oq_count=$(printf '%s' "$norm" | jq -r '.context_checkpoint.open_questions | length') + if [ "$oq_count" != "0" ]; then + printf '

Open questions

\n
    \n' + printf '%s' "$norm" | jq -r '.context_checkpoint.open_questions | .[] | tostring' | while IFS= read -r q; do + printf '
  • %s
  • \n' "$(printf '%s' "$q" | nano_html_escape)" + done + printf '
\n' + fi + printf '
\n' +} + +# ─── Atomic write ─────────────────────────────────────────── +# Codex PR 1 pass 8: a predictable temp name (HTML_PATH.tmp.$$) lets +# an attacker pre-create a symlink at that path; the redirect would +# follow it and write outside visual/. Use mktemp to create the +# files inside the already-validated parent directory; mktemp uses +# O_EXCL so a symlink-pre-creation race fails with a clear error +# instead of silently following the link. +TMP_HTML=$(mktemp "$HTML_PATH.tmp.XXXXXX" 2>/dev/null) || { + echo "render-artifact: failed to create secure temp for HTML: $HTML_PATH" >&2 + exit 4 +} +TMP_MFST=$(mktemp "$MANIFEST_PATH.tmp.XXXXXX" 2>/dev/null) || { + rm -f "$TMP_HTML" + echo "render-artifact: failed to create secure temp for manifest: $MANIFEST_PATH" >&2 + exit 4 +} +cleanup() { + rm -f "$TMP_HTML" "$TMP_MFST" 2>/dev/null || true +} +trap cleanup EXIT + +# ─── Manifest ─────────────────────────────────────────────── +# Build manifest first; if the HTML render fails we can still leave a +# clean state. We move both files into place at the very end. + +CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# Use jq to construct the manifest so all string escaping is correct. +jq -n \ + --arg schema_version "1" \ + --arg kind "phase" \ + --arg phase "$PHASE" \ + --argjson custom_phase false \ + --arg format "html" \ + --argjson interactive false \ + --argjson strict "$($STRICT && echo true || echo false)" \ + --arg src_phase "$ART_PHASE" \ + --arg src_path "$ART_PATH" \ + --arg src_integrity "$SRC_INTEGRITY" \ + --arg src_trust "$TRUST" \ + --arg output_path "$HTML_PATH" \ + --arg renderer_name "$NANO_VISUAL_RENDERER_NAME" \ + --arg renderer_version "$NANO_VISUAL_RENDERER_VERSION" \ + --arg created_at "$CREATED_AT" \ + --argjson schema_valid "$($SCHEMA_OK && echo true || echo false)" \ + --arg schema_error "$SCHEMA_ERR" \ + '{ + schema_version: $schema_version, + kind: $kind, + phase: $phase, + custom_phase: $custom_phase, + format: $format, + interactive: $interactive, + strict: $strict, + source_artifacts: [{ + phase: $src_phase, + path: $src_path, + integrity: $src_integrity, + trust: $src_trust + }], + output_path: $output_path, + renderer: { name: $renderer_name, version: $renderer_version }, + schema_valid: $schema_valid, + schema_error: (if $schema_error == "" then null else $schema_error end), + created_at: $created_at + }' > "$TMP_MFST" + +if [ "$MANIFEST_ONLY" = true ]; then + # Codex PR 1 pass 9: the manifest-only branch must not leave the + # mktemp'd HTML temp file behind. Clean it before disabling the + # cleanup trap. + rm -f "$TMP_HTML" + mv "$TMP_MFST" "$MANIFEST_PATH" + trap - EXIT + echo "$MANIFEST_PATH" + exit 0 +fi + +# ─── HTML ─────────────────────────────────────────────────── +{ + nano_visual_page_start "$PHASE" "$TRUST" "static" + + if [ "$SCHEMA_OK" = false ]; then + printf '
%s
\n' \ + "$(printf 'Schema validation: %s' "$SCHEMA_ERR" | nano_html_escape)" + fi + + case "$PHASE" in + plan) render_plan_body "$ART_PATH" ;; + esac + + nano_visual_page_end "$ART_PATH" "$MANIFEST_PATH" "$SRC_INTEGRITY" +} > "$TMP_HTML" + +mv "$TMP_HTML" "$HTML_PATH" +mv "$TMP_MFST" "$MANIFEST_PATH" +trap - EXIT + +echo "$HTML_PATH" diff --git a/ci/check-visual-artifact-templates.sh b/ci/check-visual-artifact-templates.sh new file mode 100755 index 0000000..4d236ef --- /dev/null +++ b/ci/check-visual-artifact-templates.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# check-visual-artifact-templates.sh — Static safety lint for the +# visual artifact layer. +# +# Greps the renderer and shared shell for patterns that would break +# the Visual Artifact Contract: +# +# - external network references (http://, https://) in templates +# - script-loading or XHR-style APIs +# - browser-side storage / cookie access +# - eval / new Function +# - missing CSP / data-nanostack-visual markers in the shared shell +# - trust badge wording must use the locked strings +# +# Scope is intentionally narrow: only files that emit HTML for the +# visual layer. The list is hardcoded so adding a new renderer file +# requires touching this script (auditable diff). + +set -e + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO" + +FILES=( + "bin/render-artifact.sh" + "bin/lib/visual-render.sh" + "bin/lib/html-escape.sh" +) + +PASS=0 +FAIL=0 +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[0;90m' +NC='\033[0m' + +check_absent() { + local name="$1"; local pattern="$2"; shift 2 + local files=("$@") + if grep -nE "$pattern" "${files[@]}" >/dev/null 2>&1; then + FAIL=$((FAIL+1)) + printf " ${RED}FAIL${NC} %s\n" "$name" + printf " ${DIM}pattern: %s${NC}\n" "$pattern" + grep -nE "$pattern" "${files[@]}" | sed 's/^/ /' || true + else + PASS=$((PASS+1)) + printf " ${GREEN}OK${NC} %s\n" "$name" + fi +} + +check_present() { + local name="$1"; local pattern="$2"; shift 2 + local files=("$@") + if grep -nE "$pattern" "${files[@]}" >/dev/null 2>&1; then + PASS=$((PASS+1)) + printf " ${GREEN}OK${NC} %s\n" "$name" + else + FAIL=$((FAIL+1)) + printf " ${RED}FAIL${NC} %s (pattern missing)\n" "$name" + printf " ${DIM}pattern: %s${NC}\n" "$pattern" + fi +} + +printf "\n${GREEN}=== Visual artifact template safety ===${NC}\n\n" + +# 1. No external URLs in renderer source. Comments are allowed for +# contract refs but the contract doc has no URLs either. +check_absent "no http(s) URLs in renderer/templates" \ + 'https?://' "${FILES[@]}" + +# 2. No script-loading or XHR-style APIs. +check_absent "no ", + "scope": "small", + "planned_files": ["src/.ts"], + "plan_approval": "manual", + "risks": ["\" onclick=\"alert(1)"] + }, + "context_checkpoint": { + "summary": "bold?", + "key_files": [], + "decisions_made": [], + "open_questions": [] + } + }' >/dev/null +} + +printf "\n${GREEN}=== Visual Artifacts v1 PR 1 contract ===${NC}\n\n" + +# ─── Cell 1: happy path /plan render ──────────────────────── +printf " ${DIM}Cell 1: happy path /plan render${NC}\n" +PROJ="$TMP_ROOT/cell1" +setup_project "$PROJ" +export NANOSTACK_STORE="$PROJ/.nanostack" +mkdir -p "$NANOSTACK_STORE" +(cd "$PROJ" && save_valid_plan "$NANOSTACK_STORE") +set +e +HTML=$(cd "$PROJ" && "$REPO/bin/render-artifact.sh" plan --latest 2>/dev/null) +RC=$? +set -e +assert_exit "render plan --latest succeeds" 0 test "$RC" = 0 +assert_true "html file exists" test -f "$HTML" +assert_contains "doctype" "$HTML" "" +assert_contains "viewport meta" "$HTML" "viewport" +assert_contains "CSP header" "$HTML" "default-src 'none'" +assert_contains "data-nanostack-visual attr" "$HTML" 'data-nanostack-visual="1"' +assert_contains "data-phase attr" "$HTML" 'data-phase="plan"' +assert_contains "trust badge data-trust=verified" "$HTML" 'data-trust="verified"' +assert_contains "trust badge text 'verified'" "$HTML" '>verified<' +assert_contains "goal rendered" "$HTML" "Visual artifacts v1" +assert_contains "planned file rendered" "$HTML" "bin/render-artifact.sh" +assert_contains "risk rendered" "$HTML" "Renderer escapes incomplete" +assert_contains "context summary rendered" "$HTML" "Local HTML view" +assert_contains "provenance footer" "$HTML" 'data-testid="visual-provenance"' +assert_contains "source artifact path attr" "$HTML" 'data-testid="source-artifact-path"' +assert_contains "manifest path attr" "$HTML" 'data-testid="visual-manifest-path"' + +# Manifest schema checks. +MFST=$(ls "$NANOSTACK_STORE/visual/manifests/"*.manifest.json 2>/dev/null | head -1) +assert_true "manifest file exists" test -f "$MFST" +assert_true "manifest schema_version == 1" sh -c "[ \"\$(jq -r .schema_version '$MFST')\" = '1' ]" +assert_true "manifest kind == phase" sh -c "[ \"\$(jq -r .kind '$MFST')\" = 'phase' ]" +assert_true "manifest format == html" sh -c "[ \"\$(jq -r .format '$MFST')\" = 'html' ]" +assert_true "manifest phase == plan" sh -c "[ \"\$(jq -r .phase '$MFST')\" = 'plan' ]" +assert_true "manifest custom_phase == false" sh -c "[ \"\$(jq -r .custom_phase '$MFST')\" = 'false' ]" +assert_true "manifest source_artifacts length >= 1" sh -c "[ \"\$(jq -r '.source_artifacts | length' '$MFST')\" -ge 1 ]" +assert_true "manifest source trust == verified" sh -c "[ \"\$(jq -r '.source_artifacts[0].trust' '$MFST')\" = 'verified' ]" +assert_true "manifest renderer.version present" sh -c "[ -n \"\$(jq -r '.renderer.version' '$MFST')\" ]" +assert_true "manifest output_path absolute" sh -c "[ \"\$(jq -r .output_path '$MFST')\" = '$HTML' ]" + +# ─── Cell 2: XSS / escape contract ────────────────────────── +printf "\n ${DIM}Cell 2: XSS / escape contract${NC}\n" +PROJ="$TMP_ROOT/cell2" +setup_project "$PROJ" +export NANOSTACK_STORE="$PROJ/.nanostack" +mkdir -p "$NANOSTACK_STORE" +(cd "$PROJ" && save_malicious_plan "$NANOSTACK_STORE") +HTML=$(cd "$PROJ" && "$REPO/bin/render-artifact.sh" plan --latest) +assert_not_contains "no raw