Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 13 additions & 2 deletions bin/find-artifact.sh
Original file line number Diff line number Diff line change
@@ -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 <phase> [max-age-days] [--verify] [--require-integrity]
# Usage: find-artifact.sh <phase> [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
#
Expand All @@ -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: <path>" (mismatch) or
# "INTEGRITY MISSING: <path>" (no .integrity field).
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions bin/lib/html-escape.sh
Original file line number Diff line number Diff line change
@@ -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(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
gsub(/"/, "\\&quot;")
gsub(/\047/, "\\&#39;")
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(/&/, "\\&amp;")
gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;")
gsub(/"/, "\\&quot;")
gsub(/\047/, "\\&#39;")
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
}
Loading
Loading