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
4 changes: 4 additions & 0 deletions bin/lib/visual-render.sh
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ nano_visual_page_start() {
.chip.sev-ok { color: var(--ok); border-color: var(--ok); }
.pr-link { color: var(--info); }
.unsafe-url { color: var(--bad); font-family: monospace; }
.copy-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.copy-btn { background: var(--panel-2); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit; font-size: 0.9rem; }
.copy-btn:hover, .copy-btn:focus { background: var(--panel); border-color: var(--info); color: var(--info); }
.copy-status { color: var(--muted); font-size: 0.85rem; }
</style>
</head>
<body>
Expand Down
217 changes: 208 additions & 9 deletions bin/render-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
# [--interactive] [--out <path>]
# [--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).
# PR 1 scope: /plan renderer + manifest + safety locks.
# PR 2 wires think/review/security/qa/ship.
# PR 3 wires journal/stack aggregate views.
# PR 4 adds --interactive for copy-only buttons on /plan and /review
# (copy as prompt / Markdown / JSON patch). No filesystem writes,
# no network calls, no form submission.

set -e

Expand Down Expand Up @@ -49,7 +51,10 @@ PR 1+2+3 scope:

Flags:
--strict require nano_artifact_trust == verified
--interactive reserved for PR 4 (exit 2)
--interactive enable copy-only interactive controls (PR 4 scope:
/plan and /review get "copy as prompt", "copy as Markdown",
and "copy as JSON patch" buttons; no filesystem writes,
no network calls, no form submission)
--out <path> 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)
Expand Down Expand Up @@ -90,8 +95,11 @@ while [ $# -gt 0 ]; do
--latest) USE_LATEST=true ;;
--strict) STRICT=true ;;
--interactive)
echo "render-artifact: --interactive is reserved for PR 4 (copy-only interactive mode)" >&2
exit 2
# Codex PR 4 pass 1: --interactive widens the CSP to allow
# inline script. Only enable it for phases that actually emit
# copy controls (plan and review in v1) so the wider policy
# never applies where it has no purpose.
INTERACTIVE=true
;;
--manifest-only) MANIFEST_ONLY=true ;;
--out)
Expand Down Expand Up @@ -166,6 +174,21 @@ case "$PHASE" in
;;
esac

# Codex PR 4 pass 1: --interactive is supported only for /plan and
# /review in v1. Reject elsewhere with exit 1 so the wider CSP
# (script-src 'unsafe-inline') never applies to phases that emit no
# copy controls. The architect's spec already scoped interactive UI
# to those two phases.
if [ "$INTERACTIVE" = true ]; then
case "$PHASE" in
plan|review) ;;
*)
echo "render-artifact: --interactive is supported only for /plan and /review in PR 4" >&2
exit 1
;;
esac
fi

# Default the journal date to today if neither --today nor --date
# was supplied (codex PR 3 pass 2: bare `journal` left this empty
# and the page banner read "journal · " with the filename containing
Expand Down Expand Up @@ -487,6 +510,39 @@ HTML
printf ' </ul>\n'
fi
printf ' </section>\n'

# PR 4 interactive: copy buttons that export the plan back to the
# agent in the requested shape. Built from the normalized JSON so
# malformed shapes still produce useful payloads.
if [ "${INTERACTIVE:-false}" = true ]; then
local plan_prompt plan_markdown plan_json_patch
# Coerce every payload field with tostring/map(tostring) so a
# schema-warning artifact (numbers, objects, nulls in unexpected
# places) cannot crash jq under set -e. Codex PR 4 pass 1.
plan_prompt=$(printf '%s' "$norm" | jq -r '
"Approve the plan and continue.\n" +
"Goal: " + ((.summary.goal // "Not recorded") | tostring) + "\n" +
"Scope: " + ((.summary.scope // "Not recorded") | tostring) + "\n" +
"Planned files:\n - " + ((.summary.planned_files // []) | map(tostring) | join("\n - ")) + "\n" +
"Risks:\n - " + ((.summary.risks // []) | map(tostring) | join("\n - "))
')
plan_markdown=$(printf '%s' "$norm" | jq -r '
"## Plan summary\n\n" +
"- **Goal:** " + ((.summary.goal // "Not recorded") | tostring) + "\n" +
"- **Scope:** " + ((.summary.scope // "Not recorded") | tostring) + "\n" +
"- **Approval:** " + ((.summary.plan_approval // "Not recorded") | tostring) + "\n\n" +
"### Planned files\n\n" +
((.summary.planned_files // []) | map(tostring) | map("- `" + . + "`") | join("\n"))
')
plan_json_patch=$(printf '%s' "$norm" | jq -c '{
action: "plan.approve",
goal: .summary.goal,
scope: .summary.scope,
planned_files: .summary.planned_files,
out_of_scope: .summary.out_of_scope
}')
render_copy_actions "$plan_prompt" "$plan_markdown" "$plan_json_patch"
fi
}

# Shared helper: find the latest artifact for <phase>, surfacing
Expand Down Expand Up @@ -678,6 +734,44 @@ HTML
render_findings_section "$norm" "Review findings"

render_context_checkpoint "$norm"

# PR 4 interactive: review triage payloads. The agent can paste
# the prompt back to drive a re-review or accept the findings
# as-is.
if [ "${INTERACTIVE:-false}" = true ]; then
local review_prompt review_markdown review_json_patch
# Coerce every concatenated field with tostring (codex PR 4
# pass 1). A schema-warning review with .summary.blocking as a
# string or .scope_drift.status as a number would otherwise
# crash jq under set -e.
review_prompt=$(printf '%s' "$norm" | jq -r '
"Address the review findings before /ship.\n" +
"Summary: " + ((.summary.blocking // 0) | tostring) + " blocking / " +
((.summary.should_fix // 0) | tostring) + " should-fix / " +
((.summary.nitpicks // 0) | tostring) + " nitpicks\n" +
"Scope drift: " + ((.scope_drift.status // "unknown") | tostring) + "\n" +
"Now-fix:\n - " +
((.findings // []) | map(select(.severity == "blocking" or .severity == "should_fix")) | map((.description // "(no description)") | tostring) | join("\n - "))
')
review_markdown=$(printf '%s' "$norm" | jq -r '
"## Review findings\n\n" +
"| Severity | Count |\n|----|----|\n" +
"| Blocking | " + ((.summary.blocking // 0) | tostring) + " |\n" +
"| Should fix | " + ((.summary.should_fix // 0) | tostring) + " |\n" +
"| Nitpicks | " + ((.summary.nitpicks // 0) | tostring) + " |\n" +
"| Positive | " + ((.summary.positive // 0) | tostring) + " |\n\n" +
"### Now-fix\n\n" +
((.findings // []) | map(select(.severity == "blocking" or .severity == "should_fix"))
| map("- **" + ((.severity // "?") | tostring) + "**: " + ((.description // "") | tostring)) | join("\n"))
')
review_json_patch=$(printf '%s' "$norm" | jq -c '{
action: "review.triage",
blocking_ids: ((.findings // []) | map(select(.severity == "blocking") | .id)),
should_fix_ids: ((.findings // []) | map(select(.severity == "should_fix") | .id)),
scope_drift: .scope_drift.status
}')
render_copy_actions "$review_prompt" "$review_markdown" "$review_json_patch"
fi
}

render_security_body() {
Expand Down Expand Up @@ -897,6 +991,109 @@ render_findings_section() {
printf ' </section>\n'
}

# ─── Copy-only interactive controls (PR 4) ──────────────────
#
# Emits an actions card with three buttons (copy as prompt / copy as
# Markdown / copy as JSON patch). Each button copies a pre-computed
# payload to the clipboard. The script uses only
# navigator.clipboard.writeText; no fetch, no XHR, no localStorage,
# no form submission. CI lockes those forbidden APIs.
#
# Payloads are passed as JSON-encoded strings (via nano_json_string)
# so embedded quotes, newlines, and control characters stay safe.
# Each payload is also rendered as a <details><pre>...</pre> block
# so the user can read and copy manually if the browser blocks
# clipboard access (e.g. file:// without explicit permission).
render_copy_actions() {
local prompt_payload="$1"
local markdown_payload="$2"
local json_payload="$3"
if [ "${INTERACTIVE:-false}" != true ]; then
return 0
fi

# Encode each payload as a JSON string literal so the inline JS
# can read it via JSON.parse. Critically: when embedding a JSON
# string INSIDE <script>...</script>, several HTML parser states
# can swallow or re-interpret the script body. Codex PR 4 pass 3
# caught that escaping only `</` left the `<!--<script>` sequence
# able to put the parser into the "script data double escaped"
# state, which then eats the legitimate closing `</script>`.
#
# The fix: escape EVERY `<` as `<` in the script-embedded
# payload. JSON.parse reads `<` as the literal `<`, so the
# clipboard content the user pastes is unchanged. The HTML parser
# never sees a `<` inside the script body and stays in the
# standard "script data" state until the real closing tag.
_js_safe_for_script() {
sed 's|<|\\u003c|g'
}
local p_js m_js j_js
p_js=$(printf '%s' "$prompt_payload" | nano_json_string | _js_safe_for_script)
m_js=$(printf '%s' "$markdown_payload" | nano_json_string | _js_safe_for_script)
j_js=$(printf '%s' "$json_payload" | nano_json_string | _js_safe_for_script)

# Visible escaped versions for the manual-copy <pre> blocks.
local p_html m_html j_html
p_html=$(printf '%s' "$prompt_payload" | nano_html_escape)
m_html=$(printf '%s' "$markdown_payload" | nano_html_escape)
j_html=$(printf '%s' "$json_payload" | nano_html_escape)

cat <<HTML
<section class="card" data-testid="copy-actions" data-interactive="1">
<h2>Copy back to the agent</h2>
<p class="muted">Local clipboard only. No data leaves your machine.</p>
<div class="copy-row">
<button type="button" class="copy-btn" data-payload-id="copy-prompt" data-format="prompt">Copy as prompt</button>
<button type="button" class="copy-btn" data-payload-id="copy-markdown" data-format="markdown">Copy as Markdown</button>
<button type="button" class="copy-btn" data-payload-id="copy-json" data-format="json-patch">Copy as JSON patch</button>
<span class="copy-status" data-testid="copy-status" aria-live="polite"></span>
</div>
<details>
<summary>Prompt payload (manual copy)</summary>
<pre data-testid="copy-prompt-pre">$p_html</pre>
</details>
<details>
<summary>Markdown payload (manual copy)</summary>
<pre data-testid="copy-markdown-pre">$m_html</pre>
</details>
<details>
<summary>JSON-patch payload (manual copy)</summary>
<pre data-testid="copy-json-pre">$j_html</pre>
</details>
<script>
/* Interactive contract: clipboard write only. No fetch, no XHR, no
form submission, no storage, no eval. CI greps this file (and the
shared shell) for forbidden APIs. */
(function () {
var payloads = {
"copy-prompt": $p_js,
"copy-markdown": $m_js,
"copy-json": $j_js
};
var buttons = document.querySelectorAll('button.copy-btn');
var status = document.querySelector('[data-testid="copy-status"]');
function announce(msg) { if (status) { status.textContent = msg; } }
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-payload-id');
var text = payloads[id] || '';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { announce('Copied. Paste into the agent.'); },
function () { announce('Clipboard blocked. Use the manual copy below.'); }
);
} else {
announce('Clipboard API unavailable. Use the manual copy below.');
}
});
});
})();
</script>
</section>
HTML
}

# Shared helper for the context checkpoint card. Every core phase has
# the same checkpoint shape; centralizing keeps the visual identity
# consistent.
Expand Down Expand Up @@ -1563,7 +1760,9 @@ CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# survives outside.
if [ "$MANIFEST_ONLY" != true ]; then
{
nano_visual_page_start "$PHASE" "$TRUST" "static"
csp_mode="static"
[ "$INTERACTIVE" = true ] && csp_mode="interactive"
nano_visual_page_start "$PHASE" "$TRUST" "$csp_mode"

if [ "$SCHEMA_OK" = false ]; then
printf ' <div class="schema-warning" data-testid="schema-warning">%s</div>\n' \
Expand Down Expand Up @@ -1618,7 +1817,7 @@ jq -n \
--arg phase "$PHASE" \
--argjson custom_phase false \
--arg format "html" \
--argjson interactive false \
--argjson interactive "$($INTERACTIVE && echo true || echo false)" \
--argjson strict "$($STRICT && echo true || echo false)" \
--argjson source_artifacts "$SOURCE_ARTIFACTS_JSON" \
--arg output_path "$HTML_PATH" \
Expand Down
39 changes: 36 additions & 3 deletions ci/check-visual-artifact-templates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ check_absent() {
local name="$1"; local pattern="$2"; shift 2
local files=("$@")
# `-e -- ` so a pattern starting with `-` (e.g. `--no-session-sync`)
# is not interpreted as a grep flag.
if grep -nE -e "$pattern" -- "${files[@]}" >/dev/null 2>&1; then
# is not interpreted as a grep flag. Exclude comment lines (PR 4):
# documenting the forbidden APIs in comments is fine, only actual
# source uses fail the lint.
local hits
hits=$(grep -nE -e "$pattern" -- "${files[@]}" 2>/dev/null \
| grep -v '^[^:]*:[0-9]*:[[:space:]]*#' \
|| true)
if [ -n "$hits" ]; then
FAIL=$((FAIL+1))
printf " ${RED}FAIL${NC} %s\n" "$name"
printf " ${DIM}pattern: %s${NC}\n" "$pattern"
grep -nE -e "$pattern" -- "${files[@]}" | sed 's/^/ /' || true
printf '%s\n' "$hits" | sed 's/^/ /'
else
PASS=$((PASS+1))
printf " ${GREEN}OK${NC} %s\n" "$name"
Expand Down Expand Up @@ -201,6 +207,33 @@ check_absent "no SVG <image href" \
check_absent "no SVG <foreignObject" \
'<foreignObject' "bin/render-artifact.sh" "bin/lib/visual-render.sh"

# 17. PR 4: interactive copy-only contract. The inline script body
# must use ONLY navigator.clipboard.writeText. Everything that
# could exfiltrate or persist is forbidden in renderer code.
check_present "render_copy_actions uses navigator.clipboard.writeText" \
'navigator\.clipboard\.writeText' "bin/render-artifact.sh"
check_present "render_copy_actions escapes </ in JS payloads" \
'_js_safe_for_script' "bin/render-artifact.sh"
check_present "interactive CSP defines script-src" \
"script-src 'unsafe-inline'" "bin/lib/visual-render.sh"

# 18. PR 4: confirm the static CSP path does NOT include
# script-src. The shared helper has two arms in nano_visual_csp;
# only the interactive arm should mention script-src.
check_present "nano_visual_csp has separate static and interactive arms" \
'interactive\)' "bin/lib/visual-render.sh"

# 19. PR 4: no forbidden APIs anywhere in the renderer code that
# could end up in the rendered HTML. This re-runs the absence sweep
# already in check 2-4 but explicitly under the interactive-mode
# expansion lens.
check_absent "no document.write" \
'document\.write' "bin/render-artifact.sh" "bin/lib/visual-render.sh"
check_absent "no window.open" \
'window\.open' "bin/render-artifact.sh" "bin/lib/visual-render.sh"
check_absent "no addEventListener for form submission" \
"addEventListener\\(['\"]submit" "bin/render-artifact.sh" "bin/lib/visual-render.sh"

TOTAL=$((PASS+FAIL))
printf "\n %s/%s checks passed\n" "$PASS" "$TOTAL"
if [ "$FAIL" -gt 0 ]; then
Expand Down
Loading
Loading